From 74e910986c40ef034e2c34f5e8b98420118b00cf Mon Sep 17 00:00:00 2001 From: Anthony Date: Tue, 16 Jun 2026 16:10:13 +0200 Subject: [PATCH] feat(net): honor server retry-after fully, cap only our own backoff A server-provided retry-after / suspension-time was clamped to 60s, so a longer server-requested cooldown was ignored (impolite, and the server may penalize a client that does not honor it). Honor the server's value up to a 300s safety ceiling; the tighter 60s cap now applies only to our own header-less escalating backoff. --- crates/bridge/src/governed_client.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/bridge/src/governed_client.rs b/crates/bridge/src/governed_client.rs index c722a58..39ab554 100644 --- a/crates/bridge/src/governed_client.rs +++ b/crates/bridge/src/governed_client.rs @@ -37,9 +37,11 @@ const MAX_IN_FLIGHT: usize = 4; /// First step of the default backoff, used when the server throttles without /// telling us how long to wait. const DEFAULT_BACKOFF_BASE: Duration = Duration::from_secs(2); -/// Ceiling for any single backoff (server-provided or default), so one throttle -/// can never stall the bridge for minutes. -const MAX_BACKOFF: Duration = Duration::from_secs(60); +/// Ceiling for our own escalating backoff when the server gives no hint. +const MAX_DEFAULT_BACKOFF: Duration = Duration::from_secs(60); +/// Ceiling for a server-requested wait. We honor the server's retry-after, but +/// cap it so a pathological header cannot stall the bridge indefinitely. +const MAX_SERVER_BACKOFF: Duration = Duration::from_secs(300); /// Response headers Tuta uses to advertise a throttle duration, in seconds. /// Header names reach us lowercased (see [`RestResponse`]). @@ -102,12 +104,17 @@ impl GovernedRestClient { /// escalating default (2s, 4s, 8s, ...) capped at [`MAX_BACKOFF`]. fn backoff_for(&self, server_secs: Option) -> Duration { if let Some(secs) = server_secs { + // Trust the server's requested wait (floored, capped only to avoid a + // pathological stall). self.consecutive_throttles.store(0, Ordering::Relaxed); - return Duration::from_secs(secs).clamp(DEFAULT_BACKOFF_BASE, MAX_BACKOFF); + return Duration::from_secs(secs).clamp(DEFAULT_BACKOFF_BASE, MAX_SERVER_BACKOFF); } + // No hint from the server: escalate our own guess, capped tighter. let n = self.consecutive_throttles.fetch_add(1, Ordering::Relaxed); let factor = 1u32 << n.min(5); // 1, 2, 4, 8, 16, 32 - DEFAULT_BACKOFF_BASE.saturating_mul(factor).min(MAX_BACKOFF) + DEFAULT_BACKOFF_BASE + .saturating_mul(factor) + .min(MAX_DEFAULT_BACKOFF) } fn note_success(&self) { @@ -250,7 +257,7 @@ mod tests { })); // Below the floor clamps up to the base; above the ceiling clamps down. assert_eq!(g.backoff_for(Some(1)), DEFAULT_BACKOFF_BASE); - assert_eq!(g.backoff_for(Some(9999)), MAX_BACKOFF); + assert_eq!(g.backoff_for(Some(9999)), MAX_SERVER_BACKOFF); assert_eq!(g.backoff_for(Some(10)), Duration::from_secs(10)); } @@ -266,8 +273,8 @@ mod tests { assert_eq!(g.backoff_for(None), Duration::from_secs(8)); assert_eq!(g.backoff_for(None), Duration::from_secs(16)); assert_eq!(g.backoff_for(None), Duration::from_secs(32)); - assert_eq!(g.backoff_for(None), MAX_BACKOFF); // 64 -> capped at 60 - assert_eq!(g.backoff_for(None), MAX_BACKOFF); + assert_eq!(g.backoff_for(None), MAX_DEFAULT_BACKOFF); // 64 -> capped at 60 + assert_eq!(g.backoff_for(None), MAX_DEFAULT_BACKOFF); } #[test]