Skip to main content

moq_auth/
client.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::time::{Duration, Instant};
4
5use url::Url;
6
7use crate::lease::{self, Reason};
8use crate::{Bytes, Error, Event, Grant, Request};
9
10/// Every request is bounded so a hung server refuses rather than parks the session.
11const TIMEOUT: Duration = Duration::from_secs(10);
12
13/// The longest a failed re-check waits before trying again.
14const BACKOFF_MAX: Duration = Duration::from_secs(60);
15
16/// The HTTP side of the contract: one JSON POST per event to an auth server.
17///
18/// `connect` admits a session and hands back the [`lease::Consumer`] it holds; a task
19/// behind it re-POSTs `revalidate` on the grant's cadence, applies each reply, revokes
20/// when the server refuses, answers an invalid grant, or the grant expires, and POSTs
21/// `end` when the session closes.
22#[derive(Clone)]
23pub struct Client {
24	http: reqwest::Client,
25	url: Url,
26}
27
28impl Client {
29	/// A client for the server at `url`.
30	///
31	/// `http://` is accepted for a loopback host only; `https://` presents `tls`, the
32	/// caller's client identity and roots; `unix://` speaks HTTP over the socket at
33	/// the URL's path. Anything else is refused here rather than at the first session.
34	pub fn new(url: Url, tls: Option<rustls::ClientConfig>) -> crate::Result<Self> {
35		let builder = reqwest::Client::builder().timeout(TIMEOUT);
36
37		let (builder, url) = match url.scheme() {
38			"http" => {
39				let loopback = match url.host() {
40					Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
41					Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
42					Some(url::Host::Domain(host)) => host == "localhost",
43					None => false,
44				};
45				if !loopback {
46					return Err(Error::InsecureUrl(url.to_string()));
47				}
48				(builder, url)
49			}
50			"https" => match tls {
51				Some(tls) => (builder.use_preconfigured_tls(tls), url),
52				None => (builder, url),
53			},
54			#[cfg(unix)]
55			"unix" => {
56				let path = url.to_file_path().map_err(|()| Error::InvalidUrl(url.to_string()))?;
57				// The socket is the transport; the request target is the server's root.
58				let target = Url::parse("http://localhost/").expect("a constant URL parses");
59				(builder.unix_socket(path), target)
60			}
61			_ => return Err(Error::InvalidUrl(url.to_string())),
62		};
63
64		Ok(Self {
65			http: builder.build()?,
66			url,
67		})
68	}
69
70	/// Admit a session: POST `connect`, validate the reply, and return the lease the
71	/// session holds. The session reports totals through [`lease::Consumer::close`].
72	pub async fn connect(&self, request: Request) -> crate::Result<lease::Consumer> {
73		let mut request = request;
74		request.event = Event::Connect;
75
76		let grant = self.post(&request).await?;
77		let (producer, consumer) = lease::Producer::new(grant.clone());
78
79		let driver = Driver {
80			client: self.clone(),
81			request,
82			producer: Some(producer),
83			expires: grant.deadline(),
84			started: Instant::now(),
85		};
86		tokio::spawn(driver.run(grant));
87
88		Ok(consumer)
89	}
90
91	/// One POST: a 2xx with a valid grant admits, a 401 or 403 refuses, a 2xx
92	/// whose grant fails validation is that error, and everything else is an
93	/// outage the caller decides about.
94	async fn post(&self, request: &Request) -> crate::Result<Grant> {
95		let response = self.http.post(self.url.clone()).json(request).send().await?;
96		let status = response.status();
97		if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
98			return Err(Error::Refused);
99		}
100		if !status.is_success() {
101			return Err(Error::Unavailable(format!("auth server answered {status}")));
102		}
103		let grant: Grant = response.json().await?;
104		grant.validate().map_err(|err| match err {
105			// An empty grant is a refusal, not a malformed answer.
106			Error::UselessGrant => Error::Refused,
107			other => other,
108		})?;
109		Ok(grant)
110	}
111}
112
113/// The task behind a lease: re-checks on cadence and reports the end.
114struct Driver {
115	client: Client,
116	request: Request,
117	producer: Option<lease::Producer>,
118	started: Instant,
119	expires: Option<tokio::time::Instant>,
120}
121
122impl Driver {
123	async fn run(mut self, grant: Grant) {
124		let (reason, bytes) = self.drive(grant).await;
125
126		let mut request = self.request.clone();
127		request.event = Event::End {
128			reason,
129			duration: self.started.elapsed(),
130			bytes,
131		};
132		// The session is already gone; nothing to do with a failure but say so.
133		if let Err(err) = self.client.post_end(&request).await {
134			tracing::warn!(id = %request.id, %err, "failed to report the session end");
135		}
136	}
137
138	/// Re-check until the lease ends, returning why it did and the totals the session reported.
139	async fn drive(&mut self, mut grant: Grant) -> (Reason, Bytes) {
140		let producer = self
141			.producer
142			.take()
143			.expect("the driver owns the producer until it ends");
144		let mut failures = 0u32;
145		let mut next = grant.revalidate.map(|cadence| tokio::time::Instant::now() + cadence);
146		// The re-check in flight, kept out of the select so expiry and the session's
147		// close are still polled while a stalled server holds the reply.
148		let mut inflight: Option<Pin<Box<dyn Future<Output = crate::Result<Grant>> + Send>>> = None;
149		// A nudge while a re-check is in flight: POST once more when the reply lands,
150		// so a ban set after this request left is not missed until the next cadence.
151		let mut pending = false;
152
153		loop {
154			let revalidate = async {
155				match next {
156					Some(at) => tokio::time::sleep_until(at).await,
157					None => std::future::pending().await,
158				}
159			};
160			let expire = async {
161				match self.expires {
162					Some(at) => tokio::time::sleep_until(at).await,
163					None => std::future::pending().await,
164				}
165			};
166			let reply = async {
167				match inflight.as_mut() {
168					Some(request) => request.await,
169					None => std::future::pending().await,
170				}
171			};
172
173			tokio::select! {
174				ended = producer.closed() => return ended,
175				() = expire => return producer.finish(Reason::Expired, Bytes::default()),
176				result = reply => {
177					inflight = None;
178					match result {
179						Ok(fresh) => {
180							failures = 0;
181							self.expires = fresh.deadline();
182							next = fresh.revalidate.map(|cadence| tokio::time::Instant::now() + cadence);
183							producer.update(fresh.clone());
184							grant = fresh;
185						}
186						Err(Error::Refused) => return producer.finish(Reason::Refused, Bytes::default()),
187						Err(Error::GrantExpired | Error::UnboundedRevalidate | Error::ZeroRevalidate) => {
188							return producer.finish(Reason::Invalid, Bytes::default());
189						}
190						Err(err) => {
191							// Evidence of nothing: the grant stands until `expires`.
192							failures += 1;
193							let delay = backoff(failures, grant.revalidate.unwrap_or(BACKOFF_MAX));
194							tracing::warn!(id = %self.request.id, %err, ?delay, "auth revalidation failed; retrying");
195							next = Some(tokio::time::Instant::now() + delay);
196						}
197					}
198					if pending {
199						pending = false;
200						next = None;
201						inflight = Some(self.post_revalidate());
202					}
203				}
204				() = revalidate => {
205					// One re-check at a time; the reply schedules the next.
206					next = None;
207					inflight = Some(self.post_revalidate());
208				}
209				() = producer.revalidate_requested() => {
210					if inflight.is_some() {
211						pending = true;
212					} else {
213						next = None;
214						inflight = Some(self.post_revalidate());
215					}
216				}
217			}
218		}
219	}
220
221	fn post_revalidate(&self) -> Pin<Box<dyn Future<Output = crate::Result<Grant>> + Send>> {
222		let client = self.client.clone();
223		let mut request = self.request.clone();
224		request.event = Event::Revalidate;
225		Box::pin(async move { client.post(&request).await })
226	}
227}
228
229impl Client {
230	async fn post_end(&self, request: &Request) -> crate::Result<()> {
231		self.http
232			.post(self.url.clone())
233			.json(request)
234			.send()
235			.await?
236			.error_for_status()?;
237		Ok(())
238	}
239}
240
241/// Exponential backoff from one second, capped by the cadence and [`BACKOFF_MAX`],
242/// jittered by up to a quarter so a fleet does not retry in lockstep.
243fn backoff(failures: u32, cadence: Duration) -> Duration {
244	use rand::RngExt;
245	let base = Duration::from_secs(1) * 2u32.saturating_pow(failures.saturating_sub(1).min(16));
246	let base = base.min(cadence).min(BACKOFF_MAX);
247	let jitter = rand::rng().random_range(0.75..=1.25);
248	base.mul_f64(jitter)
249}
250
251#[cfg(test)]
252mod tests {
253	use super::*;
254	use moq_pattern::Patterns;
255	use std::task::Poll;
256	use std::time::SystemTime;
257	use wiremock::matchers::{method, path};
258	use wiremock::{Mock, MockServer, Request as Received, ResponseTemplate};
259
260	fn patterns(texts: &[&str]) -> Patterns {
261		texts.iter().map(|text| text.parse().unwrap()).collect()
262	}
263
264	fn request() -> Request {
265		let mut request = Request::new("relay-1", crate::Transport::Quic, "/demo/room");
266		request.id = "0123".into();
267		request
268	}
269
270	/// Records every request body the server saw, in order.
271	#[derive(Clone, Default)]
272	struct Log(kio::Shared<Vec<Request>>);
273
274	impl Log {
275		fn events(&self) -> Vec<Event> {
276			self.0.read().iter().map(|r| r.event.clone()).collect()
277		}
278
279		fn revalidates(&self) -> usize {
280			self.events()
281				.iter()
282				.filter(|event| **event == Event::Revalidate)
283				.count()
284		}
285
286		/// Wait until the requests seen so far satisfy `done`.
287		async fn until(&self, mut done: impl FnMut(&[Request]) -> bool + Unpin) {
288			self.0
289				.wait(|log| if done(log) { Poll::Ready(()) } else { Poll::Pending })
290				.await;
291		}
292
293		/// Wait for the background `end` POST and return it.
294		async fn end(&self) -> Request {
295			let is_end = |r: &Request| matches!(r.event, Event::End { .. });
296			self.until(|log| log.iter().any(is_end)).await;
297			self.0.read().iter().find(|r| is_end(r)).cloned().unwrap()
298		}
299	}
300
301	impl wiremock::Match for Log {
302		fn matches(&self, received: &Received) -> bool {
303			self.0.lock().push(received.body_json().unwrap());
304			true
305		}
306	}
307
308	async fn server(log: Log, respond: impl Fn(&Request) -> ResponseTemplate + Send + Sync + 'static) -> MockServer {
309		let server = MockServer::start().await;
310		Mock::given(method("POST"))
311			.and(path("/"))
312			.and(log)
313			.respond_with(move |received: &Received| respond(&received.body_json().unwrap()))
314			.mount(&server)
315			.await;
316		server
317	}
318
319	/// Keep HTTP handling on the paused test clock instead of wiremock's separate runtime.
320	async fn clock_server(log: Log, grant: Grant, stall: bool) -> Client {
321		use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post};
322		let router = Router::new()
323			.route(
324				"/",
325				post(
326					|State((log, grant, stall)): State<(Log, Grant, bool)>, Json(request): Json<Request>| async move {
327						let event = request.event.clone();
328						log.0.lock().push(request);
329						match event {
330							Event::Connect => Json(grant).into_response(),
331							Event::Revalidate if stall => std::future::pending().await,
332							Event::Revalidate => StatusCode::SERVICE_UNAVAILABLE.into_response(),
333							Event::End { .. } => StatusCode::OK.into_response(),
334						}
335					},
336				),
337			)
338			.with_state((log, grant, stall));
339		let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
340		let url = format!("http://{}/", listener.local_addr().unwrap());
341		tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
342		// Only the lease clock is under test; an HTTP timeout can auto-advance
343		// Tokio's paused clock before loopback I/O gets its first reactor turn.
344		Client {
345			http: reqwest::Client::builder().no_proxy().build().unwrap(),
346			url: url.parse().unwrap(),
347		}
348	}
349
350	fn client(server: &MockServer) -> Client {
351		Client::new(server.uri().parse().unwrap(), None).unwrap()
352	}
353
354	/// The wire carries whole seconds, so a cadence under one second would serialize
355	/// as zero and be refused; these tests run on a one-second cadence.
356	fn grant(expires_in: Option<Duration>, revalidate: Option<Duration>) -> Grant {
357		let mut grant = Grant::new(patterns(&["**"]), Patterns::new());
358		grant.expires = expires_in.map(|d| SystemTime::now() + d);
359		grant.revalidate = revalidate;
360		grant
361	}
362
363	#[tokio::test]
364	async fn connect_admits_and_end_follows_the_close() {
365		let log = Log::default();
366		let server = server(log.clone(), |_| {
367			ResponseTemplate::new(200).set_body_json(grant(None, None))
368		})
369		.await;
370
371		let consumer = client(&server).connect(request()).await.unwrap();
372		assert_eq!(consumer.grant().publish, patterns(&["**"]));
373		assert_eq!(log.events(), [Event::Connect]);
374
375		consumer.close("disconnected", Bytes { sent: 7, received: 11 });
376
377		let end = log.end().await;
378		assert_eq!(end.id, "0123");
379		match end.event {
380			Event::End { reason, bytes, .. } => {
381				assert_eq!(reason, Reason::Session("disconnected".into()));
382				assert_eq!(bytes, Bytes { sent: 7, received: 11 });
383			}
384			other => panic!("expected an end, got {other:?}"),
385		}
386	}
387
388	#[tokio::test]
389	async fn a_bare_drop_ends_as_dropped() {
390		let log = Log::default();
391		let server = server(log.clone(), |_| {
392			ResponseTemplate::new(200).set_body_json(grant(None, None))
393		})
394		.await;
395
396		let consumer = client(&server).connect(request()).await.unwrap();
397		drop(consumer);
398
399		match log.end().await.event {
400			Event::End {
401				reason: Reason::Dropped,
402				bytes,
403				..
404			} => assert_eq!(bytes, Bytes::default()),
405			other => panic!("expected a dropped end, got {other:?}"),
406		}
407	}
408
409	#[tokio::test]
410	async fn refusals_and_outages_refuse_at_connect() {
411		for status in [401, 403, 400, 404, 408, 429, 500, 503] {
412			let server = server(Log::default(), move |_| ResponseTemplate::new(status)).await;
413			let err = client(&server).connect(request()).await.unwrap_err();
414			match status {
415				401 | 403 => assert!(matches!(err, Error::Refused), "{status}: {err}"),
416				_ => assert!(matches!(err, Error::Unavailable(_)), "{status}: {err}"),
417			}
418		}
419
420		let garbage = server(Log::default(), |_| ResponseTemplate::new(200).set_body_string("nope")).await;
421		let err = client(&garbage).connect(request()).await.unwrap_err();
422		assert!(matches!(err, Error::Unavailable(_)), "{err}");
423
424		let nothing = server(Log::default(), |_| {
425			ResponseTemplate::new(200).set_body_json(Grant::default())
426		})
427		.await;
428		let err = client(&nothing).connect(request()).await.unwrap_err();
429		assert!(matches!(err, Error::Refused), "{err}");
430	}
431
432	#[tokio::test]
433	async fn revalidate_runs_on_cadence_and_applies_the_reply() {
434		let log = Log::default();
435		let server = server(log.clone(), |request| {
436			let mut grant = grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)));
437			if request.event == Event::Revalidate {
438				grant.tier = Some("moved".into());
439			}
440			ResponseTemplate::new(200).set_body_json(grant)
441		})
442		.await;
443
444		let mut consumer = client(&server).connect(request()).await.unwrap();
445		let fresh = tokio::time::timeout(Duration::from_secs(3), consumer.changed())
446			.await
447			.expect("a re-check within the cadence")
448			.unwrap();
449		assert_eq!(fresh.tier.as_deref(), Some("moved"));
450		assert_eq!(log.events()[..2], [Event::Connect, Event::Revalidate]);
451	}
452
453	#[tokio::test]
454	async fn a_refusal_on_recheck_revokes() {
455		for answer in [
456			ResponseTemplate::new(401),
457			ResponseTemplate::new(403),
458			ResponseTemplate::new(200).set_body_json(Grant::default()),
459		] {
460			let server = server(Log::default(), {
461				let answer = answer.clone();
462				move |request| match request.event {
463					Event::Connect => ResponseTemplate::new(200)
464						.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)))),
465					_ => answer.clone(),
466				}
467			})
468			.await;
469
470			let consumer = client(&server).connect(request()).await.unwrap();
471			let reason = tokio::time::timeout(Duration::from_secs(3), consumer.closed())
472				.await
473				.expect("revoked within the cadence");
474			assert_eq!(reason, Reason::Refused);
475		}
476	}
477
478	#[tokio::test]
479	async fn an_invalid_grant_on_recheck_revokes() {
480		let unbounded = grant(None, Some(Duration::from_secs(1)));
481		let server = server(Log::default(), move |request| match request.event {
482			Event::Connect => ResponseTemplate::new(200)
483				.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)))),
484			_ => ResponseTemplate::new(200).set_body_json(unbounded.clone()),
485		})
486		.await;
487
488		let consumer = client(&server).connect(request()).await.unwrap();
489		let reason = tokio::time::timeout(Duration::from_secs(3), consumer.closed())
490			.await
491			.expect("revoked within the cadence");
492		assert_eq!(reason, Reason::Invalid);
493	}
494
495	#[tokio::test]
496	async fn a_grant_within_clock_skew_stays_live() {
497		tokio::time::pause();
498		let mut grant = Grant::new(patterns(&["**"]), Patterns::new());
499		grant.expires = Some(SystemTime::now() - Duration::from_secs(1));
500		let client = clock_server(Log::default(), grant, false).await;
501		let consumer = client.connect(request()).await.unwrap();
502
503		tokio::time::sleep(Duration::from_millis(500)).await;
504		assert!(
505			tokio::time::timeout(Duration::from_millis(100), consumer.closed())
506				.await
507				.is_err(),
508			"still live inside the skew window"
509		);
510
511		let reason = tokio::time::timeout(crate::grant::CLOCK_SKEW + Duration::from_secs(1), consumer.closed())
512			.await
513			.expect("expired once the skew window ended");
514		assert_eq!(reason, Reason::Expired);
515	}
516
517	#[tokio::test]
518	async fn an_outage_keeps_the_grant_until_expires() {
519		tokio::time::pause();
520		let log = Log::default();
521		let client = clock_server(
522			log.clone(),
523			grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))),
524			false,
525		)
526		.await;
527		let consumer = client.connect(request()).await.unwrap();
528
529		tokio::time::sleep(Duration::from_millis(1500)).await;
530		assert!(log.revalidates() >= 1, "re-checks happened");
531		assert_eq!(
532			consumer.grant().publish,
533			patterns(&["**"]),
534			"the grant stands through the outage"
535		);
536
537		let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed())
538			.await
539			.expect("expired");
540		assert_eq!(reason, Reason::Expired);
541		assert!(matches!(
542			log.end().await.event,
543			Event::End {
544				reason: Reason::Expired,
545				..
546			}
547		));
548	}
549
550	#[tokio::test]
551	async fn expiry_fires_while_a_recheck_is_stalled() {
552		tokio::time::pause();
553		let client = clock_server(
554			Log::default(),
555			grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))),
556			true,
557		)
558		.await;
559		let consumer = client.connect(request()).await.unwrap();
560
561		let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed())
562			.await
563			.expect("expired while the re-check was in flight");
564		assert_eq!(reason, Reason::Expired);
565	}
566
567	#[tokio::test]
568	async fn a_close_is_reported_while_a_recheck_is_stalled() {
569		let log = Log::default();
570		let server = server(log.clone(), |request| match request.event {
571			Event::Connect => ResponseTemplate::new(200)
572				.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(1)))),
573			Event::Revalidate => ResponseTemplate::new(200)
574				.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(60))))
575				.set_delay(Duration::from_secs(30)),
576			Event::End { .. } => ResponseTemplate::new(200),
577		})
578		.await;
579
580		let consumer = client(&server).connect(request()).await.unwrap();
581		tokio::time::sleep(Duration::from_millis(1500)).await;
582		assert!(log.events().contains(&Event::Revalidate), "the re-check is in flight");
583		consumer.close("disconnected", Bytes::default());
584		assert!(matches!(
585			log.end().await.event,
586			Event::End {
587				reason: Reason::Session(_),
588				..
589			}
590		));
591	}
592
593	#[test]
594	fn url_schemes_are_checked_at_construction() {
595		assert!(Client::new("http://127.0.0.1:4440/".parse().unwrap(), None).is_ok());
596		assert!(Client::new("http://localhost:4440/".parse().unwrap(), None).is_ok());
597		assert!(Client::new("http://[::1]:4440/".parse().unwrap(), None).is_ok());
598		assert!(matches!(
599			Client::new("http://auth.example/".parse().unwrap(), None),
600			Err(Error::InsecureUrl(_))
601		));
602		assert!(Client::new("https://auth.example/".parse().unwrap(), None).is_ok());
603		assert!(matches!(
604			Client::new("ftp://auth.example/".parse().unwrap(), None),
605			Err(Error::InvalidUrl(_))
606		));
607		#[cfg(unix)]
608		assert!(Client::new("unix:///run/moq-auth.sock".parse().unwrap(), None).is_ok());
609	}
610
611	/// Backoff leaves the driver in this same state (nothing in flight, a timer armed),
612	/// so this covers a nudge during backoff too.
613	#[tokio::test]
614	async fn a_nudge_while_idle_posts_at_once() {
615		let log = Log::default();
616		let server = server(log.clone(), |_| {
617			ResponseTemplate::new(200)
618				.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600))))
619		})
620		.await;
621
622		let consumer = client(&server).connect(request()).await.unwrap();
623		assert_eq!(log.events(), [Event::Connect]);
624		consumer.revalidate();
625		tokio::time::timeout(
626			Duration::from_secs(2),
627			log.until(|log| log.iter().any(|r| r.event == Event::Revalidate)),
628		)
629		.await
630		.expect("a nudge while idle POSTs at once");
631	}
632
633	#[tokio::test]
634	async fn a_nudge_during_inflight_posts_once_more_when_the_reply_lands() {
635		let log = Log::default();
636		let server = server(log.clone(), |request| match request.event {
637			Event::Connect => ResponseTemplate::new(200)
638				.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600)))),
639			Event::Revalidate => ResponseTemplate::new(200)
640				.set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(3600))))
641				.set_delay(Duration::from_millis(400)),
642			Event::End { .. } => ResponseTemplate::new(200),
643		})
644		.await;
645
646		let consumer = client(&server).connect(request()).await.unwrap();
647		consumer.revalidate();
648		tokio::time::timeout(
649			Duration::from_secs(2),
650			log.until(|log| log.iter().any(|r| r.event == Event::Revalidate)),
651		)
652		.await
653		.expect("the first re-check is in flight");
654
655		consumer.revalidate();
656		consumer.revalidate();
657		// `changed` jumps to the latest grant epoch, so two replies can arrive as one
658		// observation. The request log does not coalesce.
659		tokio::time::timeout(
660			Duration::from_secs(3),
661			log.until(|log| log.iter().filter(|r| r.event == Event::Revalidate).count() >= 2),
662		)
663		.await
664		.expect("the in-flight nudge POSTs once more when the reply lands");
665		consumer.close("disconnected", Bytes::default());
666		log.end().await;
667		assert_eq!(
668			log.revalidates(),
669			2,
670			"a burst during an in-flight re-check is one extra POST"
671		);
672	}
673
674	#[test]
675	fn backoff_grows_and_stays_bounded() {
676		let cadence = Duration::from_secs(30);
677		let first = backoff(1, cadence);
678		assert!(
679			first >= Duration::from_millis(750) && first <= Duration::from_millis(1250),
680			"{first:?}"
681		);
682		let later = backoff(10, cadence);
683		assert!(later <= cadence.mul_f64(1.25), "{later:?}");
684		assert!(backoff(40, Duration::from_secs(3600)) <= BACKOFF_MAX.mul_f64(1.25));
685	}
686}