Skip to main content

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        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// ---------------------------------------------------------------------------
202// Circuit Breaker (G28-D, v1.0.68)
203// ---------------------------------------------------------------------------
204
205/// Outcome of a single retry attempt, used to feed a [`CircuitBreaker`].
206///
207/// We keep this intentionally narrow: rate-limit / timeout errors are
208/// TRANSIENT and should NOT count toward the breaker; everything else
209/// counts as a HARD failure that contributes to opening the breaker.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum AttemptOutcome {
212    /// Transient error: counts as a successful iteration, does NOT trip the breaker.
213    /// Examples: `AppError::RateLimited`, `AppError::Timeout`, `AppError::DbBusy`.
214    Transient,
215    /// Hard failure: counts toward the breaker's failure threshold.
216    /// Examples: `AppError::Validation`, `AppError::Conflict`,
217    /// `AppError::Embedding`, `AppError::Internal`.
218    HardFailure,
219    /// Successful iteration: resets the consecutive-failure counter.
220    Success,
221}
222
223/// Counts consecutive hard failures and trips open after a threshold.
224///
225/// G28-D (v1.0.68): caps `enrich --retry-failed` and `ingest --retry-failed`
226/// loops so persistent failures (e.g., LLM provider returning the same
227/// 4xx for hours) cannot run unbounded.  After `threshold` consecutive
228/// [`AttemptOutcome::HardFailure`] outcomes, `record` returns `true` and
229/// the caller is expected to abort with `AppError::CircuitBreakerOpen`.
230///
231/// Rate-limited / transient errors are explicitly NOT counted, so a
232/// provider that throttles but eventually recovers will not trip the
233/// breaker.
234#[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    /// Creates a breaker that opens after `threshold` consecutive hard
244    /// failures and stays open for `cooldown` after the last failure.
245    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    /// Records one attempt outcome.
255    ///
256    /// Returns `true` when the breaker is now open and the caller must
257    /// abort the job.  Returns `false` when the attempt should continue.
258    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    /// `true` when the breaker is currently open (and not yet cooled down).
284    pub fn is_open(&self) -> bool {
285        self.open_until
286            .map(|deadline| Instant::now() < deadline)
287            .unwrap_or(false)
288    }
289
290    /// Resets the breaker to closed state.
291    pub fn reset(&mut self) {
292        self.consecutive_failures = 0;
293        self.open_until = None;
294    }
295
296    /// Returns the number of consecutive HardFailure outcomes observed
297    /// since the last success or reset. Public so callers can include
298    /// the value in their abort log line.
299    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        // 10 transients in a row should never open the breaker.
321        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}