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, 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/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
31/// specialised to its snapshot.
32pub 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    // Corrupt fresh cache: fall through to live fetch rather than return a
51    // fabricated "GLM Coding Unknown" snapshot with empty windows.
52
53    match fetch_live(client, &endpoints.quota, api_key).await {
54        Ok((bytes, env)) => {
55            // Only a validated envelope reaches the cache, so a 200 carrying
56            // `success: false` can never overwrite the last good payload nor
57            // clear the recorded error.
58            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    // A cached failure envelope is not usage data, even if it parses.
93    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
112/// Returns the raw bytes (for the cache) alongside the *validated* envelope,
113/// so the caller cannot accidentally cache a body it never checked.
114async 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) // NO `Bearer ` prefix.
124            .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    // Schema drift surfaces here — and so does Z.AI's in-band failure shape,
143    // which arrives as HTTP 200 with `success: false`. Both are errors, so the
144    // caller's fallback path runs and the good cache survives.
145    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        // The regression this guards: a 200 carrying `success:false` used to be
170        // written to cache, clearing the recorded error, and rendered as an
171        // unknown plan with empty windows — visually identical to "no usage".
172        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        // The last good figure is still shown, flagged stale...
199        assert!(out.stale);
200        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
201        // ...and the good payload was NOT overwritten by the failure envelope.
202        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    /// The end-to-end regression (review F1): a 200 whose `msg` quotes the
234    /// key the fetch was sent with used to persist the key in `.last_error`
235    /// and hand the key-carrying error back to the report. The seam
236    /// `build_outcome`/`api_key_and_cache` use — a `Cache::at` attached to
237    /// the key via `redacting`, plus the real fetch under the same single
238    /// error wrap — must leave the key in neither place.
239    #[tokio::test]
240    async fn in_band_failure_quoting_the_key_redacts_the_diagnostic_and_the_error() {
241        let key = "test-key-value";
242        let mut server = mockito::Server::new_async().await;
243        server
244            .mock("GET", "/api/monitor/usage/quota/limit")
245            .with_status(200)
246            .with_body(format!(
247                r#"{{"code":401,"msg":"bad key {key}","data":null,"success":false}}"#
248            ))
249            .create_async()
250            .await;
251
252        let (_td, cache) = cache_fixture();
253        let cache = cache.redacting(key);
254
255        let client = reqwest::Client::new();
256        let endpoints = Endpoints {
257            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
258        };
259        let err = crate::outcome::fetch_api_keyed(
260            async {
261                fetch_snapshot(&client, key, &cache, &endpoints, Duration::ZERO, None)
262                    .await
263                    .map(crate::vendor::VendorOutcome::from)
264            },
265            key,
266        )
267        .await
268        .unwrap_err();
269
270        // The cache's first point of writing never held the key.
271        let persisted = std::fs::read_to_string(cache.last_error_path()).unwrap();
272        assert!(
273            !persisted.contains(key),
274            "the key reached .last_error: {persisted}"
275        );
276        assert!(persisted.contains("[key]"), "{persisted}");
277        // Neither does the error the report shows.
278        let rendered = err.user_message();
279        assert!(
280            !rendered.contains(key),
281            "the key reached the report error: {rendered}"
282        );
283        assert!(rendered.contains("[key]"), "{rendered}");
284    }
285
286    #[tokio::test]
287    async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown_plan() {
288        let mut server = mockito::Server::new_async().await;
289        server
290            .mock("GET", "/api/monitor/usage/quota/limit")
291            .with_status(200)
292            .with_body(GOOD_BODY)
293            .create_async()
294            .await;
295
296        let (_td, cache) = cache_fixture();
297        cache.write_payload(b"{ truncated").unwrap();
298
299        let client = reqwest::Client::new();
300        let endpoints = Endpoints {
301            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
302        };
303        // Long TTL: the payload IS fresh, it is just unusable.
304        let out = fetch_snapshot(
305            &client,
306            "k",
307            &cache,
308            &endpoints,
309            Duration::from_secs(3600),
310            None,
311        )
312        .await
313        .unwrap();
314        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
315        assert!(!out.stale);
316    }
317
318    #[tokio::test]
319    async fn live_200_parses_real_shape() {
320        let mut server = mockito::Server::new_async().await;
321        server
322            .mock("GET", "/api/monitor/usage/quota/limit")
323            .with_status(200)
324            .with_body(
325                r#"{"code":200,"msg":"Operation successful","data":{
326                    "limits":[
327                        {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42},
328                        {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":15,"nextResetTime":1779792169974}
329                    ],"level":"pro"
330                },"success":true}"#,
331            )
332            .create_async()
333            .await;
334
335        let (_td, cache) = cache_fixture();
336        let client = reqwest::Client::new();
337        let endpoints = Endpoints {
338            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
339        };
340        let out = fetch_snapshot(
341            &client,
342            "fake-key",
343            &cache,
344            &endpoints,
345            Duration::from_secs(0),
346            None,
347        )
348        .await
349        .unwrap();
350        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
351        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 42);
352        assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 15);
353    }
354
355    #[tokio::test]
356    async fn http_401_falls_back_to_cache_when_present() {
357        let mut server = mockito::Server::new_async().await;
358        server
359            .mock("GET", "/api/monitor/usage/quota/limit")
360            .with_status(401)
361            .with_body(r#"{"code":401,"msg":"Unauthorized"}"#)
362            .create_async()
363            .await;
364
365        let (_td, cache) = cache_fixture();
366        let seed = r#"{"code":200,"data":{"limits":[
367            {"type":"TOKENS_LIMIT","unit":3,"percentage":10}
368        ],"level":"lite"},"success":true}"#;
369        cache.write_payload(seed.as_bytes()).unwrap();
370
371        let client = reqwest::Client::new();
372        let endpoints = Endpoints {
373            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
374        };
375        let out = fetch_snapshot(
376            &client,
377            "k",
378            &cache,
379            &endpoints,
380            Duration::from_secs(0),
381            None,
382        )
383        .await
384        .unwrap();
385        assert!(out.stale);
386        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
387        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
388    }
389}