Skip to main content

ai_usagebar/zai/
fetch.rs

1//! Z.AI fetch. Note the auth-header quirk — the API key is passed as
2//! `Authorization: <KEY>` WITHOUT the `Bearer` prefix. Sending `Bearer …`
3//! returns 401.
4
5use 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    // Corrupt fresh cache: fall through to live fetch rather than return a
55    // fabricated "GLM Coding Unknown" snapshot with empty windows.
56
57    match fetch_live(client, &endpoints.quota, api_key).await {
58        Ok((bytes, env)) => {
59            // Only a validated envelope reaches the cache, so a 200 carrying
60            // `success: false` can never overwrite the last good payload nor
61            // clear the recorded error.
62            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    // A cached failure envelope is not usage data, even if it parses.
87    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
118/// Returns the raw bytes (for the cache) alongside the *validated* envelope,
119/// so the caller cannot accidentally cache a body it never checked.
120async 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) // NO `Bearer ` prefix.
130            .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    // Schema drift surfaces here — and so does Z.AI's in-band failure shape,
149    // which arrives as HTTP 200 with `success: false`. Both are errors, so the
150    // caller's fallback path runs and the good cache survives.
151    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        // The regression this guards: a 200 carrying `success:false` used to be
176        // written to cache, clearing the recorded error, and rendered as an
177        // unknown plan with empty windows — visually identical to "no usage".
178        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        // The last good figure is still shown, flagged stale...
205        assert!(out.stale);
206        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
207        // ...and the good payload was NOT overwritten by the failure envelope.
208        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        // Long TTL: the payload IS fresh, it is just unusable.
257        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}