Skip to main content

cratefield_testing/
budget.rs

1//! The outbound-budget contract (issue #765), asserted against a live
2//! adapter rather than trusted from its docs. A token bucket that lets two
3//! isolates both spend the last token, a daily quota that fires its alert
4//! twice, or a retry loop that swallows a provider's `Retry-After` would
5//! pass a hand-written spot check and then spend a venture's quota behind
6//! its back.
7//!
8//! [`assert_budget_pacing`], [`assert_budget_daily_quota`],
9//! [`assert_budget_retries`] and [`assert_budget_passthrough`] each build
10//! a [`BudgetedHttpClient`] themselves — over a caller-provided database,
11//! a [`ManualClock`] the recorded sleeper advances, and a scripted inner
12//! client — so a runtime or venture holds its own store to the same
13//! standard by calling any subset, the way
14//! `assert_blob_round_trips(&db)` does for the blob port. The sleeps are
15//! recorded, never awaited: the delays are asserted, never slept.
16//!
17//! Each function runs its own probe tables
18//! (`budget_probe_*_buckets` / `budget_probe_*_usage`, the DDL the
19//! decorator ships: [`BudgetedHttpClient::create_bucket_table_sql`] and
20//! [`Usage::create_table_sql`]), cleared before and after, so the checks
21//! re-run against a database a previous run left behind.
22
23use std::sync::Arc;
24use std::time::Duration;
25
26use async_trait::async_trait;
27use bytes::Bytes;
28use futures_core::future::BoxFuture;
29use http::StatusCode;
30use time::OffsetDateTime;
31
32use cratefield_core::{
33    Budget, BudgetRouter, BudgetedHttpClient, Database, Defer, EVENT_QUOTA_EXHAUSTED,
34    EVENT_QUOTA_WARNING, EventBus, HttpClient, HttpError, Idempotent, RetryPolicy, Statement,
35    Usage,
36};
37
38use crate::fakes::ManualClock;
39
40// Connection-guarding `Mutex` for the recorded sends, sleeps and events —
41// test support, not request state (ADR 0007); the exception the workspace
42// `clippy.toml` documents for this crate's recording fakes.
43#[allow(clippy::disallowed_types)]
44use std::sync::Mutex as StdMutex;
45
46#[allow(clippy::disallowed_types)]
47type Log<T> = StdMutex<T>;
48
49/// The instant the probe clocks start at: one second into the Unix epoch,
50/// so the UTC day a daily quota counts against ends exactly 86 399 s later
51/// and the day-quota's `retry_after` is exact.
52fn clock_start() -> OffsetDateTime {
53    OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1)
54}
55
56/// The scripted inner client: records every request that arrives — whole,
57/// so a passthrough check can compare method, URI, headers and body — and
58/// pops one outcome per send. An exhausted script answers a bare `200`:
59/// the checks that care about outcomes script them, the rest only need the
60/// pipe open.
61struct ScriptedClient {
62    sent: Log<Vec<http::Request<Bytes>>>,
63    script: Log<Vec<Result<http::Response<Bytes>, HttpError>>>,
64}
65
66impl ScriptedClient {
67    fn new() -> Self {
68        Self {
69            sent: Log::new(Vec::new()),
70            script: Log::new(Vec::new()),
71        }
72    }
73
74    fn push(&self, outcome: Result<http::Response<Bytes>, HttpError>) {
75        self.script.lock().expect("script lock").push(outcome);
76    }
77
78    fn sends(&self) -> usize {
79        self.sent.lock().expect("sent lock").len()
80    }
81
82    fn captured(&self) -> Vec<http::Request<Bytes>> {
83        self.sent.lock().expect("sent lock").clone()
84    }
85}
86
87#[async_trait]
88impl HttpClient for ScriptedClient {
89    async fn send(
90        &self,
91        request: http::Request<Bytes>,
92    ) -> Result<http::Response<Bytes>, HttpError> {
93        self.sent.lock().expect("sent lock").push(request);
94        let mut script = self.script.lock().expect("script lock");
95        if script.is_empty() {
96            Ok(http::Response::new(Bytes::new()))
97        } else {
98            script.remove(0)
99        }
100    }
101}
102
103/// A `Defer` that collects the deferred futures instead of running them:
104/// the bus's quota alerts ride the scope's defer, and draining it is what
105/// makes an event's arrival observable and ordered relative to the send
106/// that emitted it.
107#[derive(Clone, Default)]
108struct RecordingDefer(Arc<Log<Vec<BoxFuture<'static, ()>>>>);
109
110impl Defer for RecordingDefer {
111    fn wait_until(&self, fut: BoxFuture<'static, ()>) {
112        self.0.lock().expect("defer lock").push(fut);
113    }
114}
115
116impl RecordingDefer {
117    /// Runs every deferred future to completion, in deferral order.
118    async fn drain(&self) {
119        loop {
120            let next = {
121                let mut pending = self.0.lock().expect("defer lock");
122                (!pending.is_empty()).then(|| pending.remove(0))
123            };
124            let Some(fut) = next else { break };
125            fut.await;
126        }
127    }
128}
129
130/// Everything one check needs: the budgeted client over the caller's
131/// database, the manual clock its sleeper advances, and the recordings —
132/// inner sends, sleeps and quota events.
133struct Fixture {
134    db: Arc<dyn Database>,
135    clock: ManualClock,
136    inner: Arc<ScriptedClient>,
137    sleeps: Arc<Log<Vec<Duration>>>,
138    events: Arc<Log<Vec<(String, serde_json::Value)>>>,
139    defer: RecordingDefer,
140    client: BudgetedHttpClient,
141}
142
143impl Fixture {
144    /// Builds a fixture whose router hands out the budgets, over `policy`,
145    /// with an instant sleeper and a bus recording the quota events.
146    fn new(
147        db: &Arc<dyn Database>,
148        buckets: &str,
149        usage: &str,
150        policy: RetryPolicy,
151        router: BudgetRouter,
152    ) -> Self {
153        let clock = ManualClock::new(clock_start());
154        let inner = Arc::new(ScriptedClient::new());
155        let sleeps: Arc<Log<Vec<Duration>>> = Arc::new(Log::new(Vec::new()));
156        let events: Arc<Log<Vec<(String, serde_json::Value)>>> = Arc::new(Log::new(Vec::new()));
157        let warnings = Arc::clone(&events);
158        let exhaustions = Arc::clone(&events);
159        let bus = EventBus::new()
160            .on(
161                EVENT_QUOTA_WARNING,
162                Arc::new(move |_scope, payload| {
163                    warnings
164                        .lock()
165                        .expect("event record")
166                        .push((EVENT_QUOTA_WARNING.to_owned(), payload));
167                    Box::pin(std::future::ready(Ok(())))
168                }),
169            )
170            .on(
171                EVENT_QUOTA_EXHAUSTED,
172                Arc::new(move |_scope, payload| {
173                    exhaustions
174                        .lock()
175                        .expect("event record")
176                        .push((EVENT_QUOTA_EXHAUSTED.to_owned(), payload));
177                    Box::pin(std::future::ready(Ok(())))
178                }),
179            );
180        let defer = RecordingDefer::default();
181        let sleeper_sleeps = Arc::clone(&sleeps);
182        let sleeper_clock = clock.clone();
183        let client = BudgetedHttpClient::new(
184            Arc::clone(&inner) as Arc<dyn HttpClient>,
185            Arc::new(clock.clone()),
186            Arc::clone(db),
187            router,
188        )
189        .with_retry(policy)
190        .with_events(bus)
191        .with_defer(Arc::new(defer.clone()) as Arc<dyn Defer>)
192        .with_bucket_table(buckets)
193        .with_quota_table(usage)
194        .with_sleeper(Arc::new(move |after| {
195            sleeper_sleeps.lock().expect("sleeps lock").push(after);
196            // Sleeping is time passing: the manual clock advances by what
197            // was slept, so a paced acquire finds its refill on the next
198            // attempt, exactly as it would against a real timer.
199            sleeper_clock.advance(after);
200            Box::pin(std::future::ready(()))
201        }));
202        Self {
203            db: Arc::clone(db),
204            clock,
205            inner,
206            sleeps,
207            events,
208            defer,
209            client,
210        }
211    }
212
213    fn sends(&self) -> usize {
214        self.inner.sends()
215    }
216
217    fn sleep_log(&self) -> Vec<Duration> {
218        self.sleeps.lock().expect("sleeps lock").clone()
219    }
220
221    /// The recorded quota events, with their deferred handlers completed,
222    /// draining the record so the next assertion starts empty.
223    async fn drain_events(&self) -> Vec<(String, serde_json::Value)> {
224        self.defer.drain().await;
225        std::mem::take(&mut *self.events.lock().expect("event record"))
226    }
227
228    /// The bucket keys holding a row, sorted — one row per budget key, no
229    /// cross-key row.
230    async fn bucket_keys(&self, buckets: &str) -> Vec<String> {
231        let rows = self
232            .db
233            .query(&Statement::new(format!(
234                "SELECT bucket FROM {buckets} ORDER BY bucket"
235            )))
236            .await
237            .expect("bucket rows read");
238        rows.rows
239            .iter()
240            .filter_map(|row| row.get::<String>("bucket"))
241            .collect()
242    }
243}
244
245/// A router that sends every request to the same budget.
246fn fixed_router(budget: &Budget) -> BudgetRouter {
247    let budget = budget.clone();
248    Arc::new(move |_request| Some(budget.clone()))
249}
250
251/// A `GET` against the probe upstream.
252fn get_request(path: &str) -> http::Request<Bytes> {
253    http::Request::builder()
254        .method(http::Method::GET)
255        .uri(format!("https://upstream.example.test{path}"))
256        .body(Bytes::new())
257        .expect("test request")
258}
259
260/// A `POST` against the probe upstream.
261fn post_request(path: &str) -> http::Request<Bytes> {
262    http::Request::builder()
263        .method(http::Method::POST)
264        .uri(format!("https://upstream.example.test{path}"))
265        .body(Bytes::new())
266        .expect("test request")
267}
268
269/// A throttled response with an optional `Retry-After` in delta-seconds.
270fn throttled(status: StatusCode, retry_after: Option<&str>) -> http::Response<Bytes> {
271    let mut builder = http::Response::builder().status(status);
272    if let Some(value) = retry_after {
273        builder = builder.header(http::header::RETRY_AFTER, value);
274    }
275    builder.body(Bytes::new()).expect("test response")
276}
277
278/// A `200` carrying a body a retry check can recognise.
279fn ok_response(body: &[u8]) -> http::Response<Bytes> {
280    http::Response::builder()
281        .status(StatusCode::OK)
282        .body(Bytes::copy_from_slice(body))
283        .expect("test response")
284}
285
286/// Creates the probe tables the decorator spends from and clears whatever a
287/// previous run left in them.
288async fn create_probe_tables(db: &Arc<dyn Database>, buckets: &str, usage: &str) {
289    db.execute(&Statement::new(
290        BudgetedHttpClient::create_bucket_table_sql(buckets),
291    ))
292    .await
293    .expect("bucket probe table");
294    db.execute(&Statement::new(Usage::new(usage).create_table_sql()))
295        .await
296        .expect("quota probe table");
297    clear_probe_tables(db, buckets, usage).await;
298}
299
300/// Leaves the probe tables empty, so a re-run starts where the first began.
301async fn clear_probe_tables(db: &Arc<dyn Database>, buckets: &str, usage: &str) {
302    for table in [buckets, usage] {
303        db.execute(&Statement::new(format!("DELETE FROM {table}")))
304            .await
305            .expect("clear probe");
306    }
307}
308
309/// Proves `db` can carry the pacing half of the outbound budgets (issue
310/// #765): capacity 2 refilled at 1 token per second, cost 1 per request —
311/// two sends spend the whole bucket, the third paces (the recorded sleeper
312/// waits no longer than the policy's `max_delay`) or refuses with
313/// [`HttpError::BudgetExhausted`] naming a positive `retry_after`, and one
314/// refill window later the request goes through. A second key spends its
315/// own full bucket and holds its own row: keys do not share a bucket.
316///
317/// Runs its own probe tables, so it can be called against any adapter with
318/// a writable connection.
319///
320/// # Panics
321///
322/// Panics when the pacing contract is violated: a dry bucket answers with
323/// anything but a paced send or a budget refusal, the recorded wait passes
324/// `max_delay`, a refusal names no wait, a refill window does not carry the
325/// next request, the second key inherits the first's emptiness, or the
326/// probe tables cannot be created on the given connection.
327pub async fn assert_budget_pacing(db: &Arc<dyn Database>) {
328    let buckets = "budget_probe_pacing_buckets";
329    let usage = "budget_probe_pacing_usage";
330    create_probe_tables(db, buckets, usage).await;
331
332    let budget = |key: &str| Budget {
333        key: key.to_owned(),
334        capacity: 2.0,
335        refill_per_sec: 1.0,
336        cost: 1.0,
337        daily_limit: None,
338    };
339    let router: BudgetRouter = {
340        let (a, b) = (budget("budget-probe:a"), budget("budget-probe:b"));
341        Arc::new(move |request| match request.uri().path() {
342            "/a" => Some(a.clone()),
343            "/b" => Some(b.clone()),
344            _ => None,
345        })
346    };
347    let policy = RetryPolicy::default();
348    let fixture = Fixture::new(db, buckets, usage, policy, router);
349
350    fixture
351        .client
352        .send(get_request("/a"))
353        .await
354        .expect("the first send is within the fresh bucket");
355    fixture
356        .client
357        .send(get_request("/a"))
358        .await
359        .expect("the second send spends the last token");
360    // The bucket is dry. A shortfall that refills within the policy's bound
361    // is pacing; anything longer is a refusal carrying the wait.
362    match fixture.client.send(get_request("/a")).await {
363        Ok(_) => {
364            let waits = fixture.sleep_log();
365            assert!(!waits.is_empty(), "a paced send records the waits it made");
366            assert!(
367                waits.iter().all(|wait| *wait <= policy.max_delay()),
368                "pacing never waits past max_delay: {waits:?}"
369            );
370        }
371        Err(HttpError::BudgetExhausted { what, retry_after }) => {
372            assert_eq!(what, "budget-probe:a");
373            assert!(
374                retry_after > Duration::ZERO,
375                "a refusal carries the refill wait, got {retry_after:?}"
376            );
377        }
378        Err(other) => panic!("a dry bucket paces or refuses, nothing else: {other}"),
379    }
380    // One refill window is one token: the request goes through.
381    fixture.clock.advance(Duration::from_secs(1));
382    fixture
383        .client
384        .send(get_request("/a"))
385        .await
386        .expect("the refilled bucket carries the request");
387
388    // The second key's first send succeeds even though the first key's
389    // bucket is dry right now — its own full bucket, not a shared one — and
390    // the store holds one row per key, never a cross-key row.
391    fixture
392        .client
393        .send(get_request("/b"))
394        .await
395        .expect("a second key has its own full bucket");
396    assert_eq!(
397        fixture.bucket_keys(buckets).await,
398        vec!["budget-probe:a", "budget-probe:b"],
399        "one bucket row per key"
400    );
401
402    clear_probe_tables(db, buckets, usage).await;
403}
404
405/// Proves `db` can carry the daily-quota half of the outbound budgets
406/// (issue #765): a `daily_limit` of 10 at cost 1 fires
407/// [`EVENT_QUOTA_WARNING`] exactly once on the eighth send (the 80% mark)
408/// and [`EVENT_QUOTA_EXHAUSTED`] exactly once on the tenth — at-most-once,
409/// with no bookkeeping — and the eleventh send is refused with
410/// [`HttpError::BudgetExhausted`] whose `retry_after` reaches the UTC day
411/// boundary, past which the quota is fresh again. A budget with
412/// `daily_limit: None` never refuses on quota and never alerts.
413///
414/// Runs its own probe tables, so it can be called against any adapter with
415/// a writable connection.
416///
417/// # Panics
418///
419/// Panics when the quota contract is violated: an alert fires twice or not
420/// at all, a refusal names anything but the day boundary, the next day's
421/// quota is not fresh, an untracked budget refuses, or the probe tables
422/// cannot be created on the given connection.
423pub async fn assert_budget_daily_quota(db: &Arc<dyn Database>) {
424    let buckets = "budget_probe_quota_buckets";
425    let usage = "budget_probe_quota_usage";
426    create_probe_tables(db, buckets, usage).await;
427
428    let budget = Budget {
429        key: "budget-probe:quota".to_owned(),
430        capacity: 100.0,
431        refill_per_sec: 0.0,
432        cost: 1.0,
433        daily_limit: Some(10),
434    };
435    let fixture = Fixture::new(
436        db,
437        buckets,
438        usage,
439        RetryPolicy::default(),
440        fixed_router(&budget),
441    );
442
443    check_quota_alerts(&fixture).await;
444    check_untracked_quota(db, buckets, usage).await;
445
446    clear_probe_tables(db, buckets, usage).await;
447}
448
449/// Eight sends land exactly on the 80% warning, the tenth on the
450/// exhaustion, the eleventh on a refusal pointing at the day boundary —
451/// and once the boundary passes, the day resets.
452async fn check_quota_alerts(fixture: &Fixture) {
453    for spent in 1..=8 {
454        fixture
455            .client
456            .send(get_request("/quota"))
457            .await
458            .expect("within quota");
459        assert_eq!(
460            fixture.sends(),
461            spent,
462            "every send within the quota goes out"
463        );
464    }
465    let events = fixture.drain_events().await;
466    let [(name, payload)] = events.as_slice() else {
467        panic!("exactly one alert by the eighth send, got {events:?}");
468    };
469    assert_eq!(name, EVENT_QUOTA_WARNING, "the eighth spend is the warning");
470    assert_eq!(payload["subject"], "budget-probe:quota");
471    assert_eq!(payload["used"], 8);
472    assert_eq!(payload["limit"], 10);
473
474    fixture
475        .client
476        .send(get_request("/quota"))
477        .await
478        .expect("the ninth send is under the limit");
479    fixture
480        .client
481        .send(get_request("/quota"))
482        .await
483        .expect("the tenth send spends the day");
484    let events = fixture.drain_events().await;
485    let [(name, payload)] = events.as_slice() else {
486        panic!("exactly one alert for the tenth send, got {events:?}");
487    };
488    assert_eq!(
489        name, EVENT_QUOTA_EXHAUSTED,
490        "the tenth spend is the exhaustion"
491    );
492    assert_eq!(payload["used"], 10);
493
494    // The day is spent: the next send is refused, pointing at the boundary
495    // — one second into the epoch day, the rest of that day.
496    let err = fixture
497        .client
498        .send(get_request("/quota"))
499        .await
500        .expect_err("the day is spent");
501    assert!(
502        matches!(
503            &err,
504            HttpError::BudgetExhausted { what, retry_after }
505                if what == "budget-probe:quota"
506                    && *retry_after == Duration::from_secs(86_400 - 1)
507        ),
508        "the retry_after reaches the UTC day boundary: got {err}"
509    );
510    assert_eq!(fixture.sends(), 10, "the refused request never went out");
511
512    // The boundary is a promise: when it passes, the new day is fresh.
513    fixture.clock.advance(Duration::from_secs(86_400 - 1));
514    fixture
515        .client
516        .send(get_request("/quota"))
517        .await
518        .expect("the next day's quota is fresh");
519}
520
521/// A budget with `daily_limit: None` is untracked: however many sends go
522/// out, none refuses and none alerts.
523async fn check_untracked_quota(db: &Arc<dyn Database>, buckets: &str, usage: &str) {
524    let budget = Budget {
525        key: "budget-probe:untracked".to_owned(),
526        daily_limit: None,
527        ..budget_shape()
528    };
529    let fixture = Fixture::new(
530        db,
531        buckets,
532        usage,
533        RetryPolicy::default(),
534        fixed_router(&budget),
535    );
536    for _ in 0..12 {
537        fixture
538            .client
539            .send(get_request("/quota"))
540            .await
541            .expect("an untracked budget never refuses on quota");
542    }
543    let events = fixture.drain_events().await;
544    assert!(events.is_empty(), "no limit, no alerts: {events:?}");
545}
546
547/// The shape every probe budget shares: a wide idle bucket and cost 1, so
548/// only the field a check names is doing any work.
549fn budget_shape() -> Budget {
550    Budget {
551        key: String::new(),
552        capacity: 100.0,
553        refill_per_sec: 0.0,
554        cost: 1.0,
555        daily_limit: None,
556    }
557}
558
559/// Proves the retry half of the outbound budgets (issue #765): a 429
560/// stating `Retry-After: 7` on an idempotent `GET` is honoured verbatim —
561/// the recorded sleeper waits exactly 7 s and the exchange lands on the
562/// second attempt — the same script on a `POST` is *not* a retry (the 429
563/// itself is the answer), a `POST` the caller vouches for with the
564/// [`Idempotent`] extension set to `true` retries, and a transport failure
565/// on a `GET` retries on the policy's exponential schedule (its own
566/// `base`, doubled).
567///
568/// Runs its own probe tables, so it can be called against any adapter with
569/// a writable connection.
570///
571/// # Panics
572///
573/// Panics when the retry contract is violated: a stated `Retry-After` is
574/// not waited out verbatim, a `POST` is retried without the extension, a
575/// vouched `POST` is not, a transport failure is not retried or retries
576/// off the schedule, or the probe tables cannot be created on the given
577/// connection.
578pub async fn assert_budget_retries(db: &Arc<dyn Database>) {
579    let buckets = "budget_probe_retry_buckets";
580    let usage = "budget_probe_retry_usage";
581    create_probe_tables(db, buckets, usage).await;
582
583    check_stated_retry_after_is_honoured(db, buckets, usage).await;
584    check_a_post_is_not_retried(db, buckets, usage).await;
585    check_the_idempotent_extension_vouches(db, buckets, usage).await;
586    check_transport_retries_on_the_schedule(db, buckets, usage).await;
587
588    clear_probe_tables(db, buckets, usage).await;
589}
590
591/// A budget for one retry check: a wide idle bucket, so only the exchange
592/// under test is doing any work.
593fn retry_fixture(db: &Arc<dyn Database>, buckets: &str, usage: &str, key: &str) -> Fixture {
594    let budget = Budget {
595        key: key.to_owned(),
596        ..budget_shape()
597    };
598    let router = fixed_router(&budget);
599    Fixture::new(db, buckets, usage, RetryPolicy::default(), router)
600}
601
602/// A 429 with `Retry-After: 7` on a `GET`: one wait of exactly the stated
603/// pause, then the retried send's own answer.
604async fn check_stated_retry_after_is_honoured(db: &Arc<dyn Database>, buckets: &str, usage: &str) {
605    let fixture = retry_fixture(db, buckets, usage, "budget-probe:retry-stated");
606    fixture
607        .inner
608        .push(Ok(throttled(StatusCode::TOO_MANY_REQUESTS, Some("7"))));
609    fixture.inner.push(Ok(ok_response(b"ok")));
610    let response = fixture
611        .client
612        .send(get_request("/retry"))
613        .await
614        .expect("the retry lands after the stated pause");
615    assert_eq!(response.status(), StatusCode::OK);
616    assert_eq!(response.body().as_ref(), b"ok");
617    assert_eq!(fixture.sends(), 2, "one throttled attempt, one retry");
618    assert_eq!(
619        fixture.sleep_log(),
620        vec![Duration::from_secs(7)],
621        "the header's pause, verbatim"
622    );
623}
624
625/// The same script on a `POST` is not a retry: the 429 itself is the
626/// answer, with no wait and no second attempt.
627async fn check_a_post_is_not_retried(db: &Arc<dyn Database>, buckets: &str, usage: &str) {
628    let fixture = retry_fixture(db, buckets, usage, "budget-probe:retry-post");
629    fixture
630        .inner
631        .push(Ok(throttled(StatusCode::TOO_MANY_REQUESTS, Some("7"))));
632    let response = fixture
633        .client
634        .send(post_request("/retry"))
635        .await
636        .expect("a throttled POST is a response, not an error");
637    assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
638    assert_eq!(fixture.sends(), 1, "a POST owns its weather");
639    assert!(fixture.sleep_log().is_empty(), "no wait was made");
640}
641
642/// A `POST` the caller vouches for with the [`Idempotent`] extension
643/// retries like a `GET`; with no header stated, the wait is the schedule's
644/// own base.
645async fn check_the_idempotent_extension_vouches(
646    db: &Arc<dyn Database>,
647    buckets: &str,
648    usage: &str,
649) {
650    let fixture = retry_fixture(db, buckets, usage, "budget-probe:retry-vouched");
651    fixture
652        .inner
653        .push(Ok(throttled(StatusCode::TOO_MANY_REQUESTS, None)));
654    fixture.inner.push(Ok(ok_response(b"ok")));
655    let mut vouched = post_request("/retry");
656    vouched.extensions_mut().insert(Idempotent(true));
657    let response = fixture
658        .client
659        .send(vouched)
660        .await
661        .expect("the vouched retry lands");
662    assert_eq!(response.status(), StatusCode::OK);
663    assert_eq!(fixture.sends(), 2, "the vouch makes the POST retryable");
664    assert_eq!(
665        fixture.sleep_log(),
666        vec![RetryPolicy::default().base()],
667        "no header stated: the schedule's first backoff"
668    );
669}
670
671/// A transport failure on a `GET` retries on the exponential schedule: the
672/// policy's `base`, then its double.
673async fn check_transport_retries_on_the_schedule(
674    db: &Arc<dyn Database>,
675    buckets: &str,
676    usage: &str,
677) {
678    let fixture = retry_fixture(db, buckets, usage, "budget-probe:retry-weather");
679    fixture
680        .inner
681        .push(Err(HttpError::Transport("reset".to_owned())));
682    fixture
683        .inner
684        .push(Err(HttpError::Transport("reset".to_owned())));
685    fixture.inner.push(Ok(ok_response(b"ok")));
686    let response = fixture
687        .client
688        .send(get_request("/retry"))
689        .await
690        .expect("the weather clears on the third attempt");
691    assert_eq!(response.status(), StatusCode::OK);
692    assert_eq!(
693        fixture.sends(),
694        3,
695        "the first send plus exactly the two retries"
696    );
697    let policy = RetryPolicy::default();
698    assert_eq!(
699        fixture.sleep_log(),
700        vec![policy.base(), policy.base() * 2],
701        "exponential: base, then double"
702    );
703}
704
705/// Proves the pass-through half of the outbound budgets (issue #765): a
706/// router that answers `None` governs nothing — the request reaches the
707/// inner client verbatim (method, URI, headers, body and the request
708/// extensions an [`Idempotent`] marker rides in), the response passes back
709/// untouched, and the budget store stays empty because nothing was spent:
710/// no bucket row, no quota row, no sleeper wait.
711///
712/// Runs its own probe tables, so it can be called against any adapter with
713/// a writable connection.
714///
715/// # Panics
716///
717/// Panics when the pass-through contract is violated: the inner client sees
718/// a request other than the one sent, the response is rewritten, a budget
719/// row or a sleeper wait appears, or the probe tables cannot be created on
720/// the given connection.
721pub async fn assert_budget_passthrough(db: &Arc<dyn Database>) {
722    let buckets = "budget_probe_passthrough_buckets";
723    let usage = "budget_probe_passthrough_usage";
724    create_probe_tables(db, buckets, usage).await;
725
726    // A router that answers None: nothing here is ours to govern, however
727    // tight the budgets a deployment configured might be.
728    let router: BudgetRouter = Arc::new(|_request| None);
729    let fixture = Fixture::new(db, buckets, usage, RetryPolicy::default(), router);
730
731    let mut request = http::Request::builder()
732        .method(http::Method::POST)
733        .uri("https://upstream.example.test/v1/things?x=1")
734        .header("x-probe", "yes")
735        .body(Bytes::from_static(b"payload"))
736        .expect("test request");
737    request.extensions_mut().insert(Idempotent(true));
738    fixture.inner.push(Ok(http::Response::builder()
739        .status(StatusCode::ACCEPTED)
740        .header("x-probe-echo", "same")
741        .body(Bytes::from_static(b"untouched"))
742        .expect("test response")));
743
744    let response = fixture
745        .client
746        .send(request)
747        .await
748        .expect("an unrouted request passes through");
749    assert_eq!(response.status(), StatusCode::ACCEPTED);
750    assert_eq!(response.body().as_ref(), b"untouched");
751    assert_eq!(
752        response
753            .headers()
754            .get("x-probe-echo")
755            .map(http::HeaderValue::as_bytes),
756        Some(b"same".as_slice()),
757        "the response's own headers survive"
758    );
759
760    let captured = fixture.inner.captured();
761    let [sent] = captured.as_slice() else {
762        panic!(
763            "the inner client saw exactly one request, got {}",
764            captured.len()
765        );
766    };
767    assert_eq!(sent.method(), http::Method::POST);
768    assert_eq!(sent.uri(), "https://upstream.example.test/v1/things?x=1");
769    assert_eq!(
770        sent.headers()
771            .get("x-probe")
772            .map(http::HeaderValue::as_bytes),
773        Some(b"yes".as_slice())
774    );
775    assert_eq!(sent.body().as_ref(), b"payload");
776    assert_eq!(
777        sent.extensions().get::<Idempotent>(),
778        Some(&Idempotent(true)),
779        "the extensions ride along"
780    );
781
782    assert!(fixture.sleep_log().is_empty(), "no budget, no waits");
783    assert_eq!(row_count(db, buckets).await, 0, "no bucket rows written");
784    assert_eq!(row_count(db, usage).await, 0, "no quota rows written");
785
786    clear_probe_tables(db, buckets, usage).await;
787}
788
789/// The number of rows in a probe table.
790async fn row_count(db: &Arc<dyn Database>, table: &str) -> i64 {
791    let rows = db
792        .query(&Statement::new(format!(
793            "SELECT COUNT(*) AS n FROM {table}"
794        )))
795        .await
796        .expect("count probe rows");
797    rows.first()
798        .and_then(|row| row.get::<i64>("n"))
799        .expect("one count row")
800}