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 {
120 crate::config::get_setting("retry.disable")
121 .ok()
122 .flatten()
123 .is_some_and(|v| matches!(v.trim(), "1" | "true" | "yes"))
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn compute_delay_half_jitter_in_bounds() {
132 let cfg = RetryConfig::llm_rate_limit();
133 for attempt in 0..5 {
134 for _ in 0..100 {
135 let d = compute_delay(&cfg, attempt);
136 let base = cfg
137 .initial_delay_ms
138 .saturating_mul(cfg.multiplier.saturating_pow(attempt))
139 .min(cfg.max_delay_ms);
140 let half = base / 2;
141 assert!(d.as_millis() >= half as u128);
142 assert!(d.as_millis() < base as u128);
143 }
144 }
145 }
146
147 #[test]
148 fn compute_delay_no_jitter_is_deterministic() {
149 let cfg = RetryConfig::cold_start();
150 let d1 = compute_delay(&cfg, 0);
151 let d2 = compute_delay(&cfg, 0);
152 assert_eq!(d1, d2);
153 assert_eq!(d1, Duration::from_millis(2000));
154 }
155
156 #[test]
157 fn kill_switch_inactive_by_default() {
158 std::env::remove_var("SQLITE_GRAPHRAG_DISABLE_RETRY");
159 assert!(!is_kill_switch_active());
160 }
161
162 #[test]
163 fn sqlite_busy_config_matches_constants() {
164 let cfg = RetryConfig::sqlite_busy();
165 assert_eq!(cfg.initial_delay_ms, 300);
166 assert_eq!(cfg.max_attempts, 5);
167 assert_eq!(cfg.max_elapsed_secs, 30);
168 }
169
170 #[test]
171 fn llm_rate_limit_has_deadline() {
172 let cfg = RetryConfig::llm_rate_limit();
173 assert_eq!(cfg.max_elapsed_secs, 3600);
174 assert_eq!(cfg.max_delay_ms, 900_000);
175 }
176
177 #[test]
178 fn full_jitter_stays_below_base() {
179 let cfg = RetryConfig {
180 initial_delay_ms: 1000,
181 max_delay_ms: 10_000,
182 multiplier: 2,
183 max_attempts: 5,
184 max_elapsed_secs: 60,
185 jitter: JitterKind::Full,
186 };
187 for attempt in 0..4 {
188 for _ in 0..100 {
189 let d = compute_delay(&cfg, attempt);
190 let base = cfg
191 .initial_delay_ms
192 .saturating_mul(cfg.multiplier.saturating_pow(attempt))
193 .min(cfg.max_delay_ms);
194 assert!(d.as_millis() < base as u128);
195 }
196 }
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum AttemptOutcome {
211 Transient,
214 HardFailure,
218 Success,
220}
221
222#[derive(Debug, Clone)]
234pub struct CircuitBreaker {
235 threshold: u32,
236 cooldown: Duration,
237 consecutive_failures: u32,
238 open_until: Option<Instant>,
239}
240
241impl CircuitBreaker {
242 pub fn new(threshold: u32, cooldown: Duration) -> Self {
245 Self {
246 threshold,
247 cooldown,
248 consecutive_failures: 0,
249 open_until: None,
250 }
251 }
252
253 pub fn record(&mut self, outcome: AttemptOutcome) -> bool {
258 match outcome {
259 AttemptOutcome::Success | AttemptOutcome::Transient => {
260 self.consecutive_failures = 0;
261 false
262 }
263 AttemptOutcome::HardFailure => {
264 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
265 if self.consecutive_failures >= self.threshold.max(1) {
266 self.open_until = Some(Instant::now() + self.cooldown);
267 tracing::error!(
268 target: "circuit_breaker",
269 consecutive_failures = self.consecutive_failures,
270 threshold = self.threshold,
271 cooldown_secs = self.cooldown.as_secs(),
272 "circuit breaker opened — aborting job"
273 );
274 true
275 } else {
276 false
277 }
278 }
279 }
280 }
281
282 pub fn is_open(&self) -> bool {
284 self.open_until
285 .map(|deadline| Instant::now() < deadline)
286 .unwrap_or(false)
287 }
288
289 pub fn reset(&mut self) {
291 self.consecutive_failures = 0;
292 self.open_until = None;
293 }
294
295 pub fn consecutive_failures(&self) -> u32 {
299 self.consecutive_failures
300 }
301}
302
303#[cfg(test)]
304mod circuit_breaker_tests {
305 use super::*;
306
307 #[test]
308 fn opens_after_threshold_consecutive_hard_failures() {
309 let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
310 assert!(!cb.record(AttemptOutcome::HardFailure));
311 assert!(!cb.record(AttemptOutcome::HardFailure));
312 assert!(cb.record(AttemptOutcome::HardFailure));
313 assert!(cb.is_open());
314 }
315
316 #[test]
317 fn ignores_transient_errors() {
318 let mut cb = CircuitBreaker::new(2, Duration::from_secs(60));
319 for _ in 0..10 {
321 assert!(!cb.record(AttemptOutcome::Transient));
322 }
323 assert!(!cb.is_open());
324 }
325
326 #[test]
327 fn success_resets_consecutive_failures() {
328 let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
329 cb.record(AttemptOutcome::HardFailure);
330 cb.record(AttemptOutcome::HardFailure);
331 cb.record(AttemptOutcome::Success);
332 assert!(!cb.record(AttemptOutcome::HardFailure));
333 assert!(!cb.is_open());
334 }
335}