faucet_core/resilience/
policy.rs1use crate::error::FaucetError;
4use crate::resilience::classify::{RetryClassSet, classify};
5use std::time::Duration;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum BackoffKind {
10 None,
12 Fixed,
14 #[default]
16 Exponential,
17}
18
19impl BackoffKind {
20 pub fn delay(self, base: Duration, max: Duration, attempt: u32) -> Duration {
22 match self {
23 BackoffKind::None => Duration::ZERO,
24 BackoffKind::Fixed => base.min(max),
25 BackoffKind::Exponential => base.saturating_mul(2u32.saturating_pow(attempt)).min(max),
26 }
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct RetryPolicy {
33 pub max_attempts: u32,
35 pub backoff: BackoffKind,
37 pub base: Duration,
39 pub max: Duration,
41 pub jitter: bool,
43 pub retry_on: RetryClassSet,
45}
46
47impl Default for RetryPolicy {
48 fn default() -> Self {
49 Self {
50 max_attempts: 5,
51 backoff: BackoffKind::Exponential,
52 base: Duration::from_millis(200),
53 max: Duration::from_secs(30),
54 jitter: true,
55 retry_on: RetryClassSet::default(),
56 }
57 }
58}
59
60impl RetryPolicy {
61 pub fn is_retriable(&self, err: &FaucetError) -> bool {
64 classify(err).is_some_and(|c| self.retry_on.contains(c))
65 }
66}
67
68#[derive(Debug, Clone, Copy)]
70pub struct CircuitBreakerConfig {
71 pub consecutive_failures: u32,
73 pub cooldown: Duration,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum PoisonAction {
80 #[default]
82 Dlq,
83 Drop,
85 Fail,
87}
88
89#[derive(Debug, Clone, Copy)]
91pub struct PoisonPolicy {
92 pub max_row_attempts: u32,
94 pub action: PoisonAction,
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct ResiliencePolicy {
101 pub retry: RetryPolicy,
103 pub circuit_breaker: Option<CircuitBreakerConfig>,
105 pub poison: Option<PoisonPolicy>,
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::resilience::classify::RetryClass;
113
114 #[test]
115 fn default_policy_retries_all_classes() {
116 let p = RetryPolicy::default();
117 assert_eq!(p.max_attempts, 5);
118 assert!(p.is_retriable(&FaucetError::HttpStatus {
119 status: 500,
120 url: "u".into(),
121 body: "".into()
122 }));
123 assert!(!p.is_retriable(&FaucetError::Auth("x".into())));
124 }
125
126 #[test]
127 fn restricted_retry_on_excludes_unlisted_classes() {
128 let p = RetryPolicy {
129 retry_on: RetryClassSet::from_iter([RetryClass::Http5xx]),
130 ..RetryPolicy::default()
131 };
132 assert!(p.is_retriable(&FaucetError::HttpStatus {
133 status: 500,
134 url: "u".into(),
135 body: "".into()
136 }));
137 assert!(!p.is_retriable(&FaucetError::HttpStatus {
139 status: 429,
140 url: "u".into(),
141 body: "".into()
142 }));
143 }
144
145 #[test]
146 fn backoff_none_is_zero_fixed_is_constant_exp_grows() {
147 let base = Duration::from_millis(100);
148 let max = Duration::from_secs(10);
149 assert_eq!(BackoffKind::None.delay(base, max, 0), Duration::ZERO);
150 assert_eq!(BackoffKind::Fixed.delay(base, max, 3), base);
151 assert_eq!(
153 BackoffKind::Fixed.delay(Duration::from_secs(20), Duration::from_secs(10), 0),
154 Duration::from_secs(10)
155 );
156 assert_eq!(BackoffKind::Exponential.delay(base, max, 0), base);
157 assert_eq!(BackoffKind::Exponential.delay(base, max, 2), base * 4);
158 assert_eq!(BackoffKind::Exponential.delay(base, max, 30), max);
160 }
161}