open-standards/openpay/crates/op-webhook/src/retry.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

385 lines
13 KiB
Rust

//! Retry scheduling.
//!
//! Exponential backoff with **full jitter** per AWS's "Exponential
//! Backoff and Jitter" guidance (2015) and current Stripe/GitHub
//! practice. The policy is fully deterministic given an injected
//! RNG, which makes tests reproducible.
//!
//! ## Why full jitter and not "equal jitter" or "decorrelated"?
//!
//! Three options are commonly cited:
//!
//! - **Full jitter**: `sleep = rand(0, base * 2^n)`. Maximum
//! spread; best at avoiding thundering herd.
//! - **Equal jitter**: `sleep = base * 2^(n-1) + rand(0, base * 2^(n-1))`.
//! Guarantees some minimum delay; smaller spread.
//! - **Decorrelated jitter**: `sleep = rand(base, prev * 3)`.
//! Couples to previous sleep; useful for adaptive systems.
//!
//! For webhook delivery, full jitter is the AWS-recommended default
//! and matches what Stripe/GitHub appear to do. The OpenPay
//! reference implementation uses full jitter; operators can plug
//! their own [`RetryPolicy`] implementation if they need a
//! different strategy.
//!
//! ## What counts as a retry?
//!
//! Only transient failures: timeouts, transport errors, 5xx HTTP
//! responses, 429s. Permanent failures (signature configured wrong,
//! 4xx other than 408/425/429) should NOT be retried; they're
//! escalated to the dead-letter state immediately. The
//! [`RetryPolicy::should_retry`] method classifies these.
use std::sync::Mutex;
/// Trait an operator can implement to plug in a custom retry
/// strategy. The reference impl is [`ExponentialBackoffPolicy`].
pub trait RetryPolicy: Send + Sync {
/// Should an HTTP status code or transport error trigger a
/// retry?
///
/// - `Some(status)`: HTTP response received. 5xx, 408, 425, 429
/// → retry. Anything else → no retry.
/// - `None`: transport-level failure (network, timeout). Always
/// retry.
fn should_retry(&self, http_status: Option<u16>) -> bool;
/// Delay in seconds before the Nth attempt (0-indexed).
///
/// Caller passes the retry attempt number and the number of
/// elapsed seconds since the original event was created. The
/// policy returns `None` if the retry budget is exhausted (no
/// further attempts).
fn next_delay_secs(&self, attempt_number: u32, elapsed_secs: u64) -> Option<u64>;
/// After how many *consecutive* delivery failures (across all
/// events for one endpoint) the endpoint is auto-disabled.
fn disable_after_consecutive_failures(&self) -> u32;
}
/// Exponential-backoff-with-full-jitter retry policy.
///
/// Defaults match industry practice: base 1s, cap 1h, total window
/// 72h (Stripe), auto-disable at 10 consecutive failures.
pub struct ExponentialBackoffPolicy {
/// Base delay in seconds. The unjittered Nth delay is
/// `base_secs * 2^attempt_number`.
base_secs: u64,
/// Maximum single delay in seconds (caps the exponential
/// growth).
max_delay_secs: u64,
/// Total time budget in seconds: if `elapsed > max_age_secs`,
/// `next_delay_secs` returns `None`.
max_age_secs: u64,
/// Auto-disable threshold.
disable_after: u32,
/// Pluggable RNG so tests can be deterministic. Wrapped in a
/// Mutex so the policy is `Send + Sync`.
rng: Mutex<Box<dyn JitterRng>>,
}
/// A trait for the jitter RNG. Tests inject `FixedJitter(x)` so
/// timings are deterministic; production uses [`SystemJitter`]
/// which calls `std::time` for entropy (good enough for jitter —
/// we're not generating crypto keys).
pub trait JitterRng: Send + Sync {
/// Return a value uniformly distributed in `[0, upper)`.
/// `upper > 0` always.
fn uniform(&mut self, upper: u64) -> u64;
}
/// Production jitter source. Uses `SystemTime`'s nanoseconds as a
/// linear-congruential-style mixer. **Not cryptographically
/// random**, but webhook jitter doesn't need it.
pub struct SystemJitter {
state: u64,
}
impl SystemJitter {
/// Construct, seeding from the current time.
#[must_use]
pub fn new() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0xC0FFEE_DEADBEEF);
Self { state: nanos | 1 } // never zero (LCG hates 0)
}
}
impl Default for SystemJitter {
fn default() -> Self {
Self::new()
}
}
impl JitterRng for SystemJitter {
fn uniform(&mut self, upper: u64) -> u64 {
// Numerical Recipes LCG constants.
self.state = self
.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
if upper == 0 { 0 } else { self.state % upper }
}
}
/// Fully deterministic jitter source for tests. Returns `value` (or
/// `upper - 1` if `value >= upper`) every call.
pub struct FixedJitter(pub u64);
impl JitterRng for FixedJitter {
fn uniform(&mut self, upper: u64) -> u64 {
if upper == 0 {
0
} else if self.0 >= upper {
upper - 1
} else {
self.0
}
}
}
impl ExponentialBackoffPolicy {
/// Construct with custom parameters.
///
/// Defaults are returned by [`Self::stripe_like`].
#[must_use]
pub fn new(
base_secs: u64,
max_delay_secs: u64,
max_age_secs: u64,
disable_after: u32,
rng: Box<dyn JitterRng>,
) -> Self {
Self {
base_secs: base_secs.max(1),
max_delay_secs: max_delay_secs.max(1),
max_age_secs,
disable_after: disable_after.max(1),
rng: Mutex::new(rng),
}
}
/// Stripe-like defaults: base 1s, cap 1h, total 72h, disable
/// after 10 consecutive failures. Uses [`SystemJitter`].
#[must_use]
pub fn stripe_like() -> Self {
Self::new(1, 3600, 72 * 3600, 10, Box::new(SystemJitter::new()))
}
/// Construct with a [`FixedJitter`] for deterministic tests.
#[must_use]
pub fn deterministic(
base_secs: u64,
max_delay_secs: u64,
max_age_secs: u64,
disable_after: u32,
fixed_jitter: u64,
) -> Self {
Self::new(
base_secs,
max_delay_secs,
max_age_secs,
disable_after,
Box::new(FixedJitter(fixed_jitter)),
)
}
}
impl RetryPolicy for ExponentialBackoffPolicy {
fn should_retry(&self, http_status: Option<u16>) -> bool {
match http_status {
// Transport-level failure (timeout, DNS, etc.).
None => true,
// 5xx, 408 (timeout), 425 (too early), 429 (too many
// requests) → retry.
Some(s) if (500..600).contains(&s) => true,
Some(408) | Some(425) | Some(429) => true,
// 2xx, 3xx, other 4xx → no retry.
Some(_) => false,
}
}
fn next_delay_secs(&self, attempt_number: u32, elapsed_secs: u64) -> Option<u64> {
if elapsed_secs >= self.max_age_secs {
return None;
}
// Exponential: base * 2^attempt, capped.
// Saturate the exponent to avoid overflow at e.g. 2^64.
let exponent = attempt_number.min(62);
let computed = self
.base_secs
.saturating_mul(1u64 << exponent)
.min(self.max_delay_secs);
// Full jitter: pick uniformly in [0, computed].
let upper = computed.saturating_add(1);
let jittered = self
.rng
.lock()
.expect("poisoned jitter mutex")
.uniform(upper);
Some(jittered)
}
fn disable_after_consecutive_failures(&self) -> u32 {
self.disable_after
}
}
/// Convenience function for callers that want the full-jitter math
/// without constructing a policy: given a base, cap, and attempt,
/// returns `[0, min(base*2^n, cap)]` with `rng_value` modulating.
#[must_use]
pub fn jitter_full(base_secs: u64, max_delay_secs: u64, attempt: u32, rng_value: u64) -> u64 {
let exponent = attempt.min(62);
let computed = base_secs
.saturating_mul(1u64 << exponent)
.min(max_delay_secs);
let upper = computed.saturating_add(1);
if upper == 0 { 0 } else { rng_value % upper }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_retry_transport_failure() {
let p = ExponentialBackoffPolicy::stripe_like();
assert!(p.should_retry(None));
}
#[test]
fn should_retry_5xx() {
let p = ExponentialBackoffPolicy::stripe_like();
assert!(p.should_retry(Some(500)));
assert!(p.should_retry(Some(502)));
assert!(p.should_retry(Some(503)));
assert!(p.should_retry(Some(599)));
}
#[test]
fn should_retry_specific_4xx() {
let p = ExponentialBackoffPolicy::stripe_like();
assert!(p.should_retry(Some(408)));
assert!(p.should_retry(Some(425)));
assert!(p.should_retry(Some(429)));
}
#[test]
fn should_not_retry_2xx() {
// Success itself doesn't trigger a retry — defensive check;
// dispatcher only calls should_retry on non-success.
let p = ExponentialBackoffPolicy::stripe_like();
assert!(!p.should_retry(Some(200)));
assert!(!p.should_retry(Some(201)));
}
#[test]
fn should_not_retry_most_4xx() {
let p = ExponentialBackoffPolicy::stripe_like();
assert!(!p.should_retry(Some(400)));
assert!(!p.should_retry(Some(401)));
assert!(!p.should_retry(Some(403)));
assert!(!p.should_retry(Some(404)));
assert!(!p.should_retry(Some(410)));
assert!(!p.should_retry(Some(422)));
}
#[test]
fn next_delay_is_zero_when_jitter_is_zero() {
let p = ExponentialBackoffPolicy::deterministic(1, 3600, 72 * 3600, 10, 0);
// Full jitter picks in [0, base*2^n]. With FixedJitter(0) =>
// always 0.
assert_eq!(p.next_delay_secs(0, 0).unwrap(), 0);
assert_eq!(p.next_delay_secs(5, 0).unwrap(), 0);
}
#[test]
fn next_delay_caps_at_max_delay() {
// base=1, max=10, attempt=20 → computed = min(1*2^20, 10) = 10.
// FixedJitter(50) clamps to upper-1 = 10.
let p = ExponentialBackoffPolicy::deterministic(1, 10, 72 * 3600, 10, 50);
let d = p.next_delay_secs(20, 0).unwrap();
assert!(d <= 10, "delay {d} exceeded cap");
}
#[test]
fn next_delay_returns_none_when_max_age_exceeded() {
let p = ExponentialBackoffPolicy::deterministic(1, 3600, 100, 10, 0);
assert!(p.next_delay_secs(0, 101).is_none());
assert!(p.next_delay_secs(0, 100).is_none());
}
#[test]
fn next_delay_still_returns_when_under_max_age() {
let p = ExponentialBackoffPolicy::deterministic(1, 3600, 100, 10, 0);
assert!(p.next_delay_secs(0, 99).is_some());
}
#[test]
fn disable_after_threshold_returned() {
let p = ExponentialBackoffPolicy::deterministic(1, 3600, 72 * 3600, 7, 0);
assert_eq!(p.disable_after_consecutive_failures(), 7);
}
#[test]
fn jitter_full_at_zero_attempt_with_zero_rng() {
// attempt 0 → computed = min(base*1, cap). rng_value=0 → 0.
assert_eq!(jitter_full(2, 100, 0, 0), 0);
}
#[test]
fn jitter_full_modulates_within_bounds() {
// attempt 3, base 1, cap 100 → computed = min(8, 100) = 8.
// rng_value = 5 → 5 % 9 = 5.
assert_eq!(jitter_full(1, 100, 3, 5), 5);
}
#[test]
fn jitter_full_caps_at_max_delay() {
// attempt 30, base 1 → 2^30 ~ 1B, capped to 100.
let d = jitter_full(1, 100, 30, u64::MAX / 2);
assert!(d <= 100);
}
#[test]
fn system_jitter_produces_varying_output() {
// Smoke test only — confirm it doesn't lock at zero.
let mut j = SystemJitter::new();
let a = j.uniform(1000);
let b = j.uniform(1000);
let c = j.uniform(1000);
// Vanishingly unlikely all three are equal.
assert!(!(a == b && b == c));
}
#[test]
fn system_jitter_zero_upper_is_zero() {
let mut j = SystemJitter::new();
assert_eq!(j.uniform(0), 0);
}
#[test]
fn fixed_jitter_returns_value_clamped() {
let mut j = FixedJitter(50);
assert_eq!(j.uniform(100), 50);
// 50 >= 10 → clamp to 9.
assert_eq!(j.uniform(10), 9);
// 50 with upper=0 → 0.
assert_eq!(j.uniform(0), 0);
}
#[test]
fn new_clamps_zero_inputs_to_one() {
// Defensive: a misconfigured policy with base=0 would
// produce infinite immediate retries. The constructor
// bumps these to 1.
let p = ExponentialBackoffPolicy::deterministic(0, 0, 72 * 3600, 0, 0);
// base_secs and max_delay_secs both forced to 1.
// disable_after forced to 1.
assert_eq!(p.disable_after_consecutive_failures(), 1);
}
}