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]
234 async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown_plan() {
235 let mut server = mockito::Server::new_async().await;
236 server
237 .mock("GET", "/api/monitor/usage/quota/limit")
238 .with_status(200)
239 .with_body(GOOD_BODY)
240 .create_async()
241 .await;
242
243 let (_td, cache) = cache_fixture();
244 cache.write_payload(b"{ truncated").unwrap();
245
246 let client = reqwest::Client::new();
247 let endpoints = Endpoints {
248 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
249 };
250 let out = fetch_snapshot(
252 &client,
253 "k",
254 &cache,
255 &endpoints,
256 Duration::from_secs(3600),
257 None,
258 )
259 .await
260 .unwrap();
261 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
262 assert!(!out.stale);
263 }
264
265 #[tokio::test]
266 async fn live_200_parses_real_shape() {
267 let mut server = mockito::Server::new_async().await;
268 server
269 .mock("GET", "/api/monitor/usage/quota/limit")
270 .with_status(200)
271 .with_body(
272 r#"{"code":200,"msg":"Operation successful","data":{
273 "limits":[
274 {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42},
275 {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":15,"nextResetTime":1779792169974}
276 ],"level":"pro"
277 },"success":true}"#,
278 )
279 .create_async()
280 .await;
281
282 let (_td, cache) = cache_fixture();
283 let client = reqwest::Client::new();
284 let endpoints = Endpoints {
285 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
286 };
287 let out = fetch_snapshot(
288 &client,
289 "fake-key",
290 &cache,
291 &endpoints,
292 Duration::from_secs(0),
293 None,
294 )
295 .await
296 .unwrap();
297 assert_eq!(out.snapshot.plan, "GLM Coding Pro");
298 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 42);
299 assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 15);
300 }
301
302 #[tokio::test]
303 async fn http_401_falls_back_to_cache_when_present() {
304 let mut server = mockito::Server::new_async().await;
305 server
306 .mock("GET", "/api/monitor/usage/quota/limit")
307 .with_status(401)
308 .with_body(r#"{"code":401,"msg":"Unauthorized"}"#)
309 .create_async()
310 .await;
311
312 let (_td, cache) = cache_fixture();
313 let seed = r#"{"code":200,"data":{"limits":[
314 {"type":"TOKENS_LIMIT","unit":3,"percentage":10}
315 ],"level":"lite"},"success":true}"#;
316 cache.write_payload(seed.as_bytes()).unwrap();
317
318 let client = reqwest::Client::new();
319 let endpoints = Endpoints {
320 quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
321 };
322 let out = fetch_snapshot(
323 &client,
324 "k",
325 &cache,
326 &endpoints,
327 Duration::from_secs(0),
328 None,
329 )
330 .await
331 .unwrap();
332 assert!(out.stale);
333 assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
334 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
335 }
336}