rovenue/transport/retry.rs
1use std::time::Duration;
2
3/// We never wait more than this on a server-driven Retry-After.
4/// Beyond this the client should fail open with RateLimited.
5pub const RETRY_AFTER_MAX: Duration = Duration::from_secs(30);
6
7/// What to do with an HTTP attempt's outcome.
8#[derive(Debug, PartialEq, Eq)]
9pub enum RetryDecision {
10 /// Treat as success (2xx/3xx).
11 Success,
12 /// Should be retried with normal exponential backoff.
13 Retryable,
14 /// Should be retried only after the specified duration (server-driven).
15 RetryAfter(Duration),
16 /// Do not retry — surface immediately.
17 Fatal,
18}
19
20/// `status = None` indicates a network-level failure (no response).
21pub fn classify(status: Option<u16>, retry_after: Option<Duration>) -> RetryDecision {
22 match status {
23 None => RetryDecision::Retryable,
24 Some(s) if (500..600).contains(&s) => RetryDecision::Retryable,
25 Some(429) => match retry_after {
26 Some(d) => RetryDecision::RetryAfter(d),
27 None => RetryDecision::Retryable,
28 },
29 // Every 4xx — 409 included — is an answer the caller must see. No
30 // route the SDK calls answers 409 for "already applied": replays are
31 // deduplicated downstream behind a 2xx. A 409 is a refusal (e.g.
32 // SUBSCRIBER_UNRESOLVABLE, the identify label-switch guard,
33 // STORE_ID_TAKEN), and reading one as delivered made durable queues
34 // delete events the server never accepted.
35 Some(s) if (400..500).contains(&s) => RetryDecision::Fatal,
36 Some(_) => RetryDecision::Success,
37 }
38}
39
40/// Compute backoff for attempt index (0-based). exp 1s→2s→4s…, jitter ±20%, cap 5min.
41pub fn backoff(attempt: u32, rng: &mut impl rand::RngCore) -> Duration {
42 use rand::Rng;
43 let base = (1u64 << attempt.min(8)).saturating_mul(1000);
44 let capped = base.min(5 * 60 * 1000);
45 let jitter: i64 = rng.gen_range(-((capped as i64) / 5)..=((capped as i64) / 5));
46 let total = (capped as i64 + jitter).max(0) as u64;
47 Duration::from_millis(total)
48}