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
104 apply_persisted_oauth(cache, &mut creds)?;
105
106 let derived;
107 let endpoints = match endpoints_override {
108 Some(e) => e,
109 None => {
110 derived = Endpoints::for_region(&creds.region)?;
111 &derived
112 }
113 };
114 match fetch_live(client, endpoints, cache, &creds, now).await {
115 Ok(snap) => {
116 let bytes = serde_json::to_vec(&snap_to_json(&snap, &creds.account_key))?;
117 cache.write_payload(&bytes)?;
118 Ok(crate::outcome::Outcome::fresh(snap))
119 }
120 Err(e) if e.is_transient() => fallback_silent(cache, &creds.account_key, now, e),
121 Err(e) => {
122 cache.mark_stale();
123 if let Some((code, msg)) = error_to_pair(&e) {
124 cache.write_last_error(code, &msg);
125 }
126 fallback_with_error(cache, &creds.account_key, now, e)
127 }
128 }
129}
130
131fn fallback_silent(
132 cache: &Cache,
133 account: &str,
134 now: DateTime<Utc>,
135 original: AppError,
136) -> Result<FetchOutcome> {
137 crate::outcome::fallback(cache, None, original, |bytes| {
138 parse_cache_at(bytes, account, now)
139 })
140}
141
142fn fallback_with_error(
143 cache: &Cache,
144 account: &str,
145 now: DateTime<Utc>,
146 original: AppError,
147) -> Result<FetchOutcome> {
148 let last_error = error_to_pair(&original);
149 crate::outcome::fallback(cache, last_error, original, |bytes| {
150 parse_cache_at(bytes, account, now)
151 })
152}
153
154fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
158 match e {
159 AppError::Http { status, .. } if matches!(status, 401 | 403) => {
160 Some((*status, "Kiro CLI authentication failed".into()))
161 }
162 AppError::Http { status, body } => Some((*status, body.clone())),
163 AppError::Credentials(msg) => Some((0, msg.clone())),
164 e => Some((0, e.to_string())),
165 }
166}
167
168fn reuse_cache(
169 bytes: &[u8],
170 cache: &Cache,
171 stale: bool,
172 account: &str,
173 now: DateTime<Utc>,
174) -> Result<FetchOutcome> {
175 let snap = parse_cache_at(bytes, account, now)?;
176 Ok(crate::outcome::Outcome::cached(snap, cache, stale))
177}
178
179fn parse_cache_at(bytes: &[u8], account: &str, now: DateTime<Utc>) -> Result<KiroSnapshot> {
180 let v: serde_json::Value = serde_json::from_slice(bytes)?;
181 if v.get("account").and_then(serde_json::Value::as_str) != Some(account) {
182 return Err(AppError::Schema(
183 "kiro cache belongs to a different account; refetching".into(),
184 ));
185 }
186 let plan = v["plan"]
187 .as_str()
188 .filter(|plan| !plan.trim().is_empty())
189 .ok_or_else(|| AppError::Schema("kiro cache: invalid plan".into()))?
190 .to_string();
191 let used = v["used"]
192 .as_f64()
193 .filter(|n| n.is_finite() && *n >= 0.0)
194 .ok_or_else(|| AppError::Schema("kiro cache: invalid used".into()))?;
195 let limit = v["limit"]
196 .as_f64()
197 .filter(|n| n.is_finite() && *n >= 0.0)
198 .ok_or_else(|| AppError::Schema("kiro cache: invalid limit".into()))?;
199 let reset_at = match &v["reset_at"] {
200 serde_json::Value::Null => None,
201 serde_json::Value::String(s) => Some(
202 DateTime::parse_from_rfc3339(s)
203 .map_err(|e| AppError::Schema(format!("kiro cache: invalid reset_at: {e}")))?
204 .with_timezone(&Utc),
205 ),
206 _ => return Err(AppError::Schema("kiro cache: invalid reset_at".into())),
207 };
208 if let Some(reset_at) = reset_at
211 && reset_at <= now
212 {
213 return Err(AppError::Schema(
214 "kiro cache is past its credit-cycle reset; refetching".into(),
215 ));
216 }
217 Ok(KiroSnapshot {
218 plan,
219 used,
220 limit,
221 reset_at,
222 })
223}
224
225fn snap_to_json(snap: &KiroSnapshot, account: &str) -> serde_json::Value {
226 serde_json::json!({
227 "account": account,
228 "plan": snap.plan,
229 "used": snap.used,
230 "limit": snap.limit,
231 "reset_at": snap.reset_at.map(|dt| dt.to_rfc3339()),
232 })
233}
234
235fn oauth_cache_path(cache: &Cache) -> std::path::PathBuf {
236 cache.dir().join(OAUTH_CACHE_FILE)
237}
238
239fn read_persisted_oauth(cache: &Cache, account: &str) -> Result<Option<PersistedOAuth>> {
240 let path = oauth_cache_path(cache);
241 let bytes = match std::fs::read(&path) {
242 Ok(bytes) => bytes,
243 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
244 Err(e) => return Err(AppError::io_at(&path, e)),
245 };
246 let persisted: PersistedOAuth = serde_json::from_slice(&bytes).map_err(|e| {
247 AppError::Credentials(format!(
248 "ai-usagebar's cached Kiro credentials at {} are malformed ({e}); remove that file and try again",
249 path.display()
250 ))
251 })?;
252 if persisted.account != account {
253 return Ok(None);
254 }
255 if persisted.access_token.trim().is_empty() || persisted.refresh_token.trim().is_empty() {
256 return Err(AppError::Credentials(format!(
257 "ai-usagebar's cached Kiro credentials at {} are incomplete; remove that file and try again",
258 path.display()
259 )));
260 }
261 Ok(Some(persisted))
262}
263
264fn apply_persisted_oauth(cache: &Cache, creds: &mut KiroCredentials) -> Result<()> {
265 let Some(persisted) = read_persisted_oauth(cache, &creds.account_key)? else {
266 return Ok(());
267 };
268 if persisted.expires_at > creds.expires_at {
271 creds.access_token = persisted.access_token;
272 creds.refresh_token = persisted.refresh_token;
273 creds.expires_at = persisted.expires_at;
274 }
275 Ok(())
276}
277
278fn write_persisted_oauth(cache: &Cache, persisted: &PersistedOAuth) -> Result<()> {
279 let path = oauth_cache_path(cache);
280 let bytes = serde_json::to_vec_pretty(persisted)?;
281 atomic_write(&path, &bytes)?;
282 #[cfg(unix)]
283 {
284 use std::os::unix::fs::PermissionsExt;
285 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
286 .map_err(|e| AppError::io_at(&path, e))?;
287 }
288 Ok(())
289}
290
291async fn fetch_live(
292 client: &reqwest::Client,
293 endpoints: &Endpoints,
294 cache: &Cache,
295 creds: &KiroCredentials,
296 now: DateTime<Utc>,
297) -> Result<KiroSnapshot> {
298 let access_token = if oauth::needs_refresh(creds.expires_at.timestamp(), now.timestamp()) {
299 let refreshed = tokio::time::timeout(
300 REFRESH_TIMEOUT,
301 oauth::refresh(
302 client,
303 &endpoints.token,
304 &creds.client_id,
305 &creds.client_secret,
306 &creds.refresh_token,
307 ),
308 )
309 .await
310 .map_err(|_| {
311 AppError::Transport(format!("kiro token refresh timeout: {}", endpoints.token))
312 })?
313 .map_err(|e| {
314 AppError::Credentials(format!(
315 "Kiro CLI token refresh failed ({e}). Run `kiro-cli login` again."
316 ))
317 })?;
318 let expires_in = i64::try_from(refreshed.expires_in)
319 .map_err(|_| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
320 let expires_at_secs = now
321 .timestamp()
322 .checked_add(expires_in)
323 .ok_or_else(|| AppError::Schema("kiro token refresh expiry overflowed".into()))?;
324 let expires_at = DateTime::from_timestamp(expires_at_secs, 0)
325 .ok_or_else(|| AppError::Schema("kiro token refresh expiry is out of range".into()))?;
326 let persisted = PersistedOAuth {
327 account: creds.account_key.clone(),
328 access_token: refreshed.access_token,
329 refresh_token: refreshed
330 .refresh_token
331 .unwrap_or_else(|| creds.refresh_token.clone()),
332 expires_at,
333 };
334 write_persisted_oauth(cache, &persisted).map_err(|e| {
335 AppError::Credentials(format!(
336 "refreshed Kiro CLI credentials could not be saved ({e}); run `kiro-cli login` again if the refresh token was rotated"
337 ))
338 })?;
339 persisted.access_token
340 } else {
341 creds.access_token.clone()
342 };
343
344 let body = serde_json::json!({
345 "origin": "AI_EDITOR",
346 "profileArn": creds.profile_arn,
347 "resourceType": "AGENTIC_REQUEST",
348 });
349
350 let resp = tokio::time::timeout(
351 HTTP_TIMEOUT,
352 client
353 .post(&endpoints.usage_limits)
354 .header("Content-Type", "application/x-amz-json-1.0")
355 .header("x-amz-target", REQUEST_TARGET)
356 .header("Authorization", format!("Bearer {access_token}"))
357 .json(&body)
358 .send(),
359 )
360 .await
361 .map_err(|_| AppError::Transport(format!("kiro timeout: {}", endpoints.usage_limits)))??;
362
363 let status = resp.status();
364 if !status.is_success() {
365 let body = if matches!(status.as_u16(), 401 | 403) {
366 "Kiro CLI authentication failed".into()
367 } else {
368 format!("Kiro CLI API returned HTTP {}", status.as_u16())
369 };
370 return Err(AppError::Http {
371 status: status.as_u16(),
372 body,
373 });
374 }
375
376 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
377 let parsed: UsageLimitsResponse = serde_json::from_slice(&bytes)
378 .map_err(|e| AppError::Schema(format!("kiro usage-limits response: {e}")))?;
379 types::to_snapshot(parsed)
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use rusqlite::Connection;
386 use tempfile::TempDir;
387
388 fn cache_fixture() -> (TempDir, Cache) {
389 let td = TempDir::new().unwrap();
390 let cache = Cache::at(td.path().join("kiro"));
391 cache.ensure_dir().unwrap();
392 (td, cache)
393 }
394
395 fn seed_db(dir: &TempDir, expires_at: &str) -> std::path::PathBuf {
396 let path = dir.path().join("data.sqlite3");
397 let conn = Connection::open(&path).unwrap();
398 conn.execute("CREATE TABLE auth_kv (key TEXT, value TEXT)", [])
399 .unwrap();
400 conn.execute("CREATE TABLE state (key TEXT, value TEXT)", [])
401 .unwrap();
402 let token = serde_json::json!({
403 "access_token": "AT", "refresh_token": "RT",
404 "expires_at": expires_at, "region": "us-east-1",
405 })
406 .to_string();
407 let device =
408 serde_json::json!({"client_id": "CID", "client_secret": "CSECRET"}).to_string();
409 let profile =
410 serde_json::json!({"arn": "arn:aws:codewhisperer:us-east-1:1:profile/A"}).to_string();
411 conn.execute(
412 "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:token', ?1)",
413 [&token],
414 )
415 .unwrap();
416 conn.execute(
417 "INSERT INTO auth_kv (key, value) VALUES ('kirocli:odic:device-registration', ?1)",
418 [&device],
419 )
420 .unwrap();
421 conn.execute(
422 "INSERT INTO state (key, value) VALUES ('api.codewhisperer.profile', ?1)",
423 [&profile],
424 )
425 .unwrap();
426 path
427 }
428
429 fn account_key(dir: &TempDir) -> String {
430 let path = dir.path().join("data.sqlite3");
431 db::read_credentials(&path).unwrap().account_key
432 }
433
434 fn usage_json() -> String {
435 r#"{
436 "nextDateReset": 4102444800.0,
437 "subscriptionInfo": { "subscriptionTitle": "KIRO POWER" },
438 "usageBreakdownList": [{
439 "resourceType": "CREDIT",
440 "currentUsageWithPrecision": 40.0,
441 "usageLimitWithPrecision": 100.0
442 }]
443 }"#
444 .to_string()
445 }
446
447 #[tokio::test]
448 async fn live_fetch_reads_token_from_db_and_calls_get_usage_limits() {
449 let mut server = mockito::Server::new_async().await;
450 let m = server
451 .mock("POST", "/")
452 .match_header("x-amz-target", "AmazonCodeWhispererService.GetUsageLimits")
453 .match_header("authorization", "Bearer AT")
454 .with_status(200)
455 .with_body(usage_json())
456 .create_async()
457 .await;
458
459 let db_dir = TempDir::new().unwrap();
460 let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
462 let client = reqwest::Client::new();
463 let endpoints_url = server.url();
464
465 let creds = db::read_credentials(&db_path).unwrap();
466 let (_cache_dir, cache) = cache_fixture();
467 let out = fetch_live(
468 &client,
469 &Endpoints {
470 usage_limits: endpoints_url,
471 token: "unused".into(),
472 },
473 &cache,
474 &creds,
475 Utc::now(),
476 )
477 .await
478 .unwrap();
479
480 assert_eq!(out.plan, "KIRO POWER");
481 assert_eq!(out.used, 40.0);
482 assert_eq!(out.limit, 100.0);
483 m.assert_async().await;
484 }
485
486 #[tokio::test]
487 async fn expired_token_is_refreshed_before_the_usage_call() {
488 let mut server = mockito::Server::new_async().await;
489 let token_mock = server
490 .mock("POST", "/token")
491 .with_status(200)
492 .with_body(r#"{"accessToken":"NEW-AT","expiresIn":3600}"#)
493 .create_async()
494 .await;
495 let usage_mock = server
496 .mock("POST", "/")
497 .match_header("authorization", "Bearer NEW-AT")
498 .with_status(200)
499 .with_body(usage_json())
500 .create_async()
501 .await;
502
503 let db_dir = TempDir::new().unwrap();
504 let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
506 let creds = db::read_credentials(&db_path).unwrap();
507 let (_cache_dir, cache) = cache_fixture();
508 let client = reqwest::Client::new();
509
510 let out = fetch_live(
511 &client,
512 &Endpoints {
513 usage_limits: server.url(),
514 token: format!("{}/token", server.url()),
515 },
516 &cache,
517 &creds,
518 Utc::now(),
519 )
520 .await
521 .unwrap();
522
523 assert_eq!(out.used, 40.0);
524 token_mock.assert_async().await;
525 usage_mock.assert_async().await;
526 }
527
528 #[tokio::test]
529 async fn refreshed_credentials_are_reused_and_rotated_token_is_retained() {
530 let mut server = mockito::Server::new_async().await;
531 let token_mock = server
532 .mock("POST", "/token")
533 .match_body(mockito::Matcher::Json(serde_json::json!({
534 "clientId": "CID",
535 "clientSecret": "CSECRET",
536 "grantType": "refresh_token",
537 "refreshToken": "RT",
538 })))
539 .with_status(200)
540 .with_body(r#"{"accessToken":"NEW-AT","refreshToken":"ROTATED-RT","expiresIn":3600}"#)
541 .expect(1)
542 .create_async()
543 .await;
544 let usage_mock = server
545 .mock("POST", "/")
546 .match_header("authorization", "Bearer NEW-AT")
547 .with_status(200)
548 .with_body(usage_json())
549 .expect(2)
550 .create_async()
551 .await;
552
553 let db_dir = TempDir::new().unwrap();
554 let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
555 let (_cache_dir, cache) = cache_fixture();
556 let endpoints = Endpoints {
557 usage_limits: server.url(),
558 token: format!("{}/token", server.url()),
559 };
560 let now = DateTime::parse_from_rfc3339("2026-08-03T12:00:00Z")
561 .unwrap()
562 .with_timezone(&Utc);
563
564 for _ in 0..2 {
565 let out = fetch_snapshot_at(
566 &reqwest::Client::new(),
567 &db_path,
568 &cache,
569 Duration::ZERO,
570 Some(&endpoints),
571 now,
572 )
573 .await
574 .unwrap();
575 assert_eq!(out.snapshot.used, 40.0);
576 }
577
578 let persisted: serde_json::Value =
579 serde_json::from_slice(&std::fs::read(oauth_cache_path(&cache)).unwrap()).unwrap();
580 assert_eq!(persisted["access_token"], "NEW-AT");
581 assert_eq!(persisted["refresh_token"], "ROTATED-RT");
582 assert_eq!(persisted["account"], account_key(&db_dir));
583 #[cfg(unix)]
584 {
585 use std::os::unix::fs::PermissionsExt;
586 let mode = std::fs::metadata(oauth_cache_path(&cache))
587 .unwrap()
588 .permissions()
589 .mode();
590 assert_eq!(mode & 0o077, 0);
591 }
592
593 token_mock.assert_async().await;
594 usage_mock.assert_async().await;
595 }
596
597 #[test]
598 fn persisted_credentials_from_another_account_are_ignored() {
599 let db_dir = TempDir::new().unwrap();
600 let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
601 let mut creds = db::read_credentials(&db_path).unwrap();
602 let (_cache_dir, cache) = cache_fixture();
603 write_persisted_oauth(
604 &cache,
605 &PersistedOAuth {
606 account: "another-account".into(),
607 access_token: "OTHER-AT".into(),
608 refresh_token: "OTHER-RT".into(),
609 expires_at: DateTime::parse_from_rfc3339("2100-01-01T00:00:00Z")
610 .unwrap()
611 .with_timezone(&Utc),
612 },
613 )
614 .unwrap();
615
616 apply_persisted_oauth(&cache, &mut creds).unwrap();
617
618 assert_eq!(creds.access_token, "AT");
619 assert_eq!(creds.refresh_token, "RT");
620 }
621
622 #[tokio::test]
623 async fn refresh_failure_is_a_credentials_error() {
624 let mut server = mockito::Server::new_async().await;
625 server
626 .mock("POST", "/token")
627 .with_status(400)
628 .with_body(r#"{"error":"invalid_grant"}"#)
629 .create_async()
630 .await;
631
632 let db_dir = TempDir::new().unwrap();
633 let db_path = seed_db(&db_dir, "2000-01-01T00:00:00Z");
634 let creds = db::read_credentials(&db_path).unwrap();
635 let (_cache_dir, cache) = cache_fixture();
636 let client = reqwest::Client::new();
637
638 let err = fetch_live(
639 &client,
640 &Endpoints {
641 usage_limits: server.url(),
642 token: format!("{}/token", server.url()),
643 },
644 &cache,
645 &creds,
646 Utc::now(),
647 )
648 .await
649 .unwrap_err();
650 assert!(matches!(err, AppError::Credentials(_)));
651 }
652
653 #[tokio::test]
654 async fn a_stale_cache_falls_back_when_the_live_call_fails() {
655 let mut server = mockito::Server::new_async().await;
656 server
657 .mock("POST", "/")
658 .with_status(500)
659 .with_body("boom")
660 .create_async()
661 .await;
662
663 let db_dir = TempDir::new().unwrap();
664 let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
665 let (_cache_dir, cache) = cache_fixture();
666 let account = account_key(&db_dir);
667 cache
668 .write_payload(
669 serde_json::to_vec(&serde_json::json!({
670 "account": account,
671 "plan": "KIRO POWER",
672 "used": 10.0,
673 "limit": 100.0,
674 "reset_at": null,
675 }))
676 .unwrap()
677 .as_slice(),
678 )
679 .unwrap();
680 std::thread::sleep(std::time::Duration::from_millis(5));
682
683 let client = reqwest::Client::new();
684 let out = fetch_snapshot_at(
685 &client,
686 &db_path,
687 &cache,
688 Duration::from_millis(1),
689 Some(&Endpoints {
690 usage_limits: server.url(),
691 token: format!("{}/token", server.url()),
692 }),
693 Utc::now(),
694 )
695 .await
696 .unwrap();
697
698 assert!(out.stale);
699 assert_eq!(out.snapshot.used, 10.0);
700 assert!(out.last_error.is_some());
701 }
702
703 #[tokio::test]
704 async fn fresh_cache_is_served_without_a_network_call() {
705 let db_dir = TempDir::new().unwrap();
706 let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
707 let (_cache_dir, cache) = cache_fixture();
708 let account = account_key(&db_dir);
709 cache
710 .write_payload(
711 serde_json::to_vec(&serde_json::json!({
712 "account": account,
713 "plan": "KIRO POWER",
714 "used": 10.0,
715 "limit": 100.0,
716 "reset_at": null,
717 }))
718 .unwrap()
719 .as_slice(),
720 )
721 .unwrap();
722
723 let client = reqwest::Client::new();
724 let out = fetch_snapshot_at(
729 &client,
730 &db_path,
731 &cache,
732 Duration::from_secs(60),
733 Some(&Endpoints {
734 usage_limits: "http://127.0.0.1:1".into(),
735 token: "http://127.0.0.1:1".into(),
736 }),
737 Utc::now(),
738 )
739 .await
740 .unwrap();
741
742 assert!(!out.stale);
743 assert_eq!(out.snapshot.used, 10.0);
744 }
745
746 #[tokio::test]
747 async fn cache_from_a_different_account_is_not_reused() {
748 let mut server = mockito::Server::new_async().await;
749 let m = server
750 .mock("POST", "/")
751 .with_status(200)
752 .with_body(usage_json())
753 .create_async()
754 .await;
755
756 let db_dir = TempDir::new().unwrap();
757 let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
758 let (_cache_dir, cache) = cache_fixture();
759 cache
760 .write_payload(
761 serde_json::to_vec(&serde_json::json!({
762 "account": "some-other-account",
763 "plan": "STALE PLAN",
764 "used": 10.0,
765 "limit": 100.0,
766 "reset_at": null,
767 }))
768 .unwrap()
769 .as_slice(),
770 )
771 .unwrap();
772
773 let client = reqwest::Client::new();
774 let out = fetch_snapshot_at(
775 &client,
776 &db_path,
777 &cache,
778 Duration::from_secs(60),
779 Some(&Endpoints {
780 usage_limits: server.url(),
781 token: format!("{}/token", server.url()),
782 }),
783 Utc::now(),
784 )
785 .await
786 .unwrap();
787
788 assert_eq!(out.snapshot.used, 40.0);
791 assert_eq!(out.snapshot.plan, "KIRO POWER");
792 assert!(!out.stale);
793 m.assert_async().await;
794 }
795
796 #[tokio::test]
801 async fn cache_past_its_credit_reset_is_not_served_during_an_outage() {
802 let db_dir = TempDir::new().unwrap();
803 let db_path = seed_db(&db_dir, "2099-01-01T00:00:00Z");
804 let (_cache_dir, cache) = cache_fixture();
805 let account = account_key(&db_dir);
806 cache
807 .write_payload(
808 serde_json::to_vec(&serde_json::json!({
809 "account": account,
810 "plan": "KIRO POWER",
811 "used": 10.0,
812 "limit": 100.0,
813 "reset_at": "2026-08-04T00:00:00Z",
814 }))
815 .unwrap()
816 .as_slice(),
817 )
818 .unwrap();
819
820 let mut server = mockito::Server::new_async().await;
821 server
822 .mock("POST", "/")
823 .with_status(503)
824 .create_async()
825 .await;
826 let now = DateTime::parse_from_rfc3339("2026-08-05T00:00:00Z")
827 .unwrap()
828 .with_timezone(&Utc);
829 let err = fetch_snapshot_at(
830 &reqwest::Client::new(),
831 &db_path,
832 &cache,
833 Duration::from_secs(0),
834 Some(&Endpoints {
835 usage_limits: server.url(),
836 token: format!("{}/token", server.url()),
837 }),
838 now,
839 )
840 .await
841 .unwrap_err();
842 assert!(matches!(err, AppError::Http { status: 503, .. }));
843 }
844}