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