Skip to main content

ai_usagebar/muse/
fetch.rs

1use std::time::Duration;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::cache::{Cache, acquire_lock_async, atomic_write};
7use crate::error::{AppError, Result};
8use crate::usage::MuseSnapshot;
9use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
10
11use super::{creds::Credentials, types::Response};
12
13pub const REQUEST_INTERVAL: Duration = Duration::from_secs(300);
14pub type FetchOutcome = crate::outcome::Outcome<MuseSnapshot>;
15
16pub struct Endpoints {
17    pub base: String,
18}
19
20impl Default for Endpoints {
21    fn default() -> Self {
22        Self {
23            base: "https://api.meta.ai".into(),
24        }
25    }
26}
27
28impl Endpoints {
29    fn url(&self) -> String {
30        format!("{}/muse-code/key", self.base)
31    }
32}
33
34#[derive(Serialize, Deserialize)]
35struct Cached {
36    account: String,
37    response: Response,
38}
39
40#[derive(Serialize, Deserialize)]
41struct RequestRecord {
42    at: DateTime<Utc>,
43    error: Option<(u16, String)>,
44}
45
46pub async fn fetch_snapshot(
47    cfg: &crate::config::MuseConfig,
48    cache: &Cache,
49) -> Result<FetchOutcome> {
50    let credentials = super::creds::read_from(&super::creds::path(cfg)?)?;
51    let endpoints = Endpoints::default();
52    fetch_snapshot_at(&credentials, cache, &endpoints, Utc::now()).await
53}
54
55/// The clock, credential and endpoint are injected for hermetic tests.
56pub async fn fetch_snapshot_at(
57    credentials: &Credentials,
58    cache: &Cache,
59    endpoints: &Endpoints,
60    now: DateTime<Utc>,
61) -> Result<FetchOutcome> {
62    cache.ensure_dir()?;
63    let _lock = acquire_lock_async(&cache.lock_path(), Duration::from_secs(20)).await?;
64    let record_path = cache
65        .dir()
66        .join(format!("request-{}.json", credentials.fingerprint));
67    let parse_cache = |bytes: &[u8]| -> Result<MuseSnapshot> {
68        let cached: Cached = serde_json::from_slice(bytes)
69            .map_err(|_| AppError::Schema("Muse Code quota cache is invalid".into()))?;
70        if cached.account != credentials.fingerprint {
71            return Err(AppError::Schema(
72                "Muse Code quota cache belongs to a different login".into(),
73            ));
74        }
75        cached.response.snapshot(now)
76    };
77    if record_path.exists() {
78        // A damaged ledger must not bypass the request bound. The user can
79        // remove this vendor cache explicitly to recover it.
80        let bytes = std::fs::read(&record_path).map_err(|e| AppError::io_at(&record_path, e))?;
81        let record: RequestRecord = serde_json::from_slice(&bytes).map_err(|_| {
82            AppError::Other("Muse Code request record is invalid; clear its vendor cache".into())
83        })?;
84        if now.signed_duration_since(record.at).num_seconds() < REQUEST_INTERVAL.as_secs() as i64 {
85            if record.error.is_none()
86                && let Some(bytes) = cache.maybe_payload()?
87                && let Ok(snapshot) = parse_cache(&bytes)
88            {
89                return Ok(crate::outcome::Outcome::cached(snapshot, cache, false));
90            }
91            let (code, message) = record.error.unwrap_or_else(|| (0,
92                "Muse Code quota request was interrupted or its observation expired; next request is deferred for five minutes".into()));
93            return crate::outcome::fallback(
94                cache,
95                Some((code, message.clone())),
96                AppError::Other(message),
97                parse_cache,
98            );
99        }
100    }
101    // This endpoint also supplies a standing inference key. Bound *every*
102    // attempt, including failures, crashes and forced refreshes, before sending.
103    atomic_write(
104        &record_path,
105        &serde_json::to_vec(&RequestRecord {
106            at: now,
107            error: Some((
108                0,
109                "Muse Code quota request did not complete; retry after five minutes".into(),
110            )),
111        })?,
112    )?;
113    let result = request(credentials, endpoints).await.and_then(|response| {
114        let snapshot = response.snapshot(now)?;
115        Ok((response, snapshot))
116    });
117    match result {
118        Ok((response, snapshot)) => {
119            cache.write_payload(&serde_json::to_vec(&Cached {
120                account: credentials.fingerprint.clone(),
121                response,
122            })?)?;
123            atomic_write(
124                &record_path,
125                &serde_json::to_vec(&RequestRecord {
126                    at: now,
127                    error: None,
128                })?,
129            )?;
130            Ok(crate::outcome::Outcome::fresh(snapshot))
131        }
132        Err(error) => {
133            let code = match &error {
134                AppError::Http { status, .. } => *status,
135                _ => 0,
136            };
137            let pair = cache.write_last_error(code, &error.to_string());
138            cache.mark_stale();
139            atomic_write(
140                &record_path,
141                &serde_json::to_vec(&RequestRecord {
142                    at: now,
143                    error: Some(pair.clone()),
144                })?,
145            )?;
146            crate::outcome::fallback(cache, Some(pair), error, parse_cache)
147        }
148    }
149}
150
151async fn request(credentials: &Credentials, endpoints: &Endpoints) -> Result<Response> {
152    // Even a same-origin redirect is rejected: no token forwarding, no extra
153    // mint call, and the route cannot come from a credential's api_base_url.
154    let client = reqwest::Client::builder()
155        .redirect(reqwest::redirect::Policy::none())
156        .timeout(Duration::from_secs(15))
157        .build()?;
158    let response = client
159        .post(endpoints.url())
160        .bearer_auth(&credentials.token)
161        .header("x-api-version", "1.0.0")
162        .header("Accept", "application/json")
163        .json(&serde_json::json!({"onboard":false}))
164        .send()
165        .await
166        .map_err(|_| AppError::Transport("Muse Code quota request failed".into()))?;
167    let status = response.status();
168    if !status.is_success() {
169        return Err(AppError::Http {
170            status: status.as_u16(),
171            body: if matches!(status.as_u16(), 401 | 403) {
172                "Muse Code login rejected; run `muse login`".into()
173            } else {
174                format!("Muse Code quota returned HTTP {}", status.as_u16())
175            },
176        });
177    }
178    let bytes = read_body_capped(response, MAX_BODY_BYTES).await?;
179    serde_json::from_slice(&bytes)
180        .map_err(|_| AppError::Schema("Muse Code quota response schema mismatch".into()))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    fn now() -> DateTime<Utc> {
187        DateTime::from_timestamp(1791936000, 0).unwrap()
188    }
189    fn credential(token: &str) -> Credentials {
190        Credentials {
191            token: token.into(),
192            fingerprint: crate::cache::fingerprint_of(token),
193        }
194    }
195    fn body() -> String {
196        serde_json::json!({"is_subs_active":true,"subs_tier_name":"Muse Code Power Usage",
197        "api_key":"ResponseSecretCanary-9123","user_email":"private@example.test",
198        "subs_usage":{"window":{"used_percent":38,"window_duration_mins":300,"resets_at":1791942677},
199        "weekly":{"used_percent":34,"resets_at":1792368000}}}).to_string()
200    }
201    #[tokio::test]
202    async fn quota_request_is_bounded_and_cache_contains_no_secrets() {
203        let mut server = mockito::Server::new_async().await;
204        let mock = server
205            .mock("POST", "/muse-code/key")
206            .match_header("authorization", "Bearer dca:test-login")
207            .match_header("x-api-version", "1.0.0")
208            .match_body("{\"onboard\":false}")
209            .with_body(body())
210            .expect(1)
211            .create_async()
212            .await;
213        let dir = tempfile::tempdir().unwrap();
214        let cache = Cache::at(dir.path().join("cache"));
215        let endpoint = Endpoints { base: server.url() };
216        let creds = credential("dca:test-login");
217        assert!(
218            !fetch_snapshot_at(&creds, &cache, &endpoint, now())
219                .await
220                .unwrap()
221                .stale
222        );
223        assert_eq!(
224            fetch_snapshot_at(
225                &creds,
226                &cache,
227                &endpoint,
228                now() + chrono::Duration::seconds(1)
229            )
230            .await
231            .unwrap()
232            .snapshot
233            .weekly
234            .unwrap()
235            .utilization_pct,
236            34
237        );
238        mock.assert_async().await;
239        for path in std::fs::read_dir(cache.dir()).unwrap() {
240            let content = std::fs::read(path.unwrap().path()).unwrap();
241            let content = String::from_utf8_lossy(&content);
242            for secret in ["dca:test-login", "ResponseSecretCanary", "private@example"] {
243                assert!(!content.contains(secret));
244            }
245        }
246    }
247    #[tokio::test]
248    async fn omitted_idle_quota_keeps_previous_observation_stale_and_bounds_failed_reads() {
249        let mut server = mockito::Server::new_async().await;
250        let first = server
251            .mock("POST", "/muse-code/key")
252            .with_body(body())
253            .expect(1)
254            .create_async()
255            .await;
256        let dir = tempfile::tempdir().unwrap();
257        let cache = Cache::at(dir.path().join("cache"));
258        let endpoint = Endpoints { base: server.url() };
259        let creds = credential("dca:test-login");
260        fetch_snapshot_at(&creds, &cache, &endpoint, now())
261            .await
262            .unwrap();
263        first.remove_async().await;
264        let second = server
265            .mock("POST", "/muse-code/key")
266            .with_body("{\"is_subs_active\":true}")
267            .expect(1)
268            .create_async()
269            .await;
270        let later = now() + chrono::Duration::seconds(300);
271        let outcome = fetch_snapshot_at(&creds, &cache, &endpoint, later)
272            .await
273            .unwrap();
274        assert!(outcome.stale);
275        assert_eq!(outcome.snapshot.weekly.unwrap().utilization_pct, 34);
276        assert!(
277            fetch_snapshot_at(
278                &creds,
279                &cache,
280                &endpoint,
281                later + chrono::Duration::seconds(1)
282            )
283            .await
284            .unwrap()
285            .stale
286        );
287        second.assert_async().await;
288        second.remove_async().await;
289        let other = server
290            .mock("POST", "/muse-code/key")
291            .match_header("authorization", "Bearer dca:other")
292            .with_body("{\"is_subs_active\":true}")
293            .expect(1)
294            .create_async()
295            .await;
296        assert!(
297            fetch_snapshot_at(&credential("dca:other"), &cache, &endpoint, later)
298                .await
299                .is_err()
300        );
301        other.assert_async().await;
302    }
303
304    #[tokio::test]
305    async fn interrupted_request_and_rejected_login_do_not_trigger_immediate_retries() {
306        let mut server = mockito::Server::new_async().await;
307        let never = server
308            .mock("POST", "/muse-code/key")
309            .expect(0)
310            .create_async()
311            .await;
312        let dir = tempfile::tempdir().unwrap();
313        let cache = Cache::at(dir.path().join("interrupted"));
314        cache.ensure_dir().unwrap();
315        let creds = credential("dca:interrupted");
316        let path = cache
317            .dir()
318            .join(format!("request-{}.json", creds.fingerprint));
319        atomic_write(
320            &path,
321            &serde_json::to_vec(&RequestRecord {
322                at: now(),
323                error: Some((0, "Previous request interrupted".into())),
324            })
325            .unwrap(),
326        )
327        .unwrap();
328        let endpoint = Endpoints { base: server.url() };
329        assert!(
330            fetch_snapshot_at(&creds, &cache, &endpoint, now())
331                .await
332                .is_err()
333        );
334        never.assert_async().await;
335        never.remove_async().await;
336        let denied = server
337            .mock("POST", "/muse-code/key")
338            .with_status(401)
339            .expect(1)
340            .create_async()
341            .await;
342        let cache = Cache::at(dir.path().join("denied"));
343        let creds = credential("dca:denied");
344        assert!(
345            fetch_snapshot_at(&creds, &cache, &endpoint, now())
346                .await
347                .is_err()
348        );
349        assert!(
350            fetch_snapshot_at(
351                &creds,
352                &cache,
353                &endpoint,
354                now() + chrono::Duration::seconds(1)
355            )
356            .await
357            .is_err()
358        );
359        denied.assert_async().await;
360    }
361    #[tokio::test]
362    async fn redirects_and_echoed_error_bodies_never_forward_or_expose_credentials() {
363        let mut server = mockito::Server::new_async().await;
364        let redirected = server.mock("POST", "/other").expect(0).create_async().await;
365        for status in [302, 401, 500] {
366            let mock = server
367                .mock("POST", "/muse-code/key")
368                .with_status(status)
369                .with_header("location", &format!("{}/other", server.url()))
370                .with_body("dca:SecretCanary-92143")
371                .expect(1)
372                .create_async()
373                .await;
374            let dir = tempfile::tempdir().unwrap();
375            let cache = Cache::at(dir.path().join("cache"));
376            let endpoint = Endpoints { base: server.url() };
377            let result = fetch_snapshot_at(&credential("dca:test"), &cache, &endpoint, now()).await;
378            assert!(!result.err().unwrap().to_string().contains("SecretCanary"));
379            assert!(
380                !std::fs::read_to_string(cache.last_error_path())
381                    .unwrap()
382                    .contains("SecretCanary")
383            );
384            mock.assert_async().await;
385            mock.remove_async().await;
386        }
387        redirected.assert_async().await;
388    }
389
390    #[tokio::test]
391    async fn network_transport_failure_is_transient_on_cold_cache() {
392        let dir = tempfile::tempdir().unwrap();
393        let cache = Cache::at(dir.path().join("cache"));
394        let endpoint = Endpoints {
395            base: "http://127.0.0.1:1".into(),
396        };
397        let creds = credential("dca:test");
398        let err = fetch_snapshot_at(&creds, &cache, &endpoint, now())
399            .await
400            .unwrap_err();
401        assert!(err.is_transient(), "expected transient error, got {err:?}");
402    }
403}