1use 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 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
60pub type FetchOutcome = crate::outcome::Outcome<KiroSnapshot>;
63
64pub 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
76async 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 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 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
168fn 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 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 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 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 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 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 #[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 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 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 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 #[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 #[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 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}