1use std::time::Duration;
10
11use chrono::{DateTime, Datelike, Utc};
12
13use crate::cache::{Cache, acquire_lock_async};
14use crate::error::{AppError, Result};
15use crate::usage::{AnthropicApiSnapshot, finite_amount};
16use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
17
18use super::types::{CostReport, page_covered_through, page_dollars};
19
20pub const BASE_URL: &str = "https://api.anthropic.com";
21pub const ANTHROPIC_VERSION: &str = "2023-06-01";
22const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
23const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
24const MAX_PAGES: usize = 12;
27
28#[derive(Debug, Clone)]
29pub struct Endpoints {
30 pub cost_report: String,
31}
32
33impl Default for Endpoints {
34 fn default() -> Self {
35 Self {
36 cost_report: format!("{BASE_URL}/v1/organizations/cost_report"),
37 }
38 }
39}
40
41pub type FetchOutcome = crate::outcome::Outcome<AnthropicApiSnapshot>;
44
45fn month_start_rfc3339(now: DateTime<Utc>) -> String {
47 format!("{:04}-{:02}-01T00:00:00Z", now.year(), now.month())
48}
49
50fn target_key(admin_key: &str) -> String {
56 use std::hash::{Hash, Hasher};
57 let mut hasher = std::collections::hash_map::DefaultHasher::new();
58 admin_key.hash(&mut hasher);
59 format!("key:{:016x}", hasher.finish())
60}
61
62fn validate_limit(limit: Option<f64>) -> Result<Option<f64>> {
63 if let Some(value) = limit
64 && (!value.is_finite() || value <= 0.0)
65 {
66 return Err(AppError::Schema(
67 "anthropic-api monthly_limit must be finite and greater than zero; \
68 remove it to show spend without a limit"
69 .into(),
70 ));
71 }
72 Ok(limit)
73}
74
75pub async fn fetch_snapshot(
78 client: &reqwest::Client,
79 admin_key: &str,
80 cache: &Cache,
81 endpoints: &Endpoints,
82 cache_ttl: Duration,
83 limit: Option<f64>,
84) -> Result<FetchOutcome> {
85 fetch_snapshot_at(
86 client,
87 admin_key,
88 cache,
89 endpoints,
90 cache_ttl,
91 limit,
92 Utc::now(),
93 )
94 .await
95}
96
97pub async fn fetch_snapshot_at(
100 client: &reqwest::Client,
101 admin_key: &str,
102 cache: &Cache,
103 endpoints: &Endpoints,
104 cache_ttl: Duration,
105 limit: Option<f64>,
106 now: DateTime<Utc>,
107) -> Result<FetchOutcome> {
108 let limit = validate_limit(limit)?;
109 cache.ensure_dir()?;
110 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
111
112 let month = month_start_rfc3339(now);
115 let target = target_key(admin_key);
116
117 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
118 && let Ok(outcome) = reuse_cache(&bytes, cache, false, limit, &month, &target)
119 {
120 return Ok(outcome);
121 }
122 if let Some(remaining) = cache.backoff_remaining() {
128 return Err(AppError::Http {
129 status: 429,
130 body: format!(
131 "rate limited; next attempt in {}",
132 crate::cache::human_backoff(remaining)
133 ),
134 });
135 }
136
137 match fetch_live(client, endpoints, admin_key, now).await {
138 Ok((spent, covered_through)) => {
139 let snap = AnthropicApiSnapshot {
140 spent,
141 limit,
142 covered_through,
143 };
144 let bytes = serde_json::to_vec(&serde_json::json!({
145 "month": month,
146 "target": target,
147 "snapshot": {
148 "spent": snap.spent,
149 "limit": snap.limit,
150 "covered_through": snap.covered_through,
151 },
152 }))?;
153 cache.write_payload(&bytes)?;
154 Ok(crate::outcome::Outcome::fresh(snap))
155 }
156 Err(e) if e.is_transient() => fallback_silent(cache, limit, &month, &target, e),
157 Err(AppError::Http { status, body }) => {
158 cache.mark_stale();
159 let diag = cache.write_last_error(status, &body);
160 fallback_with_error(
161 cache,
162 Some(diag),
163 limit,
164 &month,
165 &target,
166 AppError::Http { status, body },
167 )
168 }
169 Err(e) => {
170 cache.mark_stale();
171 let diag = cache.write_last_error(0, &e.to_string());
172 fallback_with_error(cache, Some(diag), limit, &month, &target, e)
173 }
174 }
175}
176
177fn fallback_silent(
178 cache: &Cache,
179 limit: Option<f64>,
180 month: &str,
181 target: &str,
182 original: AppError,
183) -> Result<FetchOutcome> {
184 crate::outcome::fallback(cache, None, original, |bytes| {
185 cached_snapshot(bytes, limit, month, target)
186 })
187}
188
189fn fallback_with_error(
197 cache: &Cache,
198 last_error: Option<(u16, String)>,
199 limit: Option<f64>,
200 month: &str,
201 target: &str,
202 original: AppError,
203) -> Result<FetchOutcome> {
204 crate::outcome::fallback(cache, last_error, original, |bytes| {
205 cached_snapshot(bytes, limit, month, target)
206 })
207}
208
209fn reuse_cache(
210 bytes: &[u8],
211 cache: &Cache,
212 stale: bool,
213 limit: Option<f64>,
214 month: &str,
215 target: &str,
216) -> Result<FetchOutcome> {
217 let snapshot = cached_snapshot(bytes, limit, month, target)?;
218 Ok(crate::outcome::Outcome::cached(snapshot, cache, stale))
219}
220
221fn cached_snapshot(
224 bytes: &[u8],
225 limit: Option<f64>,
226 month: &str,
227 target: &str,
228) -> Result<AnthropicApiSnapshot> {
229 let (spent, covered_through) = parse_cached_spend(bytes, month, target)?;
230 Ok(AnthropicApiSnapshot {
231 spent,
232 limit,
233 covered_through,
234 })
235}
236
237fn parse_cached_spend(
238 bytes: &[u8],
239 month: &str,
240 target: &str,
241) -> Result<(f64, Option<chrono::NaiveDate>)> {
242 let v: serde_json::Value = serde_json::from_slice(bytes)?;
243 let cached_month = v.get("month").and_then(serde_json::Value::as_str);
246 if cached_month != Some(month) {
247 return Err(AppError::Schema(format!(
248 "anthropic-api cache is for a different month ({}); refetching",
249 cached_month.unwrap_or("unknown")
250 )));
251 }
252 let cached_target = v.get("target").and_then(serde_json::Value::as_str);
253 if cached_target != Some(target) {
254 return Err(AppError::Schema(
255 "anthropic-api cache belongs to a different Admin key; refetching".into(),
256 ));
257 }
258 let s = v
259 .get("snapshot")
260 .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'snapshot'".into()))?;
261 let spent = s["spent"]
262 .as_f64()
263 .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'spent'".into()))?;
264 let spent = crate::usage::finite_amount("anthropic-api cache", "spent", spent)?;
265 let covered_through = s
266 .get("covered_through")
267 .and_then(serde_json::Value::as_str)
268 .map(|raw| {
269 chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|e| {
270 AppError::Schema(format!(
271 "anthropic-api cache has invalid 'covered_through' {raw:?}: {e}"
272 ))
273 })
274 })
275 .transpose()?;
276 Ok((spent, covered_through))
277}
278
279async fn fetch_live(
280 client: &reqwest::Client,
281 endpoints: &Endpoints,
282 admin_key: &str,
283 now: DateTime<Utc>,
284) -> Result<(f64, Option<chrono::NaiveDate>)> {
285 let starting_at = month_start_rfc3339(now);
286 let mut total = 0.0;
287 let mut page: Option<String> = None;
288 let mut seen_pages: Vec<String> = Vec::new();
289 let mut covered_through = None;
290
291 for _ in 0..MAX_PAGES {
292 let mut req = client
293 .get(&endpoints.cost_report)
294 .header("x-api-key", admin_key)
295 .header("anthropic-version", ANTHROPIC_VERSION)
296 .query(&[
297 ("starting_at", starting_at.as_str()),
298 ("bucket_width", "1d"),
299 ]);
300 if let Some(p) = &page {
301 req = req.query(&[("page", p.as_str())]);
302 }
303
304 let resp = tokio::time::timeout(HTTP_TIMEOUT, req.send())
305 .await
306 .map_err(|_| {
307 AppError::Transport(format!("anthropic-api timeout: {}", endpoints.cost_report))
308 })??;
309
310 let status = resp.status();
311 let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
312 if !status.is_success() {
313 let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
314 return Err(AppError::Http {
315 status: status.as_u16(),
316 body,
317 });
318 }
319
320 let report: CostReport = serde_json::from_slice(&bytes)
321 .map_err(|e| AppError::Schema(format!("anthropic-api cost_report: {e}")))?;
322 total = finite_amount(
323 "anthropic-api",
324 "cost_report running total",
325 total + page_dollars(&report)?,
326 )?;
327 covered_through = covered_through.max(page_covered_through(&report)?);
328
329 match (report.has_more, report.next_page) {
333 (false, _) => return Ok((total, covered_through)),
334 (true, None) => {
335 return Err(AppError::Schema(
336 "anthropic-api cost_report: has_more is true but next_page is missing; \
337 refusing to report a partial month"
338 .into(),
339 ));
340 }
341 (true, Some(p)) if p.trim().is_empty() => {
342 return Err(AppError::Schema(
343 "anthropic-api cost_report: has_more is true but next_page is empty; \
344 refusing to report a partial month"
345 .into(),
346 ));
347 }
348 (true, Some(p)) => {
349 if seen_pages.contains(&p) {
350 return Err(AppError::Schema(format!(
351 "anthropic-api cost_report: pagination repeated cursor {p:?}; \
352 refusing to report a partial month"
353 )));
354 }
355 seen_pages.push(p.clone());
356 page = Some(p);
357 }
358 }
359 }
360 Err(AppError::Schema(format!(
361 "anthropic-api cost_report: more than {MAX_PAGES} pages for one month; \
362 refusing to report a partial month"
363 )))
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use chrono::TimeZone;
370 use tempfile::TempDir;
371
372 fn cache_fixture() -> (TempDir, Cache) {
373 let td = TempDir::new().unwrap();
374 let cache = Cache::at(td.path().join("anthropic_api"));
375 cache.ensure_dir().unwrap();
376 (td, cache)
377 }
378
379 #[test]
380 fn month_start_is_first_of_month_utc() {
381 let now = Utc.with_ymd_and_hms(2026, 7, 19, 15, 8, 0).unwrap();
382 assert_eq!(month_start_rfc3339(now), "2026-07-01T00:00:00Z");
383 }
384
385 #[tokio::test]
386 async fn live_fetch_sums_month_to_date_and_divides_by_100() {
387 let mut server = mockito::Server::new_async().await;
388 server
389 .mock("GET", "/v1/organizations/cost_report")
390 .match_header("x-api-key", "sk-ant-admin01-test")
391 .match_query(mockito::Matcher::Any)
392 .with_status(200)
393 .with_body(
394 r#"{"data":[{"ending_at":"2026-07-19T00:00:00Z",
395 "results":[{"amount":"100.0","currency":"USD"},
396 {"amount":"34.0","currency":"USD"}]}],
397 "has_more":false,"next_page":null}"#,
398 )
399 .create_async()
400 .await;
401
402 let (_td, cache) = cache_fixture();
403 let client = reqwest::Client::new();
404 let endpoints = Endpoints {
405 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
406 };
407 let out = fetch_snapshot(
408 &client,
409 "sk-ant-admin01-test",
410 &cache,
411 &endpoints,
412 Duration::from_secs(0),
413 Some(1000.0),
414 )
415 .await
416 .unwrap();
417 assert!((out.snapshot.spent - 1.34).abs() < 1e-9);
419 assert_eq!(out.snapshot.limit, Some(1000.0));
420 assert_eq!(
421 out.snapshot.covered_through,
422 Some(chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap())
423 );
424 assert!(!out.stale);
425 }
426
427 #[tokio::test]
428 async fn http_401_falls_back_to_cache_when_present() {
429 let mut server = mockito::Server::new_async().await;
430 server
431 .mock("GET", "/v1/organizations/cost_report")
432 .match_query(mockito::Matcher::Any)
433 .with_status(401)
434 .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
435 .create_async()
436 .await;
437
438 let (_td, cache) = cache_fixture();
439 let now = at(2026, 7, 19);
440 cache
441 .write_payload(
442 serde_json::json!({
443 "month": month_start_rfc3339(now),
444 "target": target_key("k"),
445 "snapshot": { "spent": 2.5, "limit": null },
446 })
447 .to_string()
448 .as_bytes(),
449 )
450 .unwrap();
451
452 let client = reqwest::Client::new();
453 let endpoints = Endpoints {
454 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
455 };
456 let out = fetch_snapshot_at(
457 &client,
458 "k",
459 &cache,
460 &endpoints,
461 Duration::from_secs(0),
462 Some(50.0),
463 now,
464 )
465 .await
466 .unwrap();
467 assert!(out.stale);
468 assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
469 assert_eq!(out.snapshot.limit, Some(50.0));
471 assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
472 }
473
474 fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
476 chrono::NaiveDate::from_ymd_opt(y, m, d)
477 .unwrap()
478 .and_hms_opt(12, 0, 0)
479 .unwrap()
480 .and_utc()
481 }
482
483 fn ok_body(cents: &str) -> String {
484 format!(
485 r#"{{"data":[{{"results":[{{"amount":"{cents}","currency":"USD"}}]}}],"has_more":false}}"#
486 )
487 }
488
489 #[tokio::test]
490 async fn month_rollover_refetches_instead_of_showing_last_month() {
491 let mut server = mockito::Server::new_async().await;
494 server
495 .mock("GET", "/v1/organizations/cost_report")
496 .match_query(mockito::Matcher::Any)
497 .with_status(200)
498 .with_body(ok_body("250.0"))
499 .create_async()
500 .await;
501
502 let (_td, cache) = cache_fixture();
503 cache
504 .write_payload(
505 serde_json::json!({
506 "month": month_start_rfc3339(at(2026, 6, 30)),
507 "target": target_key("k"),
508 "snapshot": { "spent": 987.0, "limit": null },
509 })
510 .to_string()
511 .as_bytes(),
512 )
513 .unwrap();
514
515 let client = reqwest::Client::new();
516 let endpoints = Endpoints {
517 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
518 };
519 let out = fetch_snapshot_at(
521 &client,
522 "k",
523 &cache,
524 &endpoints,
525 Duration::from_secs(3600),
526 None,
527 at(2026, 7, 1),
528 )
529 .await
530 .unwrap();
531 assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
532 assert!(!out.stale);
533 }
534
535 #[tokio::test]
536 async fn last_months_cache_is_not_served_during_an_outage() {
537 let mut server = mockito::Server::new_async().await;
540 server
541 .mock("GET", "/v1/organizations/cost_report")
542 .match_query(mockito::Matcher::Any)
543 .with_status(500)
544 .with_body("upstream boom")
545 .create_async()
546 .await;
547
548 let (_td, cache) = cache_fixture();
549 cache
550 .write_payload(
551 serde_json::json!({
552 "month": month_start_rfc3339(at(2026, 6, 30)),
553 "target": target_key("k"),
554 "snapshot": { "spent": 987.0, "limit": null },
555 })
556 .to_string()
557 .as_bytes(),
558 )
559 .unwrap();
560
561 let client = reqwest::Client::new();
562 let endpoints = Endpoints {
563 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
564 };
565 let out = fetch_snapshot_at(
566 &client,
567 "k",
568 &cache,
569 &endpoints,
570 Duration::from_secs(0),
571 None,
572 at(2026, 7, 1),
573 )
574 .await;
575 assert!(out.is_err(), "expected an error, got {out:?}");
576 }
577
578 #[tokio::test]
579 async fn an_active_backoff_is_not_bypassed_by_a_month_rollover() {
580 let mut server = mockito::Server::new_async().await;
586 server
589 .mock("GET", "/v1/organizations/cost_report")
590 .match_query(mockito::Matcher::Any)
591 .with_status(500)
592 .with_body("must not be contacted during backoff")
593 .create_async()
594 .await;
595
596 let (_td, cache) = cache_fixture();
597 cache
598 .write_payload(
599 serde_json::json!({
600 "month": month_start_rfc3339(at(2026, 6, 30)),
601 "target": target_key("k"),
602 "snapshot": { "spent": 2.5, "limit": null },
603 })
604 .to_string()
605 .as_bytes(),
606 )
607 .unwrap();
608 cache.note_rate_limit_at(std::time::SystemTime::now());
612
613 let client = reqwest::Client::new();
614 let endpoints = Endpoints {
615 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
616 };
617 let err = fetch_snapshot_at(
618 &client,
619 "k",
620 &cache,
621 &endpoints,
622 Duration::from_secs(600),
623 None,
624 at(2026, 7, 1),
625 )
626 .await
627 .unwrap_err();
628 match &err {
629 AppError::Http { status, body } => {
630 assert_eq!(status, &429, "body: {body}");
631 assert!(body.contains("rate limited"), "body: {body}");
632 }
633 other => panic!("expected the shared backoff 429, got {other:?}"),
634 }
635 }
636
637 #[tokio::test]
638 async fn first_run_auth_failure_preserves_the_original_error() {
639 let mut server = mockito::Server::new_async().await;
642 server
643 .mock("GET", "/v1/organizations/cost_report")
644 .match_query(mockito::Matcher::Any)
645 .with_status(401)
646 .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
647 .create_async()
648 .await;
649
650 let (_td, cache) = cache_fixture();
651 let client = reqwest::Client::new();
652 let endpoints = Endpoints {
653 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
654 };
655 let err = fetch_snapshot_at(
656 &client,
657 "k",
658 &cache,
659 &endpoints,
660 Duration::from_secs(0),
661 None,
662 at(2026, 7, 19),
663 )
664 .await
665 .unwrap_err();
666 assert!(
667 matches!(err, AppError::Http { status: 401, .. }),
668 "original error must survive, got {err:?}"
669 );
670 assert!(err.to_string().contains("invalid x-api-key"));
671 }
672
673 #[tokio::test]
674 async fn has_more_without_next_page_is_an_error_not_a_partial_month() {
675 let mut server = mockito::Server::new_async().await;
676 server
677 .mock("GET", "/v1/organizations/cost_report")
678 .match_query(mockito::Matcher::Any)
679 .with_status(200)
680 .with_body(
681 r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
682 "has_more":true}"#,
683 )
684 .create_async()
685 .await;
686
687 let (_td, cache) = cache_fixture();
688 let client = reqwest::Client::new();
689 let endpoints = Endpoints {
690 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
691 };
692 let out = fetch_snapshot_at(
693 &client,
694 "k",
695 &cache,
696 &endpoints,
697 Duration::from_secs(0),
698 None,
699 at(2026, 7, 19),
700 )
701 .await;
702 assert!(out.is_err(), "partial month must not be reported: {out:?}");
703 }
704
705 #[tokio::test]
706 async fn repeated_pagination_cursor_is_an_error() {
707 let mut server = mockito::Server::new_async().await;
710 server
711 .mock("GET", "/v1/organizations/cost_report")
712 .match_query(mockito::Matcher::Any)
713 .with_status(200)
714 .with_body(
715 r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
716 "has_more":true,"next_page":"same"}"#,
717 )
718 .expect_at_least(1)
719 .create_async()
720 .await;
721
722 let (_td, cache) = cache_fixture();
723 let client = reqwest::Client::new();
724 let endpoints = Endpoints {
725 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
726 };
727 let out = fetch_snapshot_at(
728 &client,
729 "k",
730 &cache,
731 &endpoints,
732 Duration::from_secs(0),
733 None,
734 at(2026, 7, 19),
735 )
736 .await;
737 assert!(out.is_err(), "cursor loop must not be reported: {out:?}");
738 }
739
740 #[tokio::test]
741 async fn malformed_200_is_not_cached_as_zero_spend() {
742 let mut server = mockito::Server::new_async().await;
743 server
744 .mock("GET", "/v1/organizations/cost_report")
745 .match_query(mockito::Matcher::Any)
746 .with_status(200)
747 .with_body(r#"{"error":{"message":"permission_error"}}"#)
748 .create_async()
749 .await;
750
751 let (_td, cache) = cache_fixture();
752 let client = reqwest::Client::new();
753 let endpoints = Endpoints {
754 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
755 };
756 let out = fetch_snapshot_at(
757 &client,
758 "k",
759 &cache,
760 &endpoints,
761 Duration::from_secs(0),
762 None,
763 at(2026, 7, 19),
764 )
765 .await;
766 assert!(out.is_err(), "expected a schema error, got {out:?}");
767 assert!(cache.maybe_payload().unwrap().is_none());
769 }
770
771 #[tokio::test]
772 async fn a_month_with_no_spend_is_cached_as_a_real_zero() {
773 let mut server = mockito::Server::new_async().await;
775 server
776 .mock("GET", "/v1/organizations/cost_report")
777 .match_query(mockito::Matcher::Any)
778 .with_status(200)
779 .with_body(r#"{"data":[],"has_more":false,"next_page":null}"#)
780 .create_async()
781 .await;
782
783 let (_td, cache) = cache_fixture();
784 let client = reqwest::Client::new();
785 let endpoints = Endpoints {
786 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
787 };
788 let out = fetch_snapshot_at(
789 &client,
790 "k",
791 &cache,
792 &endpoints,
793 Duration::from_secs(0),
794 None,
795 at(2026, 7, 19),
796 )
797 .await
798 .unwrap();
799 assert_eq!(out.snapshot.spent, 0.0);
800 assert!(!out.stale);
801 assert!(cache.maybe_payload().unwrap().is_some());
802 }
803
804 #[tokio::test]
805 async fn switching_admin_key_refetches_instead_of_reusing_another_organization() {
806 let mut server = mockito::Server::new_async().await;
807 let first = server
808 .mock("GET", "/v1/organizations/cost_report")
809 .match_header("x-api-key", "org-a-key")
810 .match_query(mockito::Matcher::Any)
811 .with_status(200)
812 .with_body(ok_body("100.0"))
813 .expect(1)
814 .create_async()
815 .await;
816 let second = server
817 .mock("GET", "/v1/organizations/cost_report")
818 .match_header("x-api-key", "org-b-key")
819 .match_query(mockito::Matcher::Any)
820 .with_status(200)
821 .with_body(ok_body("250.0"))
822 .expect(1)
823 .create_async()
824 .await;
825
826 let (_td, cache) = cache_fixture();
827 let client = reqwest::Client::new();
828 let endpoints = Endpoints {
829 cost_report: format!("{}/v1/organizations/cost_report", server.url()),
830 };
831 let now = at(2026, 7, 19);
832 let a = fetch_snapshot_at(
833 &client,
834 "org-a-key",
835 &cache,
836 &endpoints,
837 Duration::ZERO,
838 None,
839 now,
840 )
841 .await
842 .unwrap();
843 assert_eq!(a.snapshot.spent, 1.0);
844
845 let b = fetch_snapshot_at(
847 &client,
848 "org-b-key",
849 &cache,
850 &endpoints,
851 Duration::from_secs(3600),
852 None,
853 now,
854 )
855 .await
856 .unwrap();
857 assert_eq!(b.snapshot.spent, 2.5);
858 first.assert_async().await;
859 second.assert_async().await;
860 }
861
862 #[tokio::test]
863 async fn invalid_monthly_limits_fail_before_network_or_cache_access() {
864 let client = reqwest::Client::new();
865 let cache = Cache::at(std::path::PathBuf::from("unused-invalid-limit-cache"));
866 for limit in [0.0, -1.0, f64::INFINITY, f64::NAN] {
867 let err = fetch_snapshot_at(
868 &client,
869 "key",
870 &cache,
871 &Endpoints::default(),
872 Duration::ZERO,
873 Some(limit),
874 at(2026, 7, 19),
875 )
876 .await
877 .unwrap_err();
878 assert!(err.to_string().contains("monthly_limit"), "{err:?}");
879 }
880 assert!(!cache.dir().exists());
881 }
882}