Skip to main content

ai_usagebar/cline/
fetch.rs

1//! Fetch the ClinePass plan and its three usage limits.
2//!
3//! A refresh is two small calls: the plan (name, billing period) and
4//! `usage-limits`, where Cline itself reports how much of each window is used
5//! and when it resets. The windows are not recomputed here — see
6//! [`super::types`] for why that approach was dropped.
7
8use std::fmt::Write as _;
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14
15use crate::cache::{Cache, acquire_lock_async};
16use crate::error::{AUTH_FAILURE_MESSAGE, AppError, Result};
17use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
18
19use super::creds::Credential;
20use super::types::{Plan, Snapshot, Window, parse_limits, parse_plan};
21
22pub const BASE_URL: &str = "https://api.cline.bot";
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
25const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
26const SCHEMA_ERROR: &str = "Cline response schema mismatch";
27
28#[derive(Debug, Clone)]
29pub struct Endpoints {
30    pub base: String,
31}
32
33impl Default for Endpoints {
34    fn default() -> Self {
35        Self {
36            base: BASE_URL.to_string(),
37        }
38    }
39}
40
41impl Endpoints {
42    fn me(&self) -> String {
43        format!("{}/api/v1/users/me", self.base)
44    }
45
46    fn plan(&self) -> String {
47        format!("{}/api/v1/users/me/plan", self.base)
48    }
49
50    fn usage_limits(&self) -> String {
51        format!("{}/api/v1/users/me/plan/usage-limits", self.base)
52    }
53}
54
55/// This vendor's [`Outcome`](crate::outcome::Outcome).
56pub type FetchOutcome = crate::outcome::Outcome<Snapshot>;
57
58/// What the cache holds between refreshes: the last answer, as the server
59/// gave it. Windows are aged to the current instant when it is read back.
60#[derive(Debug, Clone, Default)]
61struct State {
62    plan: Plan,
63    windows: [Option<Window>; 3],
64}
65
66impl State {
67    fn snapshot(&self, now: DateTime<Utc>) -> Snapshot {
68        let [five_hour, seven_day, thirty_day] = self
69            .windows
70            .clone()
71            .map(|window| window.map(|window| window.aged(now)));
72        Snapshot {
73            plan: self.plan.name.clone(),
74            five_hour,
75            seven_day,
76            thirty_day,
77            period_end: self.plan.period_end,
78        }
79    }
80}
81
82pub async fn fetch_snapshot(
83    client: &reqwest::Client,
84    credential: &Credential,
85    cache: &Cache,
86    endpoints: &Endpoints,
87    ttl: Duration,
88    now: DateTime<Utc>,
89) -> Result<FetchOutcome> {
90    cache.ensure_dir()?;
91    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
92
93    let credential_fingerprint = sha256_hex(&credential.token);
94    let cached_payload = cache.maybe_payload()?;
95    let mut target = credential
96        .account_id
97        .as_deref()
98        .map(|id| target_key(endpoints, id))
99        .or_else(|| {
100            cached_payload
101                .as_deref()
102                .and_then(|body| cached_target(body, endpoints, &credential_fingerprint))
103        });
104
105    // A lapsed token would only earn a 401. The stored answer is still worth
106    // showing, aged to the current instant: a window whose reset has passed
107    // reads as empty, exactly as it does at Cline.
108    if credential.expired {
109        let Some(target) = target.as_deref() else {
110            return Err(AppError::Credentials(super::creds::EXPIRED.to_string()));
111        };
112        cache.mark_stale();
113        let recorded = cache.write_last_error(401, super::creds::EXPIRED);
114        return fallback_or_error(
115            cache,
116            Some(recorded),
117            target,
118            AppError::Credentials(super::creds::EXPIRED.to_string()),
119            now,
120        );
121    }
122
123    let fresh_payload = cache.fresh_payload(ttl)?;
124    if let (Some(target), Some(bytes)) = (target.as_deref(), fresh_payload.as_deref())
125        && let Ok(state) = parse_cache(bytes, target)
126    {
127        return Ok(crate::outcome::Outcome::cached(
128            state.snapshot(now),
129            cache,
130            false,
131        ));
132    }
133
134    // `fresh_payload` is also the shared no-network gate. It may return a
135    // stale payload during backoff; if that payload belongs to another API
136    // key, refuse it and the network rather than using it to discover identity.
137    if target.is_none() && cache.backoff_remaining().is_some() {
138        return Err(AppError::Http {
139            status: 429,
140            body: status_message(429).to_string(),
141        });
142    }
143
144    if target.is_none() {
145        let user_id = match fetch_user_id(client, endpoints, &credential.token).await {
146            Ok(id) => id,
147            Err(error) => return handle_error(cache, None, error, now),
148        };
149        target = Some(target_key(endpoints, &user_id));
150    }
151    let target = target.expect("identity resolution set the cache target");
152    // Caches written before the credential fingerprint was added still work:
153    // resolve identity once, then apply the ordinary TTL to the old payload.
154    if let Some(bytes) = fresh_payload.as_deref()
155        && let Ok(state) = parse_cache(bytes, &target)
156    {
157        return Ok(crate::outcome::Outcome::cached(
158            state.snapshot(now),
159            cache,
160            false,
161        ));
162    }
163
164    match fetch_live(client, endpoints, &credential.token).await {
165        Ok(state) => {
166            cache.write_payload(&serde_json::to_vec(&state_repr(
167                &target,
168                &credential_fingerprint,
169                &state,
170            ))?)?;
171            Ok(crate::outcome::Outcome::fresh(state.snapshot(now)))
172        }
173        Err(error) => handle_error(cache, Some(&target), error, now),
174    }
175}
176
177async fn fetch_live(client: &reqwest::Client, endpoints: &Endpoints, token: &str) -> Result<State> {
178    let schema = |_| AppError::Schema(SCHEMA_ERROR.to_string());
179    let plan = parse_plan(&get_json(client, &endpoints.plan(), token).await?).map_err(schema)?;
180    let windows =
181        parse_limits(&get_json(client, &endpoints.usage_limits(), token).await?).map_err(schema)?;
182    Ok(State { plan, windows })
183}
184
185/// Resolve the signed-in user id when the credential store did not record it.
186async fn fetch_user_id(
187    client: &reqwest::Client,
188    endpoints: &Endpoints,
189    token: &str,
190) -> Result<String> {
191    let value = get_json(client, &endpoints.me(), token).await?;
192    value
193        .get("data")
194        .unwrap_or(&value)
195        .get("id")
196        .and_then(Value::as_str)
197        .map(str::to_string)
198        .ok_or_else(|| AppError::Schema(SCHEMA_ERROR.to_string()))
199}
200
201async fn get_json(client: &reqwest::Client, url: &str, token: &str) -> Result<Value> {
202    let response = tokio::time::timeout(
203        HTTP_TIMEOUT,
204        client
205            .get(url)
206            .bearer_auth(token)
207            .header(reqwest::header::ACCEPT, "application/json")
208            .send(),
209    )
210    .await
211    .map_err(|_| AppError::Transport("Cline request timed out".to_string()))??;
212
213    let status = response.status();
214    let body = read_body_capped(response, MAX_BODY_BYTES).await?;
215    if !status.is_success() {
216        return Err(AppError::Http {
217            status: status.as_u16(),
218            body: status_message(status.as_u16()).to_string(),
219        });
220    }
221    serde_json::from_slice(&body).map_err(|_| AppError::Schema(SCHEMA_ERROR.to_string()))
222}
223
224/// Stable, non-secret identity for the endpoint and account. The access token
225/// rotates every few hours, so it cannot be what a cache is keyed on; the user
226/// id it resolves to is what the cached figures actually belong to.
227fn target_key(endpoints: &Endpoints, user_id: &str) -> String {
228    // `v3` marks the usage-limits schema. A `v2` cache holds a charge ledger
229    // and no windows; it simply fails the target match and is refetched.
230    format!("{}|user:{}|v3", endpoints.base, sha256_hex(user_id))
231}
232
233fn sha256_hex(value: &str) -> String {
234    let digest = Sha256::digest(value.as_bytes());
235    let mut fingerprint = String::with_capacity(digest.len() * 2);
236    for byte in digest {
237        let _ = write!(fingerprint, "{byte:02x}");
238    }
239    fingerprint
240}
241
242fn state_repr(target: &str, credential_fingerprint: &str, state: &State) -> Value {
243    let window = |window: &Option<Window>| {
244        window.as_ref().map(|window| {
245            serde_json::json!({
246                "percent": window.percent,
247                "reset": window.reset.map(|at| at.to_rfc3339()),
248            })
249        })
250    };
251    serde_json::json!({
252        "target": target,
253        "credential": credential_fingerprint,
254        "plan": {
255            "name": state.plan.name,
256            "period_end": state.plan.period_end.map(|at| at.to_rfc3339()),
257        },
258        "windows": state.windows.iter().map(window).collect::<Vec<_>>(),
259    })
260}
261
262/// Recover the account-scoped target only when this exact credential wrote it.
263/// The one-way fingerprint lets API-key users reach the ordinary TTL/backoff
264/// gate without persisting the key or showing another account's figures.
265fn cached_target(
266    body: &[u8],
267    endpoints: &Endpoints,
268    credential_fingerprint: &str,
269) -> Option<String> {
270    let value: Value = serde_json::from_slice(body).ok()?;
271    if value.get("credential").and_then(Value::as_str) != Some(credential_fingerprint) {
272        return None;
273    }
274    let target = value.get("target")?.as_str()?;
275    let prefix = format!("{}|user:", endpoints.base);
276    (target.starts_with(&prefix) && target.ends_with("|v3")).then(|| target.to_string())
277}
278
279fn parse_cache(body: &[u8], target: &str) -> Result<State> {
280    let invalid = || AppError::Schema("Cline cache is invalid".to_string());
281    let timestamp = |value: Option<&Value>| {
282        value
283            .and_then(Value::as_str)
284            .and_then(|at| at.parse::<DateTime<chrono::FixedOffset>>().ok())
285            .map(|at| at.with_timezone(&Utc))
286    };
287    let value: Value = serde_json::from_slice(body).map_err(|_| invalid())?;
288    if value.get("target").and_then(Value::as_str) != Some(target) {
289        return Err(AppError::Schema(
290            "Cline cache belongs to a different account".to_string(),
291        ));
292    }
293    let plan = value.get("plan").ok_or_else(invalid)?;
294    let rows = value
295        .get("windows")
296        .and_then(Value::as_array)
297        .filter(|rows| rows.len() == 3)
298        .ok_or_else(invalid)?;
299
300    // A listed window must carry its percentage: a half-written cache is
301    // refetched, not rendered as 0 %.
302    let mut windows: [Option<Window>; 3] = [None, None, None];
303    for (slot, row) in windows.iter_mut().zip(rows) {
304        if row.is_null() {
305            continue;
306        }
307        *slot = Some(Window {
308            percent: row
309                .get("percent")
310                .and_then(Value::as_f64)
311                .filter(|percent| percent.is_finite())
312                .ok_or_else(invalid)?,
313            reset: timestamp(row.get("reset")),
314        });
315    }
316
317    Ok(State {
318        plan: Plan {
319            name: plan.get("name").and_then(Value::as_str).map(str::to_string),
320            period_end: timestamp(plan.get("period_end")),
321        },
322        windows,
323    })
324}
325
326fn status_message(status: u16) -> &'static str {
327    match status {
328        401 | 403 => AUTH_FAILURE_MESSAGE,
329        429 => "Cline request was rate limited",
330        500..=599 => "Cline service is temporarily unavailable",
331        _ => "Cline request failed",
332    }
333}
334
335fn fallback_or_error(
336    cache: &Cache,
337    last_error: Option<(u16, String)>,
338    target: &str,
339    error: AppError,
340    now: DateTime<Utc>,
341) -> Result<FetchOutcome> {
342    crate::outcome::fallback(cache, last_error, error, |body| {
343        parse_cache(body, target).map(|state| state.snapshot(now))
344    })
345}
346
347fn handle_error(
348    cache: &Cache,
349    target: Option<&str>,
350    error: AppError,
351    now: DateTime<Utc>,
352) -> Result<FetchOutcome> {
353    let (error, last_error) = match error {
354        AppError::Http { status, .. } => {
355            let message = status_message(status).to_string();
356            cache.mark_stale();
357            cache.write_last_error(status, &message);
358            (
359                AppError::Http {
360                    status,
361                    body: message.clone(),
362                },
363                Some((status, message)),
364            )
365        }
366        AppError::Schema(_) => {
367            let message = SCHEMA_ERROR.to_string();
368            cache.mark_stale();
369            cache.write_last_error(0, &message);
370            (AppError::Schema(message.clone()), Some((0, message)))
371        }
372        error => (error, None),
373    };
374
375    match target {
376        Some(target) => fallback_or_error(cache, last_error, target, error, now),
377        None => Err(error),
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use tempfile::TempDir;
384
385    use super::*;
386
387    const PLAN_BODY: &str = include_str!("../../tests/fixtures/cline/plan.json");
388    const LIMITS_BODY: &str = include_str!("../../tests/fixtures/cline/usage-limits.json");
389
390    fn now() -> DateTime<Utc> {
391        "2026-10-09T10:00:00Z".parse().expect("timestamp")
392    }
393
394    fn credential() -> Credential {
395        Credential {
396            token: "workos:test-token".to_string(),
397            account_id: Some("usr-1".to_string()),
398            source: "test".to_string(),
399            expired: false,
400        }
401    }
402
403    fn cache_fixture() -> (TempDir, Cache) {
404        let dir = TempDir::new().expect("temporary cache directory");
405        let cache = Cache::at(dir.path().join("cline"));
406        cache.ensure_dir().expect("cache directory");
407        (dir, cache)
408    }
409
410    async fn refresh(
411        cache: &Cache,
412        endpoints: &Endpoints,
413        credential: &Credential,
414        ttl: Duration,
415        at: DateTime<Utc>,
416    ) -> Result<FetchOutcome> {
417        fetch_snapshot(
418            &reqwest::Client::new(),
419            credential,
420            cache,
421            endpoints,
422            ttl,
423            at,
424        )
425        .await
426    }
427
428    #[tokio::test]
429    async fn reports_the_limits_exactly_as_the_server_states_them() {
430        let mut server = mockito::Server::new_async().await;
431        let plan = server
432            .mock("GET", "/api/v1/users/me/plan")
433            .match_header("authorization", "Bearer workos:test-token")
434            .match_header("accept", "application/json")
435            .with_body(PLAN_BODY)
436            .create_async()
437            .await;
438        let limits = server
439            .mock("GET", "/api/v1/users/me/plan/usage-limits")
440            .match_header("authorization", "Bearer workos:test-token")
441            .with_body(LIMITS_BODY)
442            .create_async()
443            .await;
444        let (_dir, cache) = cache_fixture();
445
446        let output = refresh(
447            &cache,
448            &Endpoints { base: server.url() },
449            &credential(),
450            Duration::from_secs(60),
451            now(),
452        )
453        .await
454        .expect("a successful refresh");
455
456        let snapshot = output.snapshot;
457        assert_eq!(snapshot.plan.as_deref(), Some("Cline Pass (Monthly)"));
458        assert_eq!(snapshot.five_hour.as_ref().expect("5h").pct(), 0);
459        assert_eq!(snapshot.seven_day.as_ref().expect("weekly").pct(), 5);
460        assert_eq!(snapshot.thirty_day.as_ref().expect("monthly").pct(), 2);
461        assert!(!output.stale);
462        plan.assert_async().await;
463        limits.assert_async().await;
464    }
465
466    #[tokio::test]
467    async fn a_fresh_cache_is_aged_rather_than_refetched() {
468        let mut server = mockito::Server::new_async().await;
469        let plan = server
470            .mock("GET", "/api/v1/users/me/plan")
471            .with_body(PLAN_BODY)
472            .expect(1)
473            .create_async()
474            .await;
475        let limits = server
476            .mock("GET", "/api/v1/users/me/plan/usage-limits")
477            .with_body(LIMITS_BODY)
478            .expect(1)
479            .create_async()
480            .await;
481        let (_dir, cache) = cache_fixture();
482        let endpoints = Endpoints { base: server.url() };
483        let ttl = Duration::from_secs(600);
484        refresh(&cache, &endpoints, &credential(), ttl, now())
485            .await
486            .expect("first refresh");
487
488        // Past the weekly reset (2026-10-10T13:41Z) the cached read says the
489        // week is empty again, without touching the network.
490        let later = now() + chrono::Duration::days(2);
491        let output = refresh(&cache, &endpoints, &credential(), ttl, later)
492            .await
493            .expect("cached refresh");
494
495        let week = output.snapshot.seven_day.expect("weekly");
496        assert_eq!(week.pct(), 0);
497        assert_eq!(week.resets_at(), None);
498        assert_eq!(output.snapshot.thirty_day.expect("monthly").pct(), 2);
499        plan.assert_async().await;
500        limits.assert_async().await;
501    }
502
503    #[tokio::test]
504    async fn a_lapsed_token_serves_the_cached_answer_aged_to_now() {
505        let mut server = mockito::Server::new_async().await;
506        server
507            .mock("GET", "/api/v1/users/me/plan")
508            .with_body(PLAN_BODY)
509            .expect(1)
510            .create_async()
511            .await;
512        server
513            .mock("GET", "/api/v1/users/me/plan/usage-limits")
514            .with_body(LIMITS_BODY)
515            .expect(1)
516            .create_async()
517            .await;
518        let (_dir, cache) = cache_fixture();
519        let endpoints = Endpoints { base: server.url() };
520        refresh(&cache, &endpoints, &credential(), Duration::ZERO, now())
521            .await
522            .expect("a refresh while the token was live");
523
524        let lapsed = Credential {
525            expired: true,
526            ..credential()
527        };
528        let output = refresh(
529            &cache,
530            &endpoints,
531            &lapsed,
532            Duration::ZERO,
533            now() + chrono::Duration::days(2),
534        )
535        .await
536        .expect("the cached answer is still worth showing");
537
538        assert!(output.stale);
539        assert_eq!(output.snapshot.seven_day.expect("weekly").pct(), 0);
540        assert_eq!(output.snapshot.thirty_day.expect("monthly").pct(), 2);
541        let (code, message) = output.last_error.expect("a recorded reason");
542        assert_eq!(code, 401);
543        assert!(message.contains("expired"));
544    }
545
546    #[tokio::test]
547    async fn a_lapsed_token_with_no_cache_reports_the_sign_in_instead() {
548        let (_dir, cache) = cache_fixture();
549        let lapsed = Credential {
550            expired: true,
551            ..credential()
552        };
553
554        let error = refresh(
555            &cache,
556            &Endpoints {
557                base: "http://127.0.0.1:1".to_string(),
558            },
559            &lapsed,
560            Duration::ZERO,
561            now(),
562        )
563        .await
564        .expect_err("nothing to show and nothing to fetch");
565        assert!(error.to_string().contains("expired"));
566    }
567
568    #[tokio::test]
569    async fn unauthorized_without_cache_returns_a_redacted_error() {
570        let mut server = mockito::Server::new_async().await;
571        server
572            .mock("GET", "/api/v1/users/me/plan")
573            .with_status(401)
574            .with_body("workos:test-token should never be echoed")
575            .create_async()
576            .await;
577        let (_dir, cache) = cache_fixture();
578
579        let error = refresh(
580            &cache,
581            &Endpoints { base: server.url() },
582            &credential(),
583            Duration::ZERO,
584            now(),
585        )
586        .await
587        .expect_err("401 without a cache must fail");
588
589        let rendered = error.to_string();
590        assert!(rendered.contains("401"));
591        assert!(!rendered.contains("test-token"));
592    }
593
594    #[tokio::test]
595    async fn a_malformed_limits_response_is_a_schema_error_not_zero_percent() {
596        let mut server = mockito::Server::new_async().await;
597        server
598            .mock("GET", "/api/v1/users/me/plan")
599            .with_body(PLAN_BODY)
600            .create_async()
601            .await;
602        server
603            .mock("GET", "/api/v1/users/me/plan/usage-limits")
604            .with_body(r#"{"data":{"limits":[{"type":"weekly"}]},"success":true}"#)
605            .create_async()
606            .await;
607        let (_dir, cache) = cache_fixture();
608
609        let error = refresh(
610            &cache,
611            &Endpoints { base: server.url() },
612            &credential(),
613            Duration::ZERO,
614            now(),
615        )
616        .await
617        .expect_err("a limit without a percentage must not render");
618        assert!(matches!(error, AppError::Schema(_)));
619    }
620
621    #[tokio::test]
622    async fn a_cache_written_for_another_account_is_not_served() {
623        let mut server = mockito::Server::new_async().await;
624        server
625            .mock("GET", "/api/v1/users/me/plan")
626            .with_body(PLAN_BODY)
627            .expect(2)
628            .create_async()
629            .await;
630        server
631            .mock("GET", "/api/v1/users/me/plan/usage-limits")
632            .with_body(LIMITS_BODY)
633            .expect(1)
634            .create_async()
635            .await;
636        // The second refresh (another account, so no usable cache) hits the
637        // same routes and fails on the limits call.
638        server
639            .mock("GET", "/api/v1/users/me/plan/usage-limits")
640            .with_status(503)
641            .create_async()
642            .await;
643        let (_dir, cache) = cache_fixture();
644        let endpoints = Endpoints { base: server.url() };
645        refresh(&cache, &endpoints, &credential(), Duration::ZERO, now())
646            .await
647            .expect("first account refresh");
648
649        let other = Credential {
650            account_id: Some("usr-2".to_string()),
651            ..credential()
652        };
653        let error = refresh(&cache, &endpoints, &other, Duration::ZERO, now())
654            .await
655            .expect_err("another account's cache must not be served");
656        assert!(matches!(error, AppError::Http { status: 503, .. }));
657    }
658
659    #[test]
660    fn a_ledger_cache_from_the_previous_schema_is_ignored() {
661        let endpoints = Endpoints::default();
662        let old = format!(
663            r#"{{"target":"{}|user:x|v2","plan":{{}},"ledger":[]}}"#,
664            endpoints.base
665        );
666        assert!(parse_cache(old.as_bytes(), &target_key(&endpoints, "usr-1")).is_err());
667    }
668
669    #[test]
670    fn cached_target_is_bound_to_both_credential_and_endpoint() {
671        let endpoints = Endpoints::default();
672        let state = State::default();
673        let body = serde_json::to_vec(&state_repr(
674            &target_key(&endpoints, "usr-1"),
675            "credential-a",
676            &state,
677        ))
678        .unwrap();
679
680        assert_eq!(
681            cached_target(&body, &endpoints, "credential-a"),
682            Some(target_key(&endpoints, "usr-1"))
683        );
684        assert_eq!(cached_target(&body, &endpoints, "credential-b"), None);
685        assert_eq!(
686            cached_target(
687                &body,
688                &Endpoints {
689                    base: "https://other.example".to_string(),
690                },
691                "credential-a",
692            ),
693            None
694        );
695    }
696
697    #[tokio::test]
698    async fn api_key_identity_lookup_obeys_the_cache_ttl() {
699        let mut server = mockito::Server::new_async().await;
700        let me = server
701            .mock("GET", "/api/v1/users/me")
702            .with_body(r#"{"data":{"id":"usr-1"}}"#)
703            .expect(1)
704            .create_async()
705            .await;
706        let plan = server
707            .mock("GET", "/api/v1/users/me/plan")
708            .with_body(PLAN_BODY)
709            .expect(1)
710            .create_async()
711            .await;
712        let limits = server
713            .mock("GET", "/api/v1/users/me/plan/usage-limits")
714            .with_body(LIMITS_BODY)
715            .expect(1)
716            .create_async()
717            .await;
718        let (_dir, cache) = cache_fixture();
719        let endpoints = Endpoints { base: server.url() };
720        let api_key = Credential {
721            account_id: None,
722            ..credential()
723        };
724
725        refresh(
726            &cache,
727            &endpoints,
728            &api_key,
729            Duration::from_secs(600),
730            now(),
731        )
732        .await
733        .expect("first refresh");
734        let cached = refresh(
735            &cache,
736            &endpoints,
737            &api_key,
738            Duration::from_secs(600),
739            now(),
740        )
741        .await
742        .expect("second refresh should be cached");
743
744        assert!(!cached.off_the_wire());
745        me.assert_async().await;
746        plan.assert_async().await;
747        limits.assert_async().await;
748    }
749
750    #[tokio::test]
751    async fn api_key_reuses_a_fresh_cache_from_before_the_fingerprint_field() {
752        let mut server = mockito::Server::new_async().await;
753        let me = server
754            .mock("GET", "/api/v1/users/me")
755            .with_body(r#"{"data":{"id":"usr-1"}}"#)
756            .expect(1)
757            .create_async()
758            .await;
759        let plan = server
760            .mock("GET", "/api/v1/users/me/plan")
761            .expect(0)
762            .create_async()
763            .await;
764        let limits = server
765            .mock("GET", "/api/v1/users/me/plan/usage-limits")
766            .expect(0)
767            .create_async()
768            .await;
769        let (_dir, cache) = cache_fixture();
770        let endpoints = Endpoints { base: server.url() };
771        let mut old = state_repr(
772            &target_key(&endpoints, "usr-1"),
773            "not-present-before-this-fix",
774            &State::default(),
775        );
776        old.as_object_mut().unwrap().remove("credential");
777        cache
778            .write_payload(&serde_json::to_vec(&old).unwrap())
779            .unwrap();
780        let api_key = Credential {
781            account_id: None,
782            ..credential()
783        };
784
785        let cached = refresh(
786            &cache,
787            &endpoints,
788            &api_key,
789            Duration::from_secs(600),
790            now(),
791        )
792        .await
793        .expect("the previous cache schema remains readable");
794
795        assert!(!cached.off_the_wire());
796        me.assert_async().await;
797        plan.assert_async().await;
798        limits.assert_async().await;
799    }
800
801    #[tokio::test]
802    async fn api_key_identity_lookup_obeys_rate_limit_backoff() {
803        let mut server = mockito::Server::new_async().await;
804        let me = server
805            .mock("GET", "/api/v1/users/me")
806            .with_status(429)
807            .expect(1)
808            .create_async()
809            .await;
810        let (_dir, cache) = cache_fixture();
811        let endpoints = Endpoints { base: server.url() };
812        let api_key = Credential {
813            account_id: None,
814            ..credential()
815        };
816
817        for _ in 0..2 {
818            let error = refresh(&cache, &endpoints, &api_key, Duration::ZERO, now())
819                .await
820                .expect_err("rate-limited identity lookup");
821            assert!(matches!(error, AppError::Http { status: 429, .. }));
822        }
823
824        me.assert_async().await;
825        assert!(cache.backoff_remaining().is_some());
826    }
827
828    #[tokio::test]
829    async fn rate_limit_backoff_never_serves_or_probes_another_api_key() {
830        let mut server = mockito::Server::new_async().await;
831        let me = server
832            .mock("GET", "/api/v1/users/me")
833            .expect(0)
834            .create_async()
835            .await;
836        let (_dir, cache) = cache_fixture();
837        let endpoints = Endpoints { base: server.url() };
838        cache
839            .write_payload(
840                &serde_json::to_vec(&state_repr(
841                    &target_key(&endpoints, "usr-a"),
842                    &sha256_hex("api-key-a"),
843                    &State::default(),
844                ))
845                .unwrap(),
846            )
847            .unwrap();
848        cache.write_last_error(429, "rate limited");
849        let other_key = Credential {
850            token: "api-key-b".to_string(),
851            account_id: None,
852            ..credential()
853        };
854
855        let error = refresh(&cache, &endpoints, &other_key, Duration::ZERO, now())
856            .await
857            .expect_err("another key's cache must be refused during backoff");
858
859        assert!(matches!(error, AppError::Http { status: 429, .. }));
860        me.assert_async().await;
861    }
862}