1use std::time::Duration;
6
7use crate::cache::{Cache, MAX_STALE, 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
30#[derive(Debug, Clone)]
31pub struct FetchOutcome {
32 pub snapshot: ZaiSnapshot,
33 pub stale: bool,
34 pub last_error: Option<(u16, String)>,
35 pub cache_age: Option<Duration>,
36}
37
38pub async fn fetch_snapshot(
39 client: &reqwest::Client,
40 api_key: &str,
41 cache: &Cache,
42 endpoints: &Endpoints,
43 cache_ttl: Duration,
44 config_plan_tier: Option<&str>,
45) -> Result<FetchOutcome> {
46 cache.ensure_dir()?;
47 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
48
49 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
50 && let Ok(outcome) = reuse(bytes, cache, false, config_plan_tier)
51 {
52 return Ok(outcome);
53 }
54 match fetch_live(client, &endpoints.quota, api_key).await {
58 Ok((bytes, env)) => {
59 cache.write_payload(&bytes)?;
63 Ok(FetchOutcome {
64 snapshot: env.into_snapshot(config_plan_tier),
65 stale: false,
66 last_error: None,
67 cache_age: Some(Duration::ZERO),
68 })
69 }
70 Err(e) if e.is_transient() => fallback_silent(cache, config_plan_tier, e),
71 Err(AppError::Http { status, body }) => {
72 cache.mark_stale();
73 let last_error = Some(cache.write_last_error(status, &body));
74 fallback_with_error(
75 cache,
76 last_error,
77 config_plan_tier,
78 AppError::Http { status, body },
79 )
80 }
81 Err(e) => {
82 cache.mark_stale();
83 let last_error = Some(cache.write_last_error(0, &e.to_string()));
84 fallback_with_error(cache, last_error, config_plan_tier, e)
85 }
86 }
87}
88
89fn reuse(bytes: Vec<u8>, cache: &Cache, stale: bool, tier: Option<&str>) -> Result<FetchOutcome> {
90 let env: Envelope = serde_json::from_slice(&bytes)?;
91 env.check_ok()?;
93 Ok(FetchOutcome {
94 snapshot: env.into_snapshot(tier),
95 stale,
96 last_error: cache.read_last_error(),
97 cache_age: cache.payload_age(),
98 })
99}
100
101fn fallback_silent(cache: &Cache, tier: Option<&str>, original: AppError) -> Result<FetchOutcome> {
102 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
103 return Err(original);
104 };
105 reuse(bytes, cache, true, tier)
106}
107
108fn fallback_with_error(
113 cache: &Cache,
114 last_error: Option<(u16, String)>,
115 tier: Option<&str>,
116 original: AppError,
117) -> Result<FetchOutcome> {
118 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
119 return Err(original);
120 };
121 let mut out = reuse(bytes, cache, true, tier)?;
122 out.last_error = last_error;
123 Ok(out)
124}
125
126async fn fetch_live(
129 client: &reqwest::Client,
130 url: &str,
131 api_key: &str,
132) -> Result<(Vec<u8>, Envelope)> {
133 let resp = tokio::time::timeout(
134 HTTP_TIMEOUT,
135 client
136 .get(url)
137 .header("Authorization", api_key) .header("Accept-Language", "en-US,en")
139 .header("Content-Type", "application/json")
140 .send(),
141 )
142 .await
143 .map_err(|_| AppError::Transport(format!("zai timeout: {url}")))??;
144
145 let status = resp.status();
146 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
147
148 if !status.is_success() {
149 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
150 return Err(AppError::Http {
151 status: status.as_u16(),
152 body,
153 });
154 }
155
156 let env: Envelope = serde_json::from_slice(&bytes)
160 .map_err(|e| AppError::Schema(format!("zai quota response: {e}")))?;
161 env.check_ok()?;
162 Ok((bytes, env))
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use tempfile::TempDir;
169
170 fn cache_fixture() -> (TempDir, Cache) {
171 let td = TempDir::new().unwrap();
172 let cache = Cache::at(td.path().join("zai"));
173 cache.ensure_dir().unwrap();
174 (td, cache)
175 }
176
177 const GOOD_BODY: &str = r#"{"code":200,"msg":"Operation successful","data":{
178 "limits":[{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42}],
179 "level":"pro"},"success":true}"#;
180
181 #[tokio::test]
182 async fn in_band_failure_on_200_is_rejected_and_keeps_the_good_cache() {
183 let mut server = mockito::Server::new_async().await;
187 server
188 .mock("GET", "/api/monitor/usage/quota/limit")
189 .with_status(200)
190 .with_body(r#"{"code":401,"msg":"Unauthorized","data":null,"success":false}"#)
191 .create_async()
192 .await;
193
194 let (_td, cache) = cache_fixture();
195 cache.write_payload(GOOD_BODY.as_bytes()).unwrap();
196
197 let client = reqwest::Client::new();
198 let endpoints = Endpoints {
199 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
200 };
201 let out = fetch_snapshot(
202 &client,
203 "k",
204 &cache,
205 &endpoints,
206 Duration::from_secs(0),
207 None,
208 )
209 .await
210 .unwrap();
211
212 assert!(out.stale);
214 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
215 let cached = String::from_utf8(cache.maybe_payload().unwrap().unwrap()).unwrap();
217 assert!(cached.contains("\"success\":true"), "cache was clobbered");
218 }
219
220 #[tokio::test]
221 async fn in_band_failure_with_no_cache_surfaces_the_error() {
222 let mut server = mockito::Server::new_async().await;
223 server
224 .mock("GET", "/api/monitor/usage/quota/limit")
225 .with_status(200)
226 .with_body(r#"{"code":500,"msg":"boom","data":null,"success":false}"#)
227 .create_async()
228 .await;
229
230 let (_td, cache) = cache_fixture();
231 let client = reqwest::Client::new();
232 let endpoints = Endpoints {
233 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
234 };
235 let out = fetch_snapshot(
236 &client,
237 "k",
238 &cache,
239 &endpoints,
240 Duration::from_secs(0),
241 None,
242 )
243 .await;
244 assert!(out.is_err(), "expected an error, got {out:?}");
245 }
246
247 #[tokio::test]
248 async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown_plan() {
249 let mut server = mockito::Server::new_async().await;
250 server
251 .mock("GET", "/api/monitor/usage/quota/limit")
252 .with_status(200)
253 .with_body(GOOD_BODY)
254 .create_async()
255 .await;
256
257 let (_td, cache) = cache_fixture();
258 cache.write_payload(b"{ truncated").unwrap();
259
260 let client = reqwest::Client::new();
261 let endpoints = Endpoints {
262 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
263 };
264 let out = fetch_snapshot(
266 &client,
267 "k",
268 &cache,
269 &endpoints,
270 Duration::from_secs(3600),
271 None,
272 )
273 .await
274 .unwrap();
275 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
276 assert!(!out.stale);
277 }
278
279 #[tokio::test]
280 async fn live_200_parses_real_shape() {
281 let mut server = mockito::Server::new_async().await;
282 server
283 .mock("GET", "/api/monitor/usage/quota/limit")
284 .with_status(200)
285 .with_body(
286 r#"{"code":200,"msg":"Operation successful","data":{
287 "limits":[
288 {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42},
289 {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":15,"nextResetTime":1779792169974}
290 ],"level":"pro"
291 },"success":true}"#,
292 )
293 .create_async()
294 .await;
295
296 let (_td, cache) = cache_fixture();
297 let client = reqwest::Client::new();
298 let endpoints = Endpoints {
299 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
300 };
301 let out = fetch_snapshot(
302 &client,
303 "fake-key",
304 &cache,
305 &endpoints,
306 Duration::from_secs(0),
307 None,
308 )
309 .await
310 .unwrap();
311 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
312 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 42);
313 assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 15);
314 }
315
316 #[tokio::test]
317 async fn http_401_falls_back_to_cache_when_present() {
318 let mut server = mockito::Server::new_async().await;
319 server
320 .mock("GET", "/api/monitor/usage/quota/limit")
321 .with_status(401)
322 .with_body(r#"{"code":401,"msg":"Unauthorized"}"#)
323 .create_async()
324 .await;
325
326 let (_td, cache) = cache_fixture();
327 let seed = r#"{"code":200,"data":{"limits":[
328 {"type":"TOKENS_LIMIT","unit":3,"percentage":10}
329 ],"level":"lite"},"success":true}"#;
330 cache.write_payload(seed.as_bytes()).unwrap();
331
332 let client = reqwest::Client::new();
333 let endpoints = Endpoints {
334 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
335 };
336 let out = fetch_snapshot(
337 &client,
338 "k",
339 &cache,
340 &endpoints,
341 Duration::from_secs(0),
342 None,
343 )
344 .await
345 .unwrap();
346 assert!(out.stale);
347 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
348 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
349 }
350}