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, 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);
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/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
42/// specialised to its snapshot.
43pub type FetchOutcome = crate::outcome::Outcome<AnthropicApiSnapshot>;
44
45/// First instant of `now`'s calendar month, as an RFC-3339 UTC string.
46fn month_start_rfc3339(now: DateTime<Utc>) -> String {
47    format!("{:04}-{:02}-01T00:00:00Z", now.year(), now.month())
48}
49
50/// Identity of the organization whose spend is cached. The Admin API does not
51/// return an organization id in the cost report, so the key itself is the only
52/// zero-round-trip identity available. Store only a fingerprint: it is a cache
53/// change detector, not an authentication secret. If Rust ever changes the
54/// hasher algorithm, the harmless result is one extra refetch after upgrade.
55fn 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
75/// `limit` is the user-configured monthly USD limit (from config, not the API);
76/// it's threaded through so the cached snapshot reflects the current config.
77pub 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
97/// Same as [`fetch_snapshot`] with an injected clock — the seam month-rollover
98/// tests use, so they never depend on the wall clock.
99pub 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    // The query always starts at the current month, so a payload written last
113    // month is a *different* figure — not a stale version of this one.
114    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    // #496: the cached bytes can be unusable for this refresh — a UTC month
123    // rollover makes last month's spend a *different* figure, which
124    // `reuse_cache` rejects. An active shared backoff still applies: surface
125    // the countdown instead of contacting the server, or a refresh interval
126    // shorter than the backoff window would re-request and re-arm forever.
127    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
189/// On failure we show the last good figure with the error alongside it. With
190/// nothing usable cached there is nothing to show, so the **original** error is
191/// returned — a first-run 401/403 or schema error must reach the user with its
192/// Admin-key guidance intact, not as a generic "no usable cache".
193/// Last month's spend is not this month's: a payload that will not parse for
194/// `month` fails the closure, and `outcome::fallback` then reports the outage
195/// rather than showing the wrong month as current.
196fn 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
221/// The cached spend is authoritative; the limit always comes from the current
222/// config so editing it takes effect without a refetch.
223fn 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    // Payloads written before the month was recorded cannot be attributed to
244    // one, so they are refetched rather than shown as the current month.
245    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        // A partial total is indistinguishable from a genuinely smaller spend
330        // once cached, so every way pagination can go wrong is an error rather
331        // than an early `break` with whatever was summed so far.
332        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        // 134 cents = $1.34
418        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        // limit comes from the current call, not the (null) cache.
470        assert_eq!(out.snapshot.limit, Some(50.0));
471        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
472    }
473
474    /// Fixed instant helper — tests never read the wall clock.
475    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        // June's spend must never be displayed as July's, even while the
492        // payload is still inside the TTL.
493        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        // Long TTL: the payload IS fresh, it is just the wrong month.
520        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        // With the API down and only June cached, reporting June as the current
538        // month is worse than surfacing the error.
539        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        // #496: June's cached spend is unusable in July (`reuse_cache`
581        // rejects it as a different figure), but a 429 backoff armed moments
582        // ago still applies — the refresh must surface the shared countdown
583        // without contacting the server. A refresh interval shorter than the
584        // backoff would otherwise re-request and re-arm forever.
585        let mut server = mockito::Server::new_async().await;
586        // Any live request answers 500 here; the assertion below demands the
587        // backoff 429 instead, so an accidental round-trip fails loudly.
588        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        // Armed against the real clock, like a genuine 429 would be; the
609        // payload above is seconds fresh, so the backoff branch serves it
610        // and the month check rejects it.
611        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        // No cache: the actionable Admin-key message must reach the user
640        // instead of a generic "no usable cache".
641        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        // A server that always hands back the same cursor would otherwise be
708        // summed MAX_PAGES times and cached as a wildly inflated spend.
709        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        // And nothing was written to the cache.
768        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        // The legitimate zero must still work end to end.
774        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        // The first payload is fresh, but belongs to a different Admin key.
846        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
883    /// The end-to-end regression for the Admin key: a non-401/403 error whose
884    /// body quotes the key the fetch was sent with used to persist the key in
885    /// `.last_error` and hand the key-carrying error back to the report. The
886    /// seam `build_outcome` uses — a `Cache::at` attached to the key via
887    /// `redacting`, plus the real fetch under the same single error wrap —
888    /// must leave the key in neither place.
889    #[tokio::test]
890    async fn error_response_quoting_the_admin_key_redacts_the_diagnostic_and_the_error() {
891        let key = "test-key-value";
892        let mut server = mockito::Server::new_async().await;
893        server
894            .mock("GET", "/v1/organizations/cost_report")
895            .match_query(mockito::Matcher::Any)
896            .with_status(500)
897            .with_body(format!(
898                r#"{{"error":{{"message":"invalid admin key {key}"}}}}"#
899            ))
900            .create_async()
901            .await;
902
903        let (_td, cache) = cache_fixture();
904        let cache = cache.redacting(key);
905
906        let client = reqwest::Client::new();
907        let endpoints = Endpoints {
908            cost_report: format!("{}/v1/organizations/cost_report", server.url()),
909        };
910        let err = crate::outcome::fetch_api_keyed(
911            async {
912                fetch_snapshot_at(
913                    &client,
914                    key,
915                    &cache,
916                    &endpoints,
917                    Duration::ZERO,
918                    None,
919                    at(2026, 7, 19),
920                )
921                .await
922                .map(crate::vendor::VendorOutcome::from)
923            },
924            key,
925        )
926        .await
927        .unwrap_err();
928
929        // The cache's first point of writing never held the key.
930        let persisted = std::fs::read_to_string(cache.last_error_path()).unwrap();
931        assert!(
932            !persisted.contains(key),
933            "the key reached .last_error: {persisted}"
934        );
935        assert!(persisted.contains("[key]"), "{persisted}");
936        assert!(
937            persisted.starts_with("500\n"),
938            "the code line must survive: {persisted}"
939        );
940        // Neither does the error the report shows.
941        let rendered = err.user_message();
942        assert!(
943            !rendered.contains(key),
944            "the key reached the report error: {rendered}"
945        );
946        assert!(rendered.contains("[key]"), "{rendered}");
947    }
948}