Skip to main content

ai_usagebar/kiro/
fetch.rs

1//! Orchestrates a Kiro CLI snapshot: read kiro-cli's own cached AWS SSO OIDC
2//! token (`db.rs`), refresh it if it's close to expiry (`oauth.rs` — kiro-cli's
3//! own database is never written back to), persist the refreshed credential in
4//! ai-usagebar's account-scoped cache, then call
5//! `AmazonCodeWhispererService.GetUsageLimits` — the exact operation kiro-cli's
6//! own `/usage` slash command invokes. Cache/stale/error-fallback shape
7//! mirrors `cursor::fetch`.
8
9use std::path::Path;
10use std::time::Duration;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15use crate::cache::{Cache, acquire_lock_async, atomic_write};
16use crate::error::{AppError, Result};
17use crate::usage::KiroSnapshot;
18use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
19
20use super::db::{self, KiroCredentials};
21use super::oauth;
22use super::types::{self, UsageLimitsResponse};
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
25const REFRESH_TIMEOUT: Duration = Duration::from_secs(15);
26const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
27const REQUEST_TARGET: &str = "AmazonCodeWhispererService.GetUsageLimits";
28const OAUTH_CACHE_FILE: &str = "oauth.json";
29
30#[derive(Debug, Clone)]
31pub struct Endpoints {
32    pub usage_limits: String,
33    pub token: String,
34}
35
36impl Endpoints {
37    /// AWS's regional convention for the public CodeWhisperer Runtime
38    /// endpoint (`<service>.<region>.amazonaws.com`) — confirmed live against
39    /// this account. Enterprise IAM Identity Center accounts may instead be
40    /// proxied through `management.<region>.kiro.dev` (observed in kiro-cli's
41    /// own request trace for the same operation); both speak the same
42    /// request/response shape, so either is a config override away.
43    pub fn for_region(region: &str) -> Result<Self> {
44        oauth::validate_region(region)?;
45        Ok(Self {
46            usage_limits: format!("https://codewhisperer.{region}.amazonaws.com/"),
47            token: oauth::token_endpoint(region)?,
48        })
49    }
50}
51
52#[derive(Debug, Clone, Deserialize, Serialize)]
53struct PersistedOAuth {
54    account: String,
55    access_token: String,
56    refresh_token: String,
57    expires_at: DateTime<Utc>,
58}
59
60/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
61/// specialised to its snapshot.
62pub type FetchOutcome = crate::outcome::Outcome<KiroSnapshot>;
63
64/// Cache-aware fetch. `db_path` is kiro-cli's `data.sqlite3` — the caller
65/// resolves `[kiro] db_path` (config override) vs [`db::default_db_path`],
66/// the same override pattern as `cursor.db_path`.
67pub async fn fetch_snapshot(
68    client: &reqwest::Client,
69    db_path: &Path,
70    cache: &Cache,
71    cache_ttl: Duration,
72) -> Result<FetchOutcome> {
73    fetch_snapshot_at(client, db_path, cache, cache_ttl, None, Utc::now()).await
74}
75
76/// Clock seam for cache rollover tests, mirroring `cursor::fetch::fetch_snapshot_at`.
77///
78/// `endpoints_override` exists only for tests: unlike every fixed-URL vendor
79/// here, Kiro's real endpoint is derived from the account's own region (read
80/// from `db_path`, not known until inside this function), so there is no
81/// fixed default a test could point at mockito. Production always passes
82/// `None` and gets `Endpoints::for_region(&creds.region)`.
83async fn fetch_snapshot_at(
84    client: &reqwest::Client,
85    db_path: &Path,
86    cache: &Cache,
87    cache_ttl: Duration,
88    endpoints_override: Option<&Endpoints>,
89    now: DateTime<Utc>,
90) -> Result<FetchOutcome> {
91    cache.ensure_dir()?;
92    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
93
94    // Resolve identity before accepting a cache hit — a `kiro-cli login`
95    // switch mid-session must not keep serving the previous account's cache.
96    let mut creds = db::read_credentials(db_path)?;
97
98    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
99        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &creds.account_key, now)
100    {
101        return Ok(outcome);
102    }
103    // #515: during a backoff `fresh_payload` returns bytes this refresh can
104    // refuse, such as a payload past its credit-cycle reset. The shared
105    // backoff still applies, as it does for the Anthropic API since #496:
106    // surface the countdown instead of contacting the server, or a refresh
107    // interval shorter than the backoff would re-request and re-arm it forever.
108    if let Some(remaining) = cache.backoff_remaining() {
109        return Err(AppError::Http {
110            status: 429,
111            body: format!(
112                "rate limited; next attempt in {}",
113                crate::cache::human_backoff(remaining)
114            ),
115        });
116    }
117
118    apply_persisted_oauth(cache, &mut creds)?;
119
120    let derived;
121    let endpoints = match endpoints_override {
122        Some(e) => e,
123        None => {
124            derived = Endpoints::for_region(&creds.region)?;
125            &derived
126        }
127    };
128    match fetch_live(client, endpoints, cache, &creds, now).await {
129        Ok(snap) => {
130            let bytes = serde_json::to_vec(&snap_to_json(&snap, &creds.account_key))?;
131            cache.write_payload(&bytes)?;
132            Ok(crate::outcome::Outcome::fresh(snap))
133        }
134        Err(e) if e.is_transient() => fallback_silent(cache, &creds.account_key, now, e),
135        Err(e) => {
136            cache.mark_stale();
137            if let Some((code, msg)) = error_to_pair(&e) {
138                cache.write_last_error(code, &msg);
139            }
140            fallback_with_error(cache, &creds.account_key, now, e)
141        }
142    }
143}
144
145fn fallback_silent(
146    cache: &Cache,
147    account: &str,
148    now: DateTime<Utc>,
149    original: AppError,
150) -> Result<FetchOutcome> {
151    crate::outcome::fallback(cache, None, original, |bytes| {
152        parse_cache_at(bytes, account, now)
153    })
154}
155
156fn fallback_with_error(
157    cache: &Cache,
158    account: &str,
159    now: DateTime<Utc>,
160    original: AppError,
161) -> Result<FetchOutcome> {
162    let last_error = error_to_pair(&original);
163    crate::outcome::fallback(cache, last_error, original, |bytes| {
164        parse_cache_at(bytes, account, now)
165    })
166}
167
168/// Never surface upstream bodies for auth failures: a 401/403 from an
169/// internal Bearer-token endpoint is not guaranteed not to echo something
170/// account-identifying back. Mirrors `cursor::fetch::error_to_pair`.
171fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
172    match e {
173        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
174            Some((*status, "Kiro CLI authentication failed".into()))
175        }
176        AppError::Http { status, body } => Some((*status, body.clone())),
177        AppError::Credentials(msg) => Some((0, msg.clone())),
178        e => Some((0, e.to_string())),
179    }
180}
181
182fn reuse_cache(
183    bytes: &[u8],
184    cache: &Cache,
185    stale: bool,
186    account: &str,
187    now: DateTime<Utc>,
188) -> Result<FetchOutcome> {
189    let snap = parse_cache_at(bytes, account, now)?;
190    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
191}
192
193fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<KiroSnapshot> {
194    let v: serde_json::Value = serde_json::from_slice(bytes)?;
195    if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
196        return Err(AppError::Schema(
197            "kiro cache belongs to a different account; refetching".into(),
198        ));
199    }
200    let plan = v["plan"]
201        .as_str()
202        .filter(|plan| !plan.trim().is_empty())
203        .ok_or_else(|| AppError::Schema("kiro cache: invalid plan".into()))?
204        .to_string();
205    let used = v["used"]
206        .as_f64()
207        .filter(|n| n.is_finite() && *n >= 0.0)
208        .ok_or_else(|| AppError::Schema("kiro cache: invalid used".into()))?;
209    let limit = v["limit"]
210        .as_f64()
211        .filter(|n| n.is_finite() && *n >= 0.0)
212        .ok_or_else(|| AppError::Schema("kiro cache: invalid limit".into()))?;
213    let reset_at = match &v["reset_at"] {
214        serde_json::Value::Null => None,
215        serde_json::Value::String(s) => Some(
216            DateTime::parse_from_rfc3339(s)
217                .map_err(|e| AppError::Schema(format!("kiro cache: invalid reset_at: {e}")))?
218                .with_timezone(&Utc),
219        ),
220        _ => return Err(AppError::Schema("kiro cache: invalid reset_at".into())),
221    };
222    // Only reject a *known-past* reset — the cycle rolled over while cached.
223    // A missing reset (None) is a legitimate shape, not staleness.
224    if let Some(reset_at) = reset_at
225        && reset_at <= now
226    {
227        return Err(AppError::Schema(
228            "kiro cache is past its credit-cycle reset; refetching".into(),
229        ));
230    }
231    Ok(KiroSnapshot {
232        plan,
233        used,
234        limit,
235        reset_at,
236    })
237}
238
239fn snap_to_json(snap: &KiroSnapshot, account: &str) -> serde_json::Value {
240    serde_json::json!({
241        "account": account,
242        "plan": snap.plan,
243        "used": snap.used,
244        "limit": snap.limit,
245        "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
246    })
247}
248
249fn oauth_cache_path(cache: &Cache) -> std::path::PathBuf {
250    cache.dir().join(OAUTH_CACHE_FILE)
251}
252
253fn read_persisted_oauth(cache: &Cache, account: &str) -> Result<Option<PersistedOAuth>> {
254    let path = oauth_cache_path(cache);
255    let bytes = match std::fs::read(&path) {
256        Ok(bytes) => bytes,
257        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
258        Err(e) => return Err(AppError::io_at(&path, e)),
259    };
260    let persisted: PersistedOAuth = serde_json::from_slice(&bytes).map_err(|e| {
261        AppError::Credentials(format!(
262            "ai-usagebar's cached Kiro credentials at {} are malformed ({e}); remove that file and try again",
263            path.display()
264        ))
265    })?;
266    if persisted.account != account {
267        return Ok(None);
268    }
269    if persisted.access_token.trim().is_empty() || persisted.refresh_token.trim().is_empty() {
270        return Err(AppError::Credentials(format!(
271            "ai-usagebar's cached Kiro credentials at {} are incomplete; remove that file and try again",
272            path.display()
273        )));
274    }
275    Ok(Some(persisted))
276}
277
278fn apply_persisted_oauth(cache: &Cache, creds: &mut KiroCredentials) -> Result<()> {
279    let Some(persisted) = read_persisted_oauth(cache, &creds.account_key)? else {
280        return Ok(());
281    };
282    // Kiro may refresh its own database independently. Prefer whichever
283    // account-matched credential has the later expiry.
284    if persisted.expires_at > creds.expires_at {
285        creds.access_token = persisted.access_token;
286        creds.refresh_token = persisted.refresh_token;
287        creds.expires_at = persisted.expires_at;
288    }
289    Ok(())
290}
291
292fn write_persisted_oauth(cache: &Cache, persisted: &PersistedOAuth) -> Result<()> {
293    let path = oauth_cache_path(cache);
294    let bytes = serde_json::to_vec_pretty(persisted)?;
295    atomic_write(&path, &bytes)?;
296    #[cfg(unix)]
297    {
298        use std::os::unix::fs::PermissionsExt;
299        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
300            .map_err(|e| AppError::io_at(&path, e))?;
301    }
302    Ok(())
303}
304
305async fn fetch_live(
306    client: &reqwest::Client,
307    endpoints: &Endpoints,
308    cache: &Cache,
309    creds: &KiroCredentials,
310    now: DateTime<Utc>,
311) -> Result<KiroSnapshot> {
312    let access_token = if oauth::needs_refresh(creds.expires_at.timestamp(), now.timestamp()) {
313        let refreshed = tokio::time::timeout(
314            REFRESH_TIMEOUT,
315            oauth::refresh(
316                client,
317                &endpoints.token,
318                &creds.client_id,
319                &creds.client_secret,
320                &creds.refresh_token,
321            ),
322        )
323        .await
324        .map_err(|_| {
325            AppError::Transport(format!("kiro token refresh timeout: {}", endpoints.token))
326        })?
327        .map_err(|e| {
328            // A refresh that never reached the token endpoint says nothing
329            // about the login: keep it transient, so the cache covers it the
330            // way it covers the usage call's own network failures.
331            if e.is_transient() {
332                e
333            } else {
334                AppError::Credentials(format!(
335                    "Kiro CLI token refresh failed ({e}). Run `kiro-cli login` again."
336                ))
337            }
338        })?;
339        let expires_in = i64::try_from(refreshed.expires_in)
340            .map_err(|_| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
341        let expires_at_secs = now
342            .timestamp()
343            .checked_add(expires_in)
344            .ok_or_else(|| AppError::Schema("kiro token refresh expiry overflowed".into()))?;
345        let expires_at = DateTime::from_timestamp(expires_at_secs, 0)
346            .ok_or_else(|| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
347        let persisted = PersistedOAuth {
348            account: creds.account_key.clone(),
349            access_token: refreshed.access_token,
350            refresh_token: refreshed
351                .refresh_token
352                .unwrap_or_else(|| creds.refresh_token.clone()),
353            expires_at,
354        };
355        write_persisted_oauth(cache, &persisted).map_err(|e| {
356            AppError::Credentials(format!(
357                "refreshed Kiro CLI credentials could not be saved ({e}); run `kiro-cli login` again if the refresh token was rotated"
358            ))
359        })?;
360        persisted.access_token
361    } else {
362        creds.access_token.clone()
363    };
364
365    let body = serde_json::json!({
366        "origin": "AI_EDITOR",
367        "profileArn": creds.profile_arn,
368        "resourceType": "AGENTIC_REQUEST",
369    });
370
371    let resp = tokio::time::timeout(
372        HTTP_TIMEOUT,
373        client
374            .post(&endpoints.usage_limits)
375            .header("Content-Type", "application/x-amz-json-1.0")
376            .header("x-amz-target", REQUEST_TARGET)
377            .header("Authorization", format!("Bearer {access_token}"))
378            .json(&body)
379            .send(),
380    )
381    .await
382    .map_err(|_| AppError::Transport(format!("kiro timeout: {}", endpoints.usage_limits)))??;
383
384    let status = resp.status();
385    if !status.is_success() {
386        let body = if matches!(status.as_u16(), 401 | 403) {
387            "Kiro CLI authentication failed".into()
388        } else {
389            format!("Kiro CLI API returned HTTP {}", status.as_u16())
390        };
391        return Err(AppError::Http {
392            status: status.as_u16(),
393            body,
394        });
395    }
396
397    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
398    let parsed: UsageLimitsResponse = serde_json::from_slice(&bytes)
399        .map_err(|e| AppError::Schema(format!("kiro usage-limits response: {e}")))?;
400    types::to_snapshot(parsed)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use rusqlite::Connection;
407    use tempfile::TempDir;
408
409    fn cache_fixture() -> (TempDir, Cache) {
410        let td = TempDir::new().unwrap();
411        let cache = Cache::at(td.path().join("kiro"));
412        cache.ensure_dir().unwrap();
413        (td, cache)
414    }
415
416    fn seed_db(dir: &TempDir, expires_at: &str) -> std::path::PathBuf {
417        let path = dir.path().join("data.sqlite3");
418        let conn = Connection::open(&path).unwrap();
419        conn.execute("CREATE TABLE auth_kv (key TEXT, value TEXT)", [])
420            .unwrap();
421        conn.execute("CREATE TABLE state (key TEXT, value TEXT)", [])
422            .unwrap();
423        let token = serde_json::json!({
424            "access_token": "AT", "refresh_token": "RT",
425            "expires_at": expires_at, "region": "us-east-1",
426        })
427        .to_string();
428        let device =
429            serde_json::json!({"client_id": "CID", "client_secret": "CSECRET"}).to_string();
430        let profile =
431            serde_json::json!({"arn": "arn:aws:codewhisperer:us-east-1:1:profile/A"}).to_string();
432        conn.execute(
433            "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:token', ?1)",
434            [&token],
435        )
436        .unwrap();
437        conn.execute(
438            "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:device-registration', ?1)",
439            [&device],
440        )
441        .unwrap();
442        conn.execute(
443            "INSERT INTO state (key, value) VALUES ('api.codewhisperer.profile', ?1)",
444            [&profile],
445        )
446        .unwrap();
447        path
448    }
449
450    fn account_key(dir: &TempDir) -> String {
451        let path = dir.path().join("data.sqlite3");
452        db::read_credentials(&path).unwrap().account_key
453    }
454
455    fn usage_json() -> String {
456        r#"{
457            "nextDateReset": 4102444800.0,
458            "subscriptionInfo": { "subscriptionTitle": "KIRO POWER" },
459            "usageBreakdownList": [{
460                "resourceType": "CREDIT",
461                "currentUsageWithPrecision": 40.0,
462                "usageLimitWithPrecision": 100.0
463            }]
464        }"#
465        .to_string()
466    }
467
468    #[tokio::test]
469    async fn live_fetch_reads_token_from_db_and_calls_get_usage_limits() {
470        let mut server = mockito::Server::new_async().await;
471        let m = server
472            .mock("POST", "/")
473            .match_header("x-amz-target", "AmazonCodeWhispererService.GetUsageLimits")
474            .match_header("authorization", "Bearer AT")
475            .with_status(200)
476            .with_body(usage_json())
477            .create_async()
478            .await;
479
480        let db_dir = TempDir::new().unwrap();
481        // Far future — no refresh needed.
482        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
483        let client = reqwest::Client::new();
484        let endpoints_url = server.url();
485
486        let creds = db::read_credentials(&db_path).unwrap();
487        let (_cache_dir, cache) = cache_fixture();
488        let out = fetch_live(
489            &client,
490            &Endpoints {
491                usage_limits: endpoints_url,
492                token: "unused".into(),
493            },
494            &cache,
495            &creds,
496            Utc::now(),
497        )
498        .await
499        .unwrap();
500
501        assert_eq!(out.plan, "KIRO POWER");
502        assert_eq!(out.used, 40.0);
503        assert_eq!(out.limit, 100.0);
504        m.assert_async().await;
505    }
506
507    #[tokio::test]
508    async fn expired_token_is_refreshed_before_the_usage_call() {
509        let mut server = mockito::Server::new_async().await;
510        let token_mock = server
511            .mock("POST", "/token")
512            .with_status(200)
513            .with_body(r#"{"accessToken":"NEW-AT","expiresIn":3600}"#)
514            .create_async()
515            .await;
516        let usage_mock = server
517            .mock("POST", "/")
518            .match_header("authorization", "Bearer NEW-AT")
519            .with_status(200)
520            .with_body(usage_json())
521            .create_async()
522            .await;
523
524        let db_dir = TempDir::new().unwrap();
525        // Already expired — must trigger a refresh.
526        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
527        let creds = db::read_credentials(&db_path).unwrap();
528        let (_cache_dir, cache) = cache_fixture();
529        let client = reqwest::Client::new();
530
531        let out = fetch_live(
532            &client,
533            &Endpoints {
534                usage_limits: server.url(),
535                token: format!("{}/token", server.url()),
536            },
537            &cache,
538            &creds,
539            Utc::now(),
540        )
541        .await
542        .unwrap();
543
544        assert_eq!(out.used, 40.0);
545        token_mock.assert_async().await;
546        usage_mock.assert_async().await;
547    }
548
549    #[tokio::test]
550    async fn refreshed_credentials_are_reused_and_rotated_token_is_retained() {
551        let mut server = mockito::Server::new_async().await;
552        let token_mock = server
553            .mock("POST", "/token")
554            .match_body(mockito::Matcher::Json(serde_json::json!({
555                "clientId": "CID",
556                "clientSecret": "CSECRET",
557                "grantType": "refresh_token",
558                "refreshToken": "RT",
559            })))
560            .with_status(200)
561            .with_body(r#"{"accessToken":"NEW-AT","refreshToken":"ROTATED-RT","expiresIn":3600}"#)
562            .expect(1)
563            .create_async()
564            .await;
565        let usage_mock = server
566            .mock("POST", "/")
567            .match_header("authorization", "Bearer NEW-AT")
568            .with_status(200)
569            .with_body(usage_json())
570            .expect(2)
571            .create_async()
572            .await;
573
574        let db_dir = TempDir::new().unwrap();
575        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
576        let (_cache_dir, cache) = cache_fixture();
577        let endpoints = Endpoints {
578            usage_limits: server.url(),
579            token: format!("{}/token", server.url()),
580        };
581        let now = DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z")
582            .unwrap()
583            .with_timezone(&Utc);
584
585        for _ in 0..2 {
586            let out = fetch_snapshot_at(
587                &reqwest::Client::new(),
588                &db_path,
589                &cache,
590                Duration::ZERO,
591                Some(&endpoints),
592                now,
593            )
594            .await
595            .unwrap();
596            assert_eq!(out.snapshot.used, 40.0);
597        }
598
599        let persisted: serde_json::Value =
600            serde_json::from_slice(&std::fs::read(oauth_cache_path(&cache)).unwrap()).unwrap();
601        assert_eq!(persisted["access_token"], "NEW-AT");
602        assert_eq!(persisted["refresh_token"], "ROTATED-RT");
603        assert_eq!(persisted["account"], account_key(&db_dir));
604        #[cfg(unix)]
605        {
606            use std::os::unix::fs::PermissionsExt;
607            let mode = std::fs::metadata(oauth_cache_path(&cache))
608                .unwrap()
609                .permissions()
610                .mode();
611            assert_eq!(mode & 0o077, 0);
612        }
613
614        token_mock.assert_async().await;
615        usage_mock.assert_async().await;
616    }
617
618    #[test]
619    fn persisted_credentials_from_another_account_are_ignored() {
620        let db_dir = TempDir::new().unwrap();
621        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
622        let mut creds = db::read_credentials(&db_path).unwrap();
623        let (_cache_dir, cache) = cache_fixture();
624        write_persisted_oauth(
625            &cache,
626            &PersistedOAuth {
627                account: "another-account".into(),
628                access_token: "OTHER-AT".into(),
629                refresh_token: "OTHER-RT".into(),
630                expires_at: DateTime::parse_from_rfc3339("2100-01-01T00:00:00Z")
631                    .unwrap()
632                    .with_timezone(&Utc),
633            },
634        )
635        .unwrap();
636
637        apply_persisted_oauth(&cache, &mut creds).unwrap();
638
639        assert_eq!(creds.access_token, "AT");
640        assert_eq!(creds.refresh_token, "RT");
641    }
642
643    #[tokio::test]
644    async fn refresh_failure_is_a_credentials_error() {
645        let mut server = mockito::Server::new_async().await;
646        server
647            .mock("POST", "/token")
648            .with_status(400)
649            .with_body(r#"{"error":"invalid_grant"}"#)
650            .create_async()
651            .await;
652
653        let db_dir = TempDir::new().unwrap();
654        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
655        let creds = db::read_credentials(&db_path).unwrap();
656        let (_cache_dir, cache) = cache_fixture();
657        let client = reqwest::Client::new();
658
659        let err = fetch_live(
660            &client,
661            &Endpoints {
662                usage_limits: server.url(),
663                token: format!("{}/token", server.url()),
664            },
665            &cache,
666            &creds,
667            Utc::now(),
668        )
669        .await
670        .unwrap_err();
671        assert!(matches!(err, AppError::Credentials(_)));
672    }
673
674    /// A refresh that cannot reach the token endpoint is a network failure,
675    /// not a rejected login: it must not record "run `kiro-cli login`" as the
676    /// refresh error, and with no cache to fall back on it stays transient.
677    #[tokio::test]
678    async fn a_refresh_that_cannot_connect_is_not_a_sign_in_error() {
679        let db_dir = TempDir::new().unwrap();
680        let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
681        let (_cache_dir, cache) = cache_fixture();
682
683        let err = fetch_snapshot_at(
684            &reqwest::Client::new(),
685            &db_path,
686            &cache,
687            Duration::ZERO,
688            Some(&Endpoints {
689                usage_limits: "http://127.0.0.1:1".into(),
690                token: "http://127.0.0.1:1/token".into(),
691            }),
692            Utc::now(),
693        )
694        .await
695        .unwrap_err();
696
697        assert!(err.is_transient(), "{err:?}");
698        assert_eq!(cache.read_last_error(), None);
699    }
700
701    #[tokio::test]
702    async fn a_stale_cache_falls_back_when_the_live_call_fails() {
703        let mut server = mockito::Server::new_async().await;
704        server
705            .mock("POST", "/")
706            .with_status(500)
707            .with_body("boom")
708            .create_async()
709            .await;
710
711        let db_dir = TempDir::new().unwrap();
712        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
713        let (_cache_dir, cache) = cache_fixture();
714        let account = account_key(&db_dir);
715        cache
716            .write_payload(
717                serde_json::to_vec(&serde_json::json!({
718                    "account": account,
719                    "plan": "KIRO POWER",
720                    "used": 10.0,
721                    "limit": 100.0,
722                    "reset_at": null,
723                }))
724                .unwrap()
725                .as_slice(),
726            )
727            .unwrap();
728        // Force the cache to look old enough to require a live refetch.
729        std::thread::sleep(std::time::Duration::from_millis(5));
730
731        let client = reqwest::Client::new();
732        let out = fetch_snapshot_at(
733            &client,
734            &db_path,
735            &cache,
736            Duration::from_millis(1),
737            Some(&Endpoints {
738                usage_limits: server.url(),
739                token: format!("{}/token", server.url()),
740            }),
741            Utc::now(),
742        )
743        .await
744        .unwrap();
745
746        assert!(out.stale);
747        assert_eq!(out.snapshot.used, 10.0);
748        assert!(out.last_error.is_some());
749    }
750
751    #[tokio::test]
752    async fn fresh_cache_is_served_without_a_network_call() {
753        let db_dir = TempDir::new().unwrap();
754        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
755        let (_cache_dir, cache) = cache_fixture();
756        let account = account_key(&db_dir);
757        cache
758            .write_payload(
759                serde_json::to_vec(&serde_json::json!({
760                    "account": account,
761                    "plan": "KIRO POWER",
762                    "used": 10.0,
763                    "limit": 100.0,
764                    "reset_at": null,
765                }))
766                .unwrap()
767                .as_slice(),
768            )
769            .unwrap();
770
771        let client = reqwest::Client::new();
772        // Deliberately no mock server configured: a fresh-cache hit must
773        // never reach the network, so pointing the override at a bogus
774        // "http://127.0.0.1:1" endpoint is a canary — the test would fail
775        // with a transport error if the cache short-circuit stopped working.
776        let out = fetch_snapshot_at(
777            &client,
778            &db_path,
779            &cache,
780            Duration::from_secs(60),
781            Some(&Endpoints {
782                usage_limits: "http://127.0.0.1:1".into(),
783                token: "http://127.0.0.1:1".into(),
784            }),
785            Utc::now(),
786        )
787        .await
788        .unwrap();
789
790        assert!(!out.stale);
791        assert_eq!(out.snapshot.used, 10.0);
792    }
793
794    #[tokio::test]
795    async fn cache_from_a_different_account_is_not_reused() {
796        let mut server = mockito::Server::new_async().await;
797        let m = server
798            .mock("POST", "/")
799            .with_status(200)
800            .with_body(usage_json())
801            .create_async()
802            .await;
803
804        let db_dir = TempDir::new().unwrap();
805        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
806        let (_cache_dir, cache) = cache_fixture();
807        cache
808            .write_payload(
809                serde_json::to_vec(&serde_json::json!({
810                    "account": "some-other-account",
811                    "plan": "STALE PLAN",
812                    "used": 10.0,
813                    "limit": 100.0,
814                    "reset_at": null,
815                }))
816                .unwrap()
817                .as_slice(),
818            )
819            .unwrap();
820
821        let client = reqwest::Client::new();
822        let out = fetch_snapshot_at(
823            &client,
824            &db_path,
825            &cache,
826            Duration::from_secs(60),
827            Some(&Endpoints {
828                usage_limits: server.url(),
829                token: format!("{}/token", server.url()),
830            }),
831            Utc::now(),
832        )
833        .await
834        .unwrap();
835
836        // The account-mismatched cache was rejected, so this came from the
837        // live mock (used=40, plan="KIRO POWER"), not the stale cache entry.
838        assert_eq!(out.snapshot.used, 40.0);
839        assert_eq!(out.snapshot.plan, "KIRO POWER");
840        assert!(!out.stale);
841        m.assert_async().await;
842    }
843
844    /// The credit cycle rolled over while cached: serving that cache during
845    /// an outage would show last cycle's usage as if it were current. The
846    /// parse must reject it, surfacing the live error instead. Mirrors
847    /// `cursor::fetch`'s `cache_past_its_billing_reset_is_not_served_during_an_outage`.
848    #[tokio::test]
849    async fn cache_past_its_credit_reset_is_not_served_during_an_outage() {
850        let db_dir = TempDir::new().unwrap();
851        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
852        let (_cache_dir, cache) = cache_fixture();
853        let account = account_key(&db_dir);
854        cache
855            .write_payload(
856                serde_json::to_vec(&serde_json::json!({
857                    "account": account,
858                    "plan": "KIRO POWER",
859                    "used": 10.0,
860                    "limit": 100.0,
861                    "reset_at": "2026-08-04T00:00:00Z",
862                }))
863                .unwrap()
864                .as_slice(),
865            )
866            .unwrap();
867
868        let mut server = mockito::Server::new_async().await;
869        server
870            .mock("POST", "/")
871            .with_status(503)
872            .create_async()
873            .await;
874        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
875            .unwrap()
876            .with_timezone(&Utc);
877        let err = fetch_snapshot_at(
878            &reqwest::Client::new(),
879            &db_path,
880            &cache,
881            Duration::from_secs(0),
882            Some(&Endpoints {
883                usage_limits: server.url(),
884                token: format!("{}/token", server.url()),
885            }),
886            now,
887        )
888        .await
889        .unwrap_err();
890        assert!(matches!(err, AppError::Http { status: 503, .. }));
891    }
892
893    /// #515: the cached payload is past its credit-cycle reset, so
894    /// `reuse_cache` refuses it, but a 429 backoff armed moments ago still
895    /// applies. The refresh must surface the shared countdown without
896    /// contacting the server. Mirrors `cursor::fetch`'s
897    /// `an_active_backoff_is_not_bypassed_after_the_billing_reset`.
898    #[tokio::test]
899    async fn an_active_backoff_is_not_bypassed_after_the_credit_reset() {
900        let db_dir = TempDir::new().unwrap();
901        let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
902        let (_cache_dir, cache) = cache_fixture();
903        let account = account_key(&db_dir);
904        cache
905            .write_payload(
906                serde_json::to_vec(&serde_json::json!({
907                    "account": account,
908                    "plan": "KIRO POWER",
909                    "used": 10.0,
910                    "limit": 100.0,
911                    "reset_at": "2026-08-04T00:00:00Z",
912                }))
913                .unwrap()
914                .as_slice(),
915            )
916            .unwrap();
917        // Armed against the real clock, like a genuine 429 would be; the
918        // payload above is seconds fresh, so `fresh_payload` returns it and
919        // the reset check refuses it.
920        cache.note_rate_limit_at(std::time::SystemTime::now());
921
922        let mut server = mockito::Server::new_async().await;
923        let usage = server
924            .mock("POST", "/")
925            .with_status(500)
926            .with_body("must not be contacted during backoff")
927            .expect(0)
928            .create_async()
929            .await;
930        let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
931            .unwrap()
932            .with_timezone(&Utc);
933        let err = fetch_snapshot_at(
934            &reqwest::Client::new(),
935            &db_path,
936            &cache,
937            Duration::from_secs(600),
938            Some(&Endpoints {
939                usage_limits: server.url(),
940                token: format!("{}/token", server.url()),
941            }),
942            now,
943        )
944        .await
945        .unwrap_err();
946        usage.assert_async().await;
947        match &err {
948            AppError::Http { status, body } => {
949                assert_eq!(status, &429, "body: {body}");
950                assert!(body.contains("rate limited"), "body: {body}");
951            }
952            other => panic!("expected the shared backoff 429, got {other:?}"),
953        }
954    }
955}