1use std::time::Duration;
6
7use crate::cache::{Cache, acquire_lock_async};
8use crate::error::{AppError, Result};
9use crate::usage::ZaiSnapshot;
10
11use super::types::Envelope;
12
13pub const QUOTA_URL: &str = "https://api.z.ai/api/monitor/usage/quota/limit";
14const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
15const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
16
17#[derive(Debug, Clone)]
18pub struct Endpoints {
19 pub quota: String,
20}
21
22impl Default for Endpoints {
23 fn default() -> Self {
24 Self {
25 quota: QUOTA_URL.into(),
26 }
27 }
28}
29
30pub type FetchOutcome = crate::outcome::Outcome<ZaiSnapshot>;
33
34pub async fn fetch_snapshot(
35 client: &reqwest::Client,
36 api_key: &str,
37 cache: &Cache,
38 endpoints: &Endpoints,
39 cache_ttl: Duration,
40 config_plan_tier: Option<&str>,
41) -> Result<FetchOutcome> {
42 cache.ensure_dir()?;
43 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
44
45 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
46 && let Ok(outcome) = reuse(bytes, cache, false, config_plan_tier)
47 {
48 return Ok(outcome);
49 }
50 match fetch_live(client, &endpoints.quota, api_key).await {
54 Ok((bytes, env)) => {
55 cache.write_payload(&bytes)?;
59 Ok(crate::outcome::Outcome::fresh(
60 env.into_snapshot(config_plan_tier),
61 ))
62 }
63 Err(e) if e.is_transient() => fallback_silent(cache, config_plan_tier, e),
64 Err(AppError::Http { status, body }) => {
65 cache.mark_stale();
66 let last_error = Some(cache.write_last_error(status, &body));
67 fallback_with_error(
68 cache,
69 last_error,
70 config_plan_tier,
71 AppError::Http { status, body },
72 )
73 }
74 Err(e) => {
75 cache.mark_stale();
76 let last_error = Some(cache.write_last_error(0, &e.to_string()));
77 fallback_with_error(cache, last_error, config_plan_tier, e)
78 }
79 }
80}
81
82fn reuse(bytes: Vec<u8>, cache: &Cache, stale: bool, tier: Option<&str>) -> Result<FetchOutcome> {
83 Ok(crate::outcome::Outcome::cached(
84 parse_cache(&bytes, tier)?,
85 cache,
86 stale,
87 ))
88}
89
90fn parse_cache(bytes: &[u8], tier: Option<&str>) -> Result<ZaiSnapshot> {
91 let env: Envelope = serde_json::from_slice(bytes)?;
92 env.check_ok()?;
94 Ok(env.into_snapshot(tier))
95}
96
97fn fallback_silent(cache: &Cache, tier: Option<&str>, original: AppError) -> Result<FetchOutcome> {
98 crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, tier))
99}
100
101fn fallback_with_error(
102 cache: &Cache,
103 last_error: Option<(u16, String)>,
104 tier: Option<&str>,
105 original: AppError,
106) -> Result<FetchOutcome> {
107 crate::outcome::fallback(cache, last_error, original, |bytes| {
108 parse_cache(bytes, tier)
109 })
110}
111
112async fn fetch_live(
115 client: &reqwest::Client,
116 url: &str,
117 api_key: &str,
118) -> Result<(Vec<u8>, Envelope)> {
119 let resp = tokio::time::timeout(
120 HTTP_TIMEOUT,
121 client
122 .get(url)
123 .header("Authorization", api_key) .header("Accept-Language", "en-US,en")
125 .header("Content-Type", "application/json")
126 .send(),
127 )
128 .await
129 .map_err(|_| AppError::Transport(format!("zai timeout: {url}")))??;
130
131 let status = resp.status();
132 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
133
134 if !status.is_success() {
135 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
136 return Err(AppError::Http {
137 status: status.as_u16(),
138 body,
139 });
140 }
141
142 let env: Envelope = serde_json::from_slice(&bytes)
146 .map_err(|e| AppError::Schema(format!("zai quota response: {e}")))?;
147 env.check_ok()?;
148 Ok((bytes, env))
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use tempfile::TempDir;
155
156 fn cache_fixture() -> (TempDir, Cache) {
157 let td = TempDir::new().unwrap();
158 let cache = Cache::at(td.path().join("zai"));
159 cache.ensure_dir().unwrap();
160 (td, cache)
161 }
162
163 const GOOD_BODY: &str = r#"{"code":200,"msg":"Operation successful","data":{
164 "limits":[{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42}],
165 "level":"pro"},"success":true}"#;
166
167 #[tokio::test]
168 async fn in_band_failure_on_200_is_rejected_and_keeps_the_good_cache() {
169 let mut server = mockito::Server::new_async().await;
173 server
174 .mock("GET", "/api/monitor/usage/quota/limit")
175 .with_status(200)
176 .with_body(r#"{"code":401,"msg":"Unauthorized","data":null,"success":false}"#)
177 .create_async()
178 .await;
179
180 let (_td, cache) = cache_fixture();
181 cache.write_payload(GOOD_BODY.as_bytes()).unwrap();
182
183 let client = reqwest::Client::new();
184 let endpoints = Endpoints {
185 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
186 };
187 let out = fetch_snapshot(
188 &client,
189 "k",
190 &cache,
191 &endpoints,
192 Duration::from_secs(0),
193 None,
194 )
195 .await
196 .unwrap();
197
198 assert!(out.stale);
200 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
201 let cached = String::from_utf8(cache.maybe_payload().unwrap().unwrap()).unwrap();
203 assert!(cached.contains("\"success\":true"), "cache was clobbered");
204 }
205
206 #[tokio::test]
207 async fn in_band_failure_with_no_cache_surfaces_the_error() {
208 let mut server = mockito::Server::new_async().await;
209 server
210 .mock("GET", "/api/monitor/usage/quota/limit")
211 .with_status(200)
212 .with_body(r#"{"code":500,"msg":"boom","data":null,"success":false}"#)
213 .create_async()
214 .await;
215
216 let (_td, cache) = cache_fixture();
217 let client = reqwest::Client::new();
218 let endpoints = Endpoints {
219 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
220 };
221 let out = fetch_snapshot(
222 &client,
223 "k",
224 &cache,
225 &endpoints,
226 Duration::from_secs(0),
227 None,
228 )
229 .await;
230 assert!(out.is_err(), "expected an error, got {out:?}");
231 }
232
233 #[tokio::test]
240 async fn in_band_failure_quoting_the_key_redacts_the_diagnostic_and_the_error() {
241 let key = "test-key-value";
242 let mut server = mockito::Server::new_async().await;
243 server
244 .mock("GET", "/api/monitor/usage/quota/limit")
245 .with_status(200)
246 .with_body(format!(
247 r#"{{"code":401,"msg":"bad key {key}","data":null,"success":false}}"#
248 ))
249 .create_async()
250 .await;
251
252 let (_td, cache) = cache_fixture();
253 let cache = cache.redacting(key);
254
255 let client = reqwest::Client::new();
256 let endpoints = Endpoints {
257 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
258 };
259 let err = crate::outcome::fetch_api_keyed(
260 async {
261 fetch_snapshot(&client, key, &cache, &endpoints, Duration::ZERO, None)
262 .await
263 .map(crate::vendor::VendorOutcome::from)
264 },
265 key,
266 )
267 .await
268 .unwrap_err();
269
270 let persisted = std::fs::read_to_string(cache.last_error_path()).unwrap();
272 assert!(
273 !persisted.contains(key),
274 "the key reached .last_error: {persisted}"
275 );
276 assert!(persisted.contains("[key]"), "{persisted}");
277 let rendered = err.user_message();
279 assert!(
280 !rendered.contains(key),
281 "the key reached the report error: {rendered}"
282 );
283 assert!(rendered.contains("[key]"), "{rendered}");
284 }
285
286 #[tokio::test]
287 async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown_plan() {
288 let mut server = mockito::Server::new_async().await;
289 server
290 .mock("GET", "/api/monitor/usage/quota/limit")
291 .with_status(200)
292 .with_body(GOOD_BODY)
293 .create_async()
294 .await;
295
296 let (_td, cache) = cache_fixture();
297 cache.write_payload(b"{ truncated").unwrap();
298
299 let client = reqwest::Client::new();
300 let endpoints = Endpoints {
301 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
302 };
303 let out = fetch_snapshot(
305 &client,
306 "k",
307 &cache,
308 &endpoints,
309 Duration::from_secs(3600),
310 None,
311 )
312 .await
313 .unwrap();
314 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
315 assert!(!out.stale);
316 }
317
318 #[tokio::test]
319 async fn live_200_parses_real_shape() {
320 let mut server = mockito::Server::new_async().await;
321 server
322 .mock("GET", "/api/monitor/usage/quota/limit")
323 .with_status(200)
324 .with_body(
325 r#"{"code":200,"msg":"Operation successful","data":{
326 "limits":[
327 {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42},
328 {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":15,"nextResetTime":1779792169974}
329 ],"level":"pro"
330 },"success":true}"#,
331 )
332 .create_async()
333 .await;
334
335 let (_td, cache) = cache_fixture();
336 let client = reqwest::Client::new();
337 let endpoints = Endpoints {
338 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
339 };
340 let out = fetch_snapshot(
341 &client,
342 "fake-key",
343 &cache,
344 &endpoints,
345 Duration::from_secs(0),
346 None,
347 )
348 .await
349 .unwrap();
350 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
351 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 42);
352 assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 15);
353 }
354
355 #[tokio::test]
356 async fn http_401_falls_back_to_cache_when_present() {
357 let mut server = mockito::Server::new_async().await;
358 server
359 .mock("GET", "/api/monitor/usage/quota/limit")
360 .with_status(401)
361 .with_body(r#"{"code":401,"msg":"Unauthorized"}"#)
362 .create_async()
363 .await;
364
365 let (_td, cache) = cache_fixture();
366 let seed = r#"{"code":200,"data":{"limits":[
367 {"type":"TOKENS_LIMIT","unit":3,"percentage":10}
368 ],"level":"lite"},"success":true}"#;
369 cache.write_payload(seed.as_bytes()).unwrap();
370
371 let client = reqwest::Client::new();
372 let endpoints = Endpoints {
373 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
374 };
375 let out = fetch_snapshot(
376 &client,
377 "k",
378 &cache,
379 &endpoints,
380 Duration::from_secs(0),
381 None,
382 )
383 .await
384 .unwrap();
385 assert!(out.stale);
386 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
387 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
388 }
389}