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
55pub 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 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 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 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}