Skip to main content

ai_usagebar/anthropic_api/
fetch.rs

1//! Anthropic Admin API fetch — sums the current month's `cost_report` buckets
2//! into a month-to-date spend, under the shared cache + flock primitives.
3//!
4//! Auth is a Console **Admin key** (`sk-ant-admin01-…`, distinct from an
5//! inference key) in the `x-api-key` header. The monthly `limit` is NOT part of
6//! the API response — it's supplied from config and carried in the snapshot so
7//! the renderer can show spend-vs-limit.
8
9use std::time::Duration;
10
11use chrono::{DateTime, Datelike, Utc};
12
13use crate::cache::{Cache, MAX_STALE, 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_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);
24/// Safety cap on pagination — a single month is at most 31 daily buckets, so a
25/// handful of pages is plenty; this just bounds a runaway `next_page` loop.
26const 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
41#[derive(Debug, Clone)]
42pub struct FetchOutcome {
43    pub snapshot: AnthropicApiSnapshot,
44    pub stale: bool,
45    pub last_error: Option<(u16, String)>,
46    pub cache_age: Option<Duration>,
47}
48
49/// First instant of `now`'s calendar month, as an RFC-3339 UTC string.
50fn month_start_rfc3339(now: DateTime<Utc>) -> String {
51    format!("{:04}-{:02}-01T00:00:00Z", now.year(), now.month())
52}
53
54/// Identity of the organization whose spend is cached. The Admin API does not
55/// return an organization id in the cost report, so the key itself is the only
56/// zero-round-trip identity available. Store only a fingerprint: it is a cache
57/// change detector, not an authentication secret. If Rust ever changes the
58/// hasher algorithm, the harmless result is one extra refetch after upgrade.
59fn target_key(admin_key: &str) -> String {
60    use std::hash::{Hash, Hasher};
61    let mut hasher = std::collections::hash_map::DefaultHasher::new();
62    admin_key.hash(&mut hasher);
63    format!("key:{:016x}", hasher.finish())
64}
65
66fn validate_limit(limit: Option<f64>) -> Result<Option<f64>> {
67    if let Some(value) = limit
68        && (!value.is_finite() || value <= 0.0)
69    {
70        return Err(AppError::Schema(
71            "anthropic-api monthly_limit must be finite and greater than zero; \
72             remove it to show spend without a limit"
73                .into(),
74        ));
75    }
76    Ok(limit)
77}
78
79/// `limit` is the user-configured monthly USD limit (from config, not the API);
80/// it's threaded through so the cached snapshot reflects the current config.
81pub async fn fetch_snapshot(
82    client: &reqwest::Client,
83    admin_key: &str,
84    cache: &Cache,
85    endpoints: &Endpoints,
86    cache_ttl: Duration,
87    limit: Option<f64>,
88) -> Result<FetchOutcome> {
89    fetch_snapshot_at(
90        client,
91        admin_key,
92        cache,
93        endpoints,
94        cache_ttl,
95        limit,
96        Utc::now(),
97    )
98    .await
99}
100
101/// Same as [`fetch_snapshot`] with an injected clock — the seam month-rollover
102/// tests use, so they never depend on the wall clock.
103pub async fn fetch_snapshot_at(
104    client: &reqwest::Client,
105    admin_key: &str,
106    cache: &Cache,
107    endpoints: &Endpoints,
108    cache_ttl: Duration,
109    limit: Option<f64>,
110    now: DateTime<Utc>,
111) -> Result<FetchOutcome> {
112    let limit = validate_limit(limit)?;
113    cache.ensure_dir()?;
114    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
115
116    // The query always starts at the current month, so a payload written last
117    // month is a *different* figure — not a stale version of this one.
118    let month = month_start_rfc3339(now);
119    let target = target_key(admin_key);
120
121    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
122        && let Ok(outcome) = reuse_cache(&bytes, cache, false, limit, &month, &target)
123    {
124        return Ok(outcome);
125    }
126
127    match fetch_live(client, endpoints, admin_key, now).await {
128        Ok(spent) => {
129            let snap = AnthropicApiSnapshot { spent, limit };
130            let bytes = serde_json::to_vec(&serde_json::json!({
131                "month": month,
132                "target": target,
133                "snapshot": { "spent": snap.spent, "limit": snap.limit },
134            }))?;
135            cache.write_payload(&bytes)?;
136            Ok(FetchOutcome {
137                snapshot: snap,
138                stale: false,
139                last_error: None,
140                cache_age: Some(Duration::ZERO),
141            })
142        }
143        Err(e) if e.is_transient() => fallback_silent(cache, limit, &month, &target, e),
144        Err(AppError::Http { status, body }) => {
145            cache.mark_stale();
146            cache.write_last_error(status, &body);
147            let diag = (status, body.clone());
148            fallback_with_error(
149                cache,
150                Some(diag),
151                limit,
152                &month,
153                &target,
154                AppError::Http { status, body },
155            )
156        }
157        Err(e) => {
158            cache.mark_stale();
159            cache.write_last_error(0, &e.to_string());
160            let diag = (0, e.to_string());
161            fallback_with_error(cache, Some(diag), limit, &month, &target, e)
162        }
163    }
164}
165
166fn fallback_silent(
167    cache: &Cache,
168    limit: Option<f64>,
169    month: &str,
170    target: &str,
171    original: AppError,
172) -> Result<FetchOutcome> {
173    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
174        return Err(original);
175    };
176    reuse_cache(&bytes, cache, true, limit, month, target)
177}
178
179/// On failure we show the last good figure with the error alongside it. With
180/// nothing usable cached there is nothing to show, so the **original** error is
181/// returned — a first-run 401/403 or schema error must reach the user with its
182/// Admin-key guidance intact, not as a generic "no usable cache".
183fn fallback_with_error(
184    cache: &Cache,
185    last_error: Option<(u16, String)>,
186    limit: Option<f64>,
187    month: &str,
188    target: &str,
189    original: AppError,
190) -> Result<FetchOutcome> {
191    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
192        return Err(original);
193    };
194    // Last month's spend is not this month's, so during an outage it is better
195    // to report the error than to display the wrong month as current.
196    let Ok(mut outcome) = reuse_cache(&bytes, cache, true, limit, month, target) else {
197        return Err(original);
198    };
199    outcome.last_error = last_error;
200    Ok(outcome)
201}
202
203fn reuse_cache(
204    bytes: &[u8],
205    cache: &Cache,
206    stale: bool,
207    limit: Option<f64>,
208    month: &str,
209    target: &str,
210) -> Result<FetchOutcome> {
211    // The cached spend is authoritative; the limit always comes from the
212    // current config so editing it takes effect without a refetch.
213    let spent = parse_cached_spent(bytes, month, target)?;
214    Ok(FetchOutcome {
215        snapshot: AnthropicApiSnapshot { spent, limit },
216        stale,
217        last_error: cache.read_last_error(),
218        cache_age: cache.payload_age(),
219    })
220}
221
222fn parse_cached_spent(bytes: &[u8], month: &str, target: &str) -> Result<f64> {
223    let v: serde_json::Value = serde_json::from_slice(bytes)?;
224    // Payloads written before the month was recorded cannot be attributed to
225    // one, so they are refetched rather than shown as the current month.
226    let cached_month = v.get("month").and_then(serde_json::Value::as_str);
227    if cached_month != Some(month) {
228        return Err(AppError::Schema(format!(
229            "anthropic-api cache is for a different month ({}); refetching",
230            cached_month.unwrap_or("unknown")
231        )));
232    }
233    let cached_target = v.get("target").and_then(serde_json::Value::as_str);
234    if cached_target != Some(target) {
235        return Err(AppError::Schema(
236            "anthropic-api cache belongs to a different Admin key; refetching".into(),
237        ));
238    }
239    let s = v
240        .get("snapshot")
241        .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'snapshot'".into()))?;
242    let spent = s["spent"]
243        .as_f64()
244        .ok_or_else(|| AppError::Schema("anthropic-api cache missing 'spent'".into()))?;
245    crate::usage::finite_amount("anthropic-api cache", "spent", spent)
246}
247
248async fn fetch_live(
249    client: &reqwest::Client,
250    endpoints: &Endpoints,
251    admin_key: &str,
252    now: DateTime<Utc>,
253) -> Result<f64> {
254    let starting_at = month_start_rfc3339(now);
255    let mut total = 0.0;
256    let mut page: Option<String> = None;
257    let mut seen_pages: Vec<String> = Vec::new();
258
259    for _ in 0..MAX_PAGES {
260        let mut req = client
261            .get(&endpoints.cost_report)
262            .header("x-api-key", admin_key)
263            .header("anthropic-version", ANTHROPIC_VERSION)
264            .query(&[
265                ("starting_at", starting_at.as_str()),
266                ("bucket_width", "1d"),
267            ]);
268        if let Some(p) = &page {
269            req = req.query(&[("page", p.as_str())]);
270        }
271
272        let resp = tokio::time::timeout(HTTP_TIMEOUT, req.send())
273            .await
274            .map_err(|_| {
275                AppError::Transport(format!("anthropic-api timeout: {}", endpoints.cost_report))
276            })??;
277
278        let status = resp.status();
279        let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
280        if !status.is_success() {
281            let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
282            return Err(AppError::Http {
283                status: status.as_u16(),
284                body,
285            });
286        }
287
288        let report: CostReport = serde_json::from_slice(&bytes)
289            .map_err(|e| AppError::Schema(format!("anthropic-api cost_report: {e}")))?;
290        total = finite_amount(
291            "anthropic-api",
292            "cost_report running total",
293            total + page_dollars(&report)?,
294        )?;
295
296        // A partial total is indistinguishable from a genuinely smaller spend
297        // once cached, so every way pagination can go wrong is an error rather
298        // than an early `break` with whatever was summed so far.
299        match (report.has_more, report.next_page) {
300            (false, _) => return Ok(total),
301            (true, None) => {
302                return Err(AppError::Schema(
303                    "anthropic-api cost_report: has_more is true but next_page is missing; \
304                     refusing to report a partial month"
305                        .into(),
306                ));
307            }
308            (true, Some(p)) if p.trim().is_empty() => {
309                return Err(AppError::Schema(
310                    "anthropic-api cost_report: has_more is true but next_page is empty; \
311                     refusing to report a partial month"
312                        .into(),
313                ));
314            }
315            (true, Some(p)) => {
316                if seen_pages.contains(&p) {
317                    return Err(AppError::Schema(format!(
318                        "anthropic-api cost_report: pagination repeated cursor {p:?}; \
319                         refusing to report a partial month"
320                    )));
321                }
322                seen_pages.push(p.clone());
323                page = Some(p);
324            }
325        }
326    }
327    Err(AppError::Schema(format!(
328        "anthropic-api cost_report: more than {MAX_PAGES} pages for one month; \
329         refusing to report a partial month"
330    )))
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use chrono::TimeZone;
337    use tempfile::TempDir;
338
339    fn cache_fixture() -> (TempDir, Cache) {
340        let td = TempDir::new().unwrap();
341        let cache = Cache::at(td.path().join("anthropic_api"));
342        cache.ensure_dir().unwrap();
343        (td, cache)
344    }
345
346    #[test]
347    fn month_start_is_first_of_month_utc() {
348        let now = Utc.with_ymd_and_hms(2026, 7, 19, 15, 8, 0).unwrap();
349        assert_eq!(month_start_rfc3339(now), "2026-07-01T00:00:00Z");
350    }
351
352    #[tokio::test]
353    async fn live_fetch_sums_month_to_date_and_divides_by_100() {
354        let mut server = mockito::Server::new_async().await;
355        server
356            .mock("GET", "/v1/organizations/cost_report")
357            .match_header("x-api-key", "sk-ant-admin01-test")
358            .match_query(mockito::Matcher::Any)
359            .with_status(200)
360            .with_body(
361                r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"},
362                    {"amount":"34.0","currency":"USD"}]}],
363                    "has_more":false,"next_page":null}"#,
364            )
365            .create_async()
366            .await;
367
368        let (_td, cache) = cache_fixture();
369        let client = reqwest::Client::new();
370        let endpoints = Endpoints {
371            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
372        };
373        let out = fetch_snapshot(
374            &client,
375            "sk-ant-admin01-test",
376            &cache,
377            &endpoints,
378            Duration::from_secs(0),
379            Some(1000.0),
380        )
381        .await
382        .unwrap();
383        // 134 cents = $1.34
384        assert!((out.snapshot.spent - 1.34).abs() < 1e-9);
385        assert_eq!(out.snapshot.limit, Some(1000.0));
386        assert!(!out.stale);
387    }
388
389    #[tokio::test]
390    async fn http_401_falls_back_to_cache_when_present() {
391        let mut server = mockito::Server::new_async().await;
392        server
393            .mock("GET", "/v1/organizations/cost_report")
394            .match_query(mockito::Matcher::Any)
395            .with_status(401)
396            .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
397            .create_async()
398            .await;
399
400        let (_td, cache) = cache_fixture();
401        let now = at(2026, 7, 19);
402        cache
403            .write_payload(
404                serde_json::json!({
405                    "month": month_start_rfc3339(now),
406                    "target": target_key("k"),
407                    "snapshot": { "spent": 2.5, "limit": null },
408                })
409                .to_string()
410                .as_bytes(),
411            )
412            .unwrap();
413
414        let client = reqwest::Client::new();
415        let endpoints = Endpoints {
416            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
417        };
418        let out = fetch_snapshot_at(
419            &client,
420            "k",
421            &cache,
422            &endpoints,
423            Duration::from_secs(0),
424            Some(50.0),
425            now,
426        )
427        .await
428        .unwrap();
429        assert!(out.stale);
430        assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
431        // limit comes from the current call, not the (null) cache.
432        assert_eq!(out.snapshot.limit, Some(50.0));
433        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
434    }
435
436    /// Fixed instant helper — tests never read the wall clock.
437    fn at(y: i32, m: u32, d: u32) -> DateTime<Utc> {
438        chrono::NaiveDate::from_ymd_opt(y, m, d)
439            .unwrap()
440            .and_hms_opt(12, 0, 0)
441            .unwrap()
442            .and_utc()
443    }
444
445    fn ok_body(cents: &str) -> String {
446        format!(
447            r#"{{"data":[{{"results":[{{"amount":"{cents}","currency":"USD"}}]}}],"has_more":false}}"#
448        )
449    }
450
451    #[tokio::test]
452    async fn month_rollover_refetches_instead_of_showing_last_month() {
453        // June's spend must never be displayed as July's, even while the
454        // payload is still inside the TTL.
455        let mut server = mockito::Server::new_async().await;
456        server
457            .mock("GET", "/v1/organizations/cost_report")
458            .match_query(mockito::Matcher::Any)
459            .with_status(200)
460            .with_body(ok_body("250.0"))
461            .create_async()
462            .await;
463
464        let (_td, cache) = cache_fixture();
465        cache
466            .write_payload(
467                serde_json::json!({
468                    "month": month_start_rfc3339(at(2026, 6, 30)),
469                    "target": target_key("k"),
470                    "snapshot": { "spent": 987.0, "limit": null },
471                })
472                .to_string()
473                .as_bytes(),
474            )
475            .unwrap();
476
477        let client = reqwest::Client::new();
478        let endpoints = Endpoints {
479            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
480        };
481        // Long TTL: the payload IS fresh, it is just the wrong month.
482        let out = fetch_snapshot_at(
483            &client,
484            "k",
485            &cache,
486            &endpoints,
487            Duration::from_secs(3600),
488            None,
489            at(2026, 7, 1),
490        )
491        .await
492        .unwrap();
493        assert!((out.snapshot.spent - 2.5).abs() < 1e-9);
494        assert!(!out.stale);
495    }
496
497    #[tokio::test]
498    async fn last_months_cache_is_not_served_during_an_outage() {
499        // With the API down and only June cached, reporting June as the current
500        // month is worse than surfacing the error.
501        let mut server = mockito::Server::new_async().await;
502        server
503            .mock("GET", "/v1/organizations/cost_report")
504            .match_query(mockito::Matcher::Any)
505            .with_status(500)
506            .with_body("upstream boom")
507            .create_async()
508            .await;
509
510        let (_td, cache) = cache_fixture();
511        cache
512            .write_payload(
513                serde_json::json!({
514                    "month": month_start_rfc3339(at(2026, 6, 30)),
515                    "target": target_key("k"),
516                    "snapshot": { "spent": 987.0, "limit": null },
517                })
518                .to_string()
519                .as_bytes(),
520            )
521            .unwrap();
522
523        let client = reqwest::Client::new();
524        let endpoints = Endpoints {
525            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
526        };
527        let out = fetch_snapshot_at(
528            &client,
529            "k",
530            &cache,
531            &endpoints,
532            Duration::from_secs(0),
533            None,
534            at(2026, 7, 1),
535        )
536        .await;
537        assert!(out.is_err(), "expected an error, got {out:?}");
538    }
539
540    #[tokio::test]
541    async fn first_run_auth_failure_preserves_the_original_error() {
542        // No cache: the actionable Admin-key message must reach the user
543        // instead of a generic "no usable cache".
544        let mut server = mockito::Server::new_async().await;
545        server
546            .mock("GET", "/v1/organizations/cost_report")
547            .match_query(mockito::Matcher::Any)
548            .with_status(401)
549            .with_body(r#"{"error":{"message":"invalid x-api-key"}}"#)
550            .create_async()
551            .await;
552
553        let (_td, cache) = cache_fixture();
554        let client = reqwest::Client::new();
555        let endpoints = Endpoints {
556            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
557        };
558        let err = fetch_snapshot_at(
559            &client,
560            "k",
561            &cache,
562            &endpoints,
563            Duration::from_secs(0),
564            None,
565            at(2026, 7, 19),
566        )
567        .await
568        .unwrap_err();
569        assert!(
570            matches!(err, AppError::Http { status: 401, .. }),
571            "original error must survive, got {err:?}"
572        );
573        assert!(err.to_string().contains("invalid x-api-key"));
574    }
575
576    #[tokio::test]
577    async fn has_more_without_next_page_is_an_error_not_a_partial_month() {
578        let mut server = mockito::Server::new_async().await;
579        server
580            .mock("GET", "/v1/organizations/cost_report")
581            .match_query(mockito::Matcher::Any)
582            .with_status(200)
583            .with_body(
584                r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
585                    "has_more":true}"#,
586            )
587            .create_async()
588            .await;
589
590        let (_td, cache) = cache_fixture();
591        let client = reqwest::Client::new();
592        let endpoints = Endpoints {
593            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
594        };
595        let out = fetch_snapshot_at(
596            &client,
597            "k",
598            &cache,
599            &endpoints,
600            Duration::from_secs(0),
601            None,
602            at(2026, 7, 19),
603        )
604        .await;
605        assert!(out.is_err(), "partial month must not be reported: {out:?}");
606    }
607
608    #[tokio::test]
609    async fn repeated_pagination_cursor_is_an_error() {
610        // A server that always hands back the same cursor would otherwise be
611        // summed MAX_PAGES times and cached as a wildly inflated spend.
612        let mut server = mockito::Server::new_async().await;
613        server
614            .mock("GET", "/v1/organizations/cost_report")
615            .match_query(mockito::Matcher::Any)
616            .with_status(200)
617            .with_body(
618                r#"{"data":[{"results":[{"amount":"100.0","currency":"USD"}]}],
619                    "has_more":true,"next_page":"same"}"#,
620            )
621            .expect_at_least(1)
622            .create_async()
623            .await;
624
625        let (_td, cache) = cache_fixture();
626        let client = reqwest::Client::new();
627        let endpoints = Endpoints {
628            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
629        };
630        let out = fetch_snapshot_at(
631            &client,
632            "k",
633            &cache,
634            &endpoints,
635            Duration::from_secs(0),
636            None,
637            at(2026, 7, 19),
638        )
639        .await;
640        assert!(out.is_err(), "cursor loop must not be reported: {out:?}");
641    }
642
643    #[tokio::test]
644    async fn malformed_200_is_not_cached_as_zero_spend() {
645        let mut server = mockito::Server::new_async().await;
646        server
647            .mock("GET", "/v1/organizations/cost_report")
648            .match_query(mockito::Matcher::Any)
649            .with_status(200)
650            .with_body(r#"{"error":{"message":"permission_error"}}"#)
651            .create_async()
652            .await;
653
654        let (_td, cache) = cache_fixture();
655        let client = reqwest::Client::new();
656        let endpoints = Endpoints {
657            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
658        };
659        let out = fetch_snapshot_at(
660            &client,
661            "k",
662            &cache,
663            &endpoints,
664            Duration::from_secs(0),
665            None,
666            at(2026, 7, 19),
667        )
668        .await;
669        assert!(out.is_err(), "expected a schema error, got {out:?}");
670        // And nothing was written to the cache.
671        assert!(cache.maybe_payload().unwrap().is_none());
672    }
673
674    #[tokio::test]
675    async fn a_month_with_no_spend_is_cached_as_a_real_zero() {
676        // The legitimate zero must still work end to end.
677        let mut server = mockito::Server::new_async().await;
678        server
679            .mock("GET", "/v1/organizations/cost_report")
680            .match_query(mockito::Matcher::Any)
681            .with_status(200)
682            .with_body(r#"{"data":[],"has_more":false,"next_page":null}"#)
683            .create_async()
684            .await;
685
686        let (_td, cache) = cache_fixture();
687        let client = reqwest::Client::new();
688        let endpoints = Endpoints {
689            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
690        };
691        let out = fetch_snapshot_at(
692            &client,
693            "k",
694            &cache,
695            &endpoints,
696            Duration::from_secs(0),
697            None,
698            at(2026, 7, 19),
699        )
700        .await
701        .unwrap();
702        assert_eq!(out.snapshot.spent, 0.0);
703        assert!(!out.stale);
704        assert!(cache.maybe_payload().unwrap().is_some());
705    }
706
707    #[tokio::test]
708    async fn switching_admin_key_refetches_instead_of_reusing_another_organization() {
709        let mut server = mockito::Server::new_async().await;
710        let first = server
711            .mock("GET", "/v1/organizations/cost_report")
712            .match_header("x-api-key", "org-a-key")
713            .match_query(mockito::Matcher::Any)
714            .with_status(200)
715            .with_body(ok_body("100.0"))
716            .expect(1)
717            .create_async()
718            .await;
719        let second = server
720            .mock("GET", "/v1/organizations/cost_report")
721            .match_header("x-api-key", "org-b-key")
722            .match_query(mockito::Matcher::Any)
723            .with_status(200)
724            .with_body(ok_body("250.0"))
725            .expect(1)
726            .create_async()
727            .await;
728
729        let (_td, cache) = cache_fixture();
730        let client = reqwest::Client::new();
731        let endpoints = Endpoints {
732            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
733        };
734        let now = at(2026, 7, 19);
735        let a = fetch_snapshot_at(
736            &client,
737            "org-a-key",
738            &cache,
739            &endpoints,
740            Duration::ZERO,
741            None,
742            now,
743        )
744        .await
745        .unwrap();
746        assert_eq!(a.snapshot.spent, 1.0);
747
748        // The first payload is fresh, but belongs to a different Admin key.
749        let b = fetch_snapshot_at(
750            &client,
751            "org-b-key",
752            &cache,
753            &endpoints,
754            Duration::from_secs(3600),
755            None,
756            now,
757        )
758        .await
759        .unwrap();
760        assert_eq!(b.snapshot.spent, 2.5);
761        first.assert_async().await;
762        second.assert_async().await;
763    }
764
765    #[tokio::test]
766    async fn invalid_monthly_limits_fail_before_network_or_cache_access() {
767        let client = reqwest::Client::new();
768        let cache = Cache::at(std::path::PathBuf::from("unused-invalid-limit-cache"));
769        for limit in [0.0, -1.0, f64::INFINITY, f64::NAN] {
770            let err = fetch_snapshot_at(
771                &client,
772                "key",
773                &cache,
774                &Endpoints::default(),
775                Duration::ZERO,
776                Some(limit),
777                at(2026, 7, 19),
778            )
779            .await
780            .unwrap_err();
781            assert!(err.to_string().contains("monthly_limit"), "{err:?}");
782        }
783        assert!(!cache.dir().exists());
784    }
785}