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, 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    // A cached failure envelope is not usage data, even if it parses.
92    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
108/// On failure we show the last good figure with the error alongside it. With
109/// nothing usable cached there is nothing to show, so the **original** error is
110/// returned rather than a generic "no usable cache" that hides what went wrong
111/// — a cold cache and an expired key would otherwise look identical.
112fn 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
126/// Returns the raw bytes (for the cache) alongside the *validated* envelope,
127/// so the caller cannot accidentally cache a body it never checked.
128async 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) // NO `Bearer ` prefix.
138            .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    // Schema drift surfaces here — and so does Z.AI's in-band failure shape,
157    // which arrives as HTTP 200 with `success: false`. Both are errors, so the
158    // caller's fallback path runs and the good cache survives.
159    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        // The regression this guards: a 200 carrying `success:false` used to be
184        // written to cache, clearing the recorded error, and rendered as an
185        // unknown plan with empty windows — visually identical to "no usage".
186        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        // The last good figure is still shown, flagged stale...
213        assert!(out.stale);
214        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
215        // ...and the good payload was NOT overwritten by the failure envelope.
216        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        // Long TTL: the payload IS fresh, it is just unusable.
265        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}