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