Skip to main content

oxihuman_core/
http_retry_policy.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! HTTP retry policy with exponential backoff and jitter.
6
7/// Strategy for retry backoff.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum BackoffStrategy {
10    Fixed,
11    Exponential,
12    LinearJitter,
13}
14
15/// Configuration for a retry policy.
16#[derive(Clone, Debug)]
17pub struct HttpRetryPolicyConfig {
18    pub max_attempts: u32,
19    pub base_delay_ms: u64,
20    pub max_delay_ms: u64,
21    pub strategy: BackoffStrategy,
22    pub jitter_ms: u64,
23}
24
25impl Default for HttpRetryPolicyConfig {
26    fn default() -> Self {
27        Self {
28            max_attempts: 3,
29            base_delay_ms: 100,
30            max_delay_ms: 30_000,
31            strategy: BackoffStrategy::Exponential,
32            jitter_ms: 50,
33        }
34    }
35}
36
37/// Tracks retry state for a single operation.
38pub struct RetryState {
39    pub config: HttpRetryPolicyConfig,
40    pub attempt: u32,
41}
42
43/// Creates a new retry state from config.
44pub fn new_retry_state(config: HttpRetryPolicyConfig) -> RetryState {
45    RetryState { config, attempt: 0 }
46}
47
48/// Computes the delay in ms before the next retry attempt.
49pub fn delay_for_attempt(config: &HttpRetryPolicyConfig, attempt: u32) -> u64 {
50    let raw = match config.strategy {
51        BackoffStrategy::Fixed => config.base_delay_ms,
52        BackoffStrategy::Exponential => {
53            let shift = attempt.min(30);
54            let factor = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
55            config.base_delay_ms.saturating_mul(factor)
56        }
57        BackoffStrategy::LinearJitter => config.base_delay_ms.saturating_mul(attempt as u64 + 1),
58    };
59    raw.min(config.max_delay_ms)
60        .saturating_add(config.jitter_ms)
61}
62
63/// Returns true if another retry is possible.
64pub fn can_retry(state: &RetryState) -> bool {
65    state.attempt < state.config.max_attempts
66}
67
68/// Records that an attempt was made, returning the delay before the next retry.
69pub fn next_delay_ms(state: &mut RetryState) -> Option<u64> {
70    if !can_retry(state) {
71        return None;
72    }
73    let delay = delay_for_attempt(&state.config, state.attempt);
74    state.attempt += 1;
75    Some(delay)
76}
77
78/// Resets the retry state for a new operation.
79pub fn reset_retry(state: &mut RetryState) {
80    state.attempt = 0;
81}
82
83/// Returns the total remaining attempts.
84pub fn remaining_attempts(state: &RetryState) -> u32 {
85    state.config.max_attempts.saturating_sub(state.attempt)
86}
87
88impl RetryState {
89    /// Creates a new retry state with default config.
90    pub fn new(config: HttpRetryPolicyConfig) -> Self {
91        new_retry_state(config)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn make_state(max: u32, strategy: BackoffStrategy) -> RetryState {
100        new_retry_state(HttpRetryPolicyConfig {
101            max_attempts: max,
102            base_delay_ms: 100,
103            max_delay_ms: 10_000,
104            strategy,
105            jitter_ms: 0,
106        })
107    }
108
109    #[test]
110    fn test_can_retry_when_attempts_remain() {
111        let state = make_state(3, BackoffStrategy::Fixed);
112        assert!(can_retry(&state));
113    }
114
115    #[test]
116    fn test_cannot_retry_when_exhausted() {
117        let mut state = make_state(2, BackoffStrategy::Fixed);
118        next_delay_ms(&mut state);
119        next_delay_ms(&mut state);
120        assert!(!can_retry(&state));
121    }
122
123    #[test]
124    fn test_fixed_delay_constant() {
125        let cfg = HttpRetryPolicyConfig {
126            strategy: BackoffStrategy::Fixed,
127            jitter_ms: 0,
128            ..Default::default()
129        };
130        let d0 = delay_for_attempt(&cfg, 0);
131        let d1 = delay_for_attempt(&cfg, 1);
132        assert_eq!(d0, d1);
133    }
134
135    #[test]
136    fn test_exponential_delay_grows() {
137        let cfg = HttpRetryPolicyConfig {
138            strategy: BackoffStrategy::Exponential,
139            jitter_ms: 0,
140            ..Default::default()
141        };
142        let d0 = delay_for_attempt(&cfg, 0);
143        let d1 = delay_for_attempt(&cfg, 1);
144        assert!(d1 > d0);
145    }
146
147    #[test]
148    fn test_delay_capped_at_max() {
149        let cfg = HttpRetryPolicyConfig {
150            max_delay_ms: 200,
151            base_delay_ms: 100,
152            strategy: BackoffStrategy::Exponential,
153            jitter_ms: 0,
154            max_attempts: 10,
155        };
156        let d = delay_for_attempt(&cfg, 20);
157        assert!(d <= 200);
158    }
159
160    #[test]
161    fn test_next_delay_ms_returns_none_when_exhausted() {
162        let mut state = make_state(1, BackoffStrategy::Fixed);
163        let _ = next_delay_ms(&mut state);
164        assert!(next_delay_ms(&mut state).is_none());
165    }
166
167    #[test]
168    fn test_reset_restores_full_budget() {
169        let mut state = make_state(3, BackoffStrategy::Fixed);
170        next_delay_ms(&mut state);
171        next_delay_ms(&mut state);
172        reset_retry(&mut state);
173        assert_eq!(remaining_attempts(&state), 3);
174    }
175
176    #[test]
177    fn test_remaining_attempts_decrements() {
178        let mut state = make_state(3, BackoffStrategy::Fixed);
179        assert_eq!(remaining_attempts(&state), 3);
180        next_delay_ms(&mut state);
181        assert_eq!(remaining_attempts(&state), 2);
182    }
183
184    #[test]
185    fn test_linear_jitter_grows_linearly() {
186        let cfg = HttpRetryPolicyConfig {
187            strategy: BackoffStrategy::LinearJitter,
188            jitter_ms: 0,
189            ..Default::default()
190        };
191        let d0 = delay_for_attempt(&cfg, 0);
192        let d2 = delay_for_attempt(&cfg, 2);
193        assert!(d2 > d0);
194    }
195}