sqlite_graphrag/retry.rs
1//! Centralized retry infrastructure with exponential backoff and half-jitter.
2//!
3//! Provides [`RetryConfig`](crate::retry::RetryConfig) with named constructors for each failure domain
4//! (SQLite BUSY, LLM rate-limit, cold-start) and a [`compute_delay`](crate::retry::compute_delay) function
5//! that applies the configured jitter strategy.
6
7use std::time::{Duration, Instant};
8
9/// Configures retry behavior for a specific failure domain.
10///
11/// Use the named constructors ([`Self::sqlite_busy`], [`Self::llm_rate_limit`],
12/// [`Self::cold_start`]) for pre-tuned policies. All timing values are in
13/// milliseconds except `max_elapsed_secs` which is in seconds.
14#[derive(Debug, Clone)]
15pub struct RetryConfig {
16 /// Base delay for the first retry attempt (ms).
17 pub initial_delay_ms: u64,
18 /// Upper bound on any single delay (ms).
19 pub max_delay_ms: u64,
20 /// Multiplicative factor applied per attempt.
21 pub multiplier: u64,
22 /// Hard cap on total attempts (0 = unlimited, use deadline).
23 pub max_attempts: u32,
24 /// Total elapsed wall-clock time before giving up (seconds).
25 pub max_elapsed_secs: u64,
26 /// Jitter strategy applied to computed delays.
27 pub jitter: JitterKind,
28}
29
30/// Jitter strategy for randomizing retry delays.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum JitterKind {
33 /// No randomization — deterministic delay.
34 None,
35 /// Half-jitter: delay in [base/2, base). Guarantees minimum wait.
36 Half,
37 /// Full-jitter: delay in [0, base). Maximum spread.
38 Full,
39}
40
41impl RetryConfig {
42 /// SQLite BUSY retry: 5 attempts, 300ms base, half-jitter, 30s deadline.
43 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 /// LLM rate-limit retry: 60s base, 900s cap, half-jitter, 1h deadline.
55 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 /// Cold-start retry: 2s base, 2 attempts, no jitter, 30s deadline.
67 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
79/// Computes the delay for a given attempt using the config's jitter strategy.
80///
81/// # Formula
82///
83/// ```text
84/// base = min(initial_delay_ms * multiplier^attempt, max_delay_ms)
85/// delay = apply_jitter(base, jitter_kind)
86/// ```
87pub 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
115/// Returns `true` if XDG `retry.disable` is truthy (`1` / `true` / `yes`).
116///
117/// When active, all retry loops should propagate the error immediately without
118/// sleeping. Use during incidents to prevent retry storms
119/// (`config set retry.disable 1`).
120pub 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 // GAP-SG-232: no environment cleanup to do, because the product reads no
160 // variable of its own. The switch comes from the XDG key
161 // `retry.disable` alone, and precedence is CLI flag > `config set` >
162 // compiled default. Clearing `SQLITE_GRAPHRAG_DISABLE_RETRY` here used
163 // to suggest that variable still had an effect on the verdict below.
164 assert!(!is_kill_switch_active());
165 }
166
167 #[test]
168 fn sqlite_busy_config_matches_constants() {
169 let cfg = RetryConfig::sqlite_busy();
170 assert_eq!(cfg.initial_delay_ms, 300);
171 assert_eq!(cfg.max_attempts, 5);
172 assert_eq!(cfg.max_elapsed_secs, 30);
173 }
174
175 #[test]
176 fn llm_rate_limit_has_deadline() {
177 let cfg = RetryConfig::llm_rate_limit();
178 assert_eq!(cfg.max_elapsed_secs, 3600);
179 assert_eq!(cfg.max_delay_ms, 900_000);
180 }
181
182 #[test]
183 fn full_jitter_stays_below_base() {
184 let cfg = RetryConfig {
185 initial_delay_ms: 1000,
186 max_delay_ms: 10_000,
187 multiplier: 2,
188 max_attempts: 5,
189 max_elapsed_secs: 60,
190 jitter: JitterKind::Full,
191 };
192 for attempt in 0..4 {
193 for _ in 0..100 {
194 let d = compute_delay(&cfg, attempt);
195 let base = cfg
196 .initial_delay_ms
197 .saturating_mul(cfg.multiplier.saturating_pow(attempt))
198 .min(cfg.max_delay_ms);
199 assert!(d.as_millis() < base as u128);
200 }
201 }
202 }
203}
204
205// ---------------------------------------------------------------------------
206// Circuit Breaker (G28-D, v1.0.68)
207// ---------------------------------------------------------------------------
208
209/// Outcome of a single retry attempt, used to feed a [`CircuitBreaker`].
210///
211/// We keep this intentionally narrow: rate-limit / timeout errors are
212/// TRANSIENT and should NOT count toward the breaker; everything else
213/// counts as a HARD failure that contributes to opening the breaker.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum AttemptOutcome {
216 /// Transient error: counts as a successful iteration, does NOT trip the breaker.
217 /// Examples: `AppError::RateLimited`, `AppError::Timeout`, `AppError::DbBusy`.
218 Transient,
219 /// Hard failure: counts toward the breaker's failure threshold.
220 /// Examples: `AppError::Validation`, `AppError::Conflict`,
221 /// `AppError::Embedding`, `AppError::Internal`.
222 HardFailure,
223 /// Successful iteration: resets the consecutive-failure counter.
224 Success,
225}
226
227/// Counts consecutive hard failures and trips open after a threshold.
228///
229/// G28-D (v1.0.68): caps `enrich --retry-failed` and `ingest --retry-failed`
230/// loops so persistent failures (e.g., LLM provider returning the same
231/// 4xx for hours) cannot run unbounded. After `threshold` consecutive
232/// [`AttemptOutcome::HardFailure`] outcomes, `record` returns `true` and
233/// the caller is expected to abort with `AppError::CircuitBreakerOpen`.
234///
235/// Rate-limited / transient errors are explicitly NOT counted, so a
236/// provider that throttles but eventually recovers will not trip the
237/// breaker.
238#[derive(Debug, Clone)]
239pub struct CircuitBreaker {
240 threshold: u32,
241 cooldown: Duration,
242 consecutive_failures: u32,
243 open_until: Option<Instant>,
244}
245
246impl CircuitBreaker {
247 /// Creates a breaker that opens after `threshold` consecutive hard
248 /// failures and stays open for `cooldown` after the last failure.
249 pub fn new(threshold: u32, cooldown: Duration) -> Self {
250 Self {
251 threshold,
252 cooldown,
253 consecutive_failures: 0,
254 open_until: None,
255 }
256 }
257
258 /// Records one attempt outcome.
259 ///
260 /// Returns `true` when the breaker is now open and the caller must
261 /// abort the job. Returns `false` when the attempt should continue.
262 pub fn record(&mut self, outcome: AttemptOutcome) -> bool {
263 match outcome {
264 AttemptOutcome::Success | AttemptOutcome::Transient => {
265 self.consecutive_failures = 0;
266 false
267 }
268 AttemptOutcome::HardFailure => {
269 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
270 if self.consecutive_failures >= self.threshold.max(1) {
271 self.open_until = Some(Instant::now() + self.cooldown);
272 tracing::error!(
273 target: "circuit_breaker",
274 consecutive_failures = self.consecutive_failures,
275 threshold = self.threshold,
276 cooldown_secs = self.cooldown.as_secs(),
277 "circuit breaker opened — aborting job"
278 );
279 true
280 } else {
281 false
282 }
283 }
284 }
285 }
286
287 /// `true` when the breaker is currently open (and not yet cooled down).
288 pub fn is_open(&self) -> bool {
289 self.open_until
290 .map(|deadline| Instant::now() < deadline)
291 .unwrap_or(false)
292 }
293
294 /// Resets the breaker to closed state.
295 pub fn reset(&mut self) {
296 self.consecutive_failures = 0;
297 self.open_until = None;
298 }
299
300 /// Returns the number of consecutive HardFailure outcomes observed
301 /// since the last success or reset. Public so callers can include
302 /// the value in their abort log line.
303 pub fn consecutive_failures(&self) -> u32 {
304 self.consecutive_failures
305 }
306}
307
308#[cfg(test)]
309mod circuit_breaker_tests {
310 use super::*;
311
312 #[test]
313 fn opens_after_threshold_consecutive_hard_failures() {
314 let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
315 assert!(!cb.record(AttemptOutcome::HardFailure));
316 assert!(!cb.record(AttemptOutcome::HardFailure));
317 assert!(cb.record(AttemptOutcome::HardFailure));
318 assert!(cb.is_open());
319 }
320
321 #[test]
322 fn ignores_transient_errors() {
323 let mut cb = CircuitBreaker::new(2, Duration::from_secs(60));
324 // 10 transients in a row should never open the breaker.
325 for _ in 0..10 {
326 assert!(!cb.record(AttemptOutcome::Transient));
327 }
328 assert!(!cb.is_open());
329 }
330
331 #[test]
332 fn success_resets_consecutive_failures() {
333 let mut cb = CircuitBreaker::new(3, Duration::from_secs(60));
334 cb.record(AttemptOutcome::HardFailure);
335 cb.record(AttemptOutcome::HardFailure);
336 cb.record(AttemptOutcome::Success);
337 assert!(!cb.record(AttemptOutcome::HardFailure));
338 assert!(!cb.is_open());
339 }
340}