1use std::time::{Duration, Instant};
8
9#[derive(Debug, Clone)]
15pub struct RetryConfig {
16 pub initial_delay_ms: u64,
18 pub max_delay_ms: u64,
20 pub multiplier: u64,
22 pub max_attempts: u32,
24 pub max_elapsed_secs: u64,
26 pub jitter: JitterKind,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum JitterKind {
33 None,
35 Half,
37 Full,
39}
40
41impl RetryConfig {
42 pub fn sqlite_busy() -> Self {
44 Self {
45 initial_delay_ms: 300,
46 max_delay_ms: 4800,
47 multiplier: 2,
48 max_attempts: 5,
49 max_elapsed_secs: 30,
50 jitter: JitterKind::Half,
51 }
52 }
53
54 pub fn llm_rate_limit() -> Self {
56 Self {
57 initial_delay_ms: 60_000,
58 max_delay_ms: 900_000,
59 multiplier: 2,
60 max_attempts: 20,
61 max_elapsed_secs: 3600,
62 jitter: JitterKind::Half,
63 }
64 }
65
66 pub fn cold_start() -> Self {
68 Self {
69 initial_delay_ms: 2000,
70 max_delay_ms: 4000,
71 multiplier: 2,
72 max_attempts: 2,
73 max_elapsed_secs: 30,
74 jitter: JitterKind::None,
75 }
76 }
77}
78
79pub fn compute_delay(config: &RetryConfig, attempt: u32) -> Duration {
88 let base = config
89 .initial_delay_ms
90 .saturating_mul(config.multiplier.saturating_pow(attempt))
91 .min(config.max_delay_ms);
92
93 let delay_ms = match config.jitter {
94 JitterKind::None => base,
95 JitterKind::Half => {
96 let half = base / 2;
97 if half == 0 {
98 base
99 } else {
100 half + fastrand::u64(0..half)
101 }
102 }
103 JitterKind::Full => {
104 if base == 0 {
105 0
106 } else {
107 fastrand::u64(0..base)
108 }
109 }
110 };
111
112 Duration::from_millis(delay_ms)
113}
114
115pub fn is_kill_switch_active() -> bool {
121 crate::config::get_setting("retry.disable")
122 .ok()
123 .flatten()
124 .is_some_and(|v| matches!(v.trim(), "1" | "true" | "yes"))
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[test]
132 fn compute_delay_half_jitter_in_bounds() {
133 let cfg = RetryConfig::llm_rate_limit();
134 for attempt in 0..5 {
135 for _ in 0..100 {
136 let d = compute_delay(&cfg, attempt);
137 let base = cfg
138 .initial_delay_ms
139 .saturating_mul(cfg.multiplier.saturating_pow(attempt))
140 .min(cfg.max_delay_ms);
141 let half = base / 2;
142 assert!(d.as_millis() >= half as u128);
143 assert!(d.as_millis() < base as u128);
144 }
145 }
146 }
147
148 #[test]
149 fn compute_delay_no_jitter_is_deterministic() {
150 let cfg = RetryConfig::cold_start();
151 let d1 = compute_delay(&cfg, 0);
152 let d2 = compute_delay(&cfg, 0);
153 assert_eq!(d1, d2);
154 assert_eq!(d1, Duration::from_millis(2000));
155 }
156
157 #[test]
158 fn kill_switch_inactive_by_default() {
159 std::env::remove_var("SQLITE_GRAPHRAG_DISABLE_RETRY");
160 assert!(!is_kill_switch_active());
161 }
162
163 #[test]
164 fn sqlite_busy_config_matches_constants() {
165 let cfg = RetryConfig::sqlite_busy();
166 assert_eq!(cfg.initial_delay_ms, 300);
167 assert_eq!(cfg.max_attempts, 5);
168 assert_eq!(cfg.max_elapsed_secs, 30);
169 }
170
171 #[test]
172 fn llm_rate_limit_has_deadline() {
173 let cfg = RetryConfig::llm_rate_limit();
174 assert_eq!(cfg.max_elapsed_secs, 3600);
175 assert_eq!(cfg.max_delay_ms, 900_000);
176 }
177
178 #[test]
179 fn full_jitter_stays_below_base() {
180 let cfg = RetryConfig {
181 initial_delay_ms: 1000,
182 max_delay_ms: 10_000,
183 multiplier: 2,
184 max_attempts: 5,
185 max_elapsed_secs: 60,
186 jitter: JitterKind::Full,
187 };
188 for attempt in 0..4 {
189 for _ in 0..100 {
190 let d = compute_delay(&cfg, attempt);
191 let base = cfg
192 .initial_delay_ms
193 .saturating_mul(cfg.multiplier.saturating_pow(attempt))
194 .min(cfg.max_delay_ms);
195 assert!(d.as_millis() < base as u128);
196 }
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum AttemptOutcome {
212 Transient,
215 HardFailure,
219 Success,
221}
222
223#[derive(Debug, Clone)]
235pub struct CircuitBreaker {
236 threshold: u32,
237 cooldown: Duration,
238 consecutive_failures: u32,
239 open_until: Option<Instant>,
240}
241
242impl CircuitBreaker {
243 pub fn new(threshold: u32, cooldown: Duration) -> Self {
246 Self {
247 threshold,
248 cooldown,
249 consecutive_failures: 0,
250 open_until: None,
251 }
252 }
253
254 pub fn record(&mut self, outcome: AttemptOutcome) -> bool {
259 match outcome {
260 AttemptOutcome::Success | AttemptOutcome::Transient => {
261 self.consecutive_failures = 0;
262 false
263 }
264 AttemptOutcome::HardFailure => {
265 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
266 if self.consecutive_failures >= self.threshold.max(1) {
267 self.open_until = Some(Instant::now() + self.cooldown);
268 tracing::error!(
269 target: "circuit_breaker",
270 consecutive_failures = self.consecutive_failures,
271 threshold = self.threshold,
272 cooldown_secs = self.cooldown.as_secs(),
273 "circuit breaker opened — aborting job"
274 );
275 true
276 } else {
277 false
278 }
279 }
280 }
281 }
282
283 pub fn is_open(&self) -> bool {
285 self.open_until
286 .map(|deadline| Instant::now() < deadline)
287 .unwrap_or(false)
288 }
289
290 pub fn reset(&mut self) {
292 self.consecutive_failures = 0;
293 self.open_until = None;
294 }
295
296 pub fn consecutive_failures(&self) -> u32 {
300 self.consecutive_failures
301 }
302}
303
304#[cfg(test)]
305mod circuit_breaker_tests {
306 use super::*;
307
308 #[test]
309 fn opens_after_threshold_consecutive_hard_failures() {
310 let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
311 assert!(!cb.record(AttemptOutcome::HardFailure));
312 assert!(!cb.record(AttemptOutcome::HardFailure));
313 assert!(cb.record(AttemptOutcome::HardFailure));
314 assert!(cb.is_open());
315 }
316
317 #[test]
318 fn ignores_transient_errors() {
319 let mut cb = CircuitBreaker::new(2, Duration::from_secs(60));
320 for _ in 0..10 {
322 assert!(!cb.record(AttemptOutcome::Transient));
323 }
324 assert!(!cb.is_open());
325 }
326
327 #[test]
328 fn success_resets_consecutive_failures() {
329 let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
330 cb.record(AttemptOutcome::HardFailure);
331 cb.record(AttemptOutcome::HardFailure);
332 cb.record(AttemptOutcome::Success);
333 assert!(!cb.record(AttemptOutcome::HardFailure));
334 assert!(!cb.is_open());
335 }
336}