1use 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#[allow(clippy::disallowed_types)]
44use std::sync::Mutex as StdMutex;
45
46#[allow(clippy::disallowed_types)]
47type Log<T> = StdMutex<T>;
48
49fn clock_start() -> OffsetDateTime {
53 OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1)
54}
55
56struct 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#[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 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
130struct 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 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 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 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 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
245fn fixed_router(budget: &Budget) -> BudgetRouter {
247 let budget = budget.clone();
248 Arc::new(move |_request| Some(budget.clone()))
249}
250
251fn 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
260fn 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
269fn 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
278fn 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
286async 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
300async 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
309pub 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 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 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 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
405pub 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
449async 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 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 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
521async 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
547fn 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
559pub 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
591fn 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
602async 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
625async 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
642async 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
671async 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
705pub 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 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
789async 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}