Skip to main content

imagegen_bridge_runtime/
circuit_breaker.rs

1//! Per-provider circuit breakers with bounded, deterministic state.
2
3use std::{
4    sync::Mutex,
5    time::{Duration, Instant},
6};
7
8use imagegen_bridge_core::{BridgeError, ErrorCode};
9
10/// Circuit-breaker policy applied independently to every provider.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct CircuitBreakerConfig {
13    /// Whether calls are guarded by the breaker.
14    pub enabled: bool,
15    /// Consecutive qualifying failures that open a closed circuit.
16    pub failure_threshold: u32,
17    /// Recovery delay before a bounded half-open probe is permitted.
18    pub open_duration: Duration,
19    /// Simultaneous probes permitted while half-open.
20    pub half_open_max_calls: u32,
21    /// Consecutive successful probes required to close the circuit.
22    pub success_threshold: u32,
23}
24
25impl Default for CircuitBreakerConfig {
26    fn default() -> Self {
27        Self {
28            enabled: true,
29            failure_threshold: 5,
30            open_duration: Duration::from_secs(3 * 60),
31            half_open_max_calls: 1,
32            success_threshold: 1,
33        }
34    }
35}
36
37/// Stable circuit state exposed to diagnostics and metrics.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CircuitState {
40    /// Normal operation.
41    Closed,
42    /// Calls fail fast during the recovery delay.
43    Open,
44    /// A bounded number of recovery probes are in flight.
45    HalfOpen,
46}
47
48impl CircuitState {
49    /// Stable lowercase metric/JSON label.
50    #[must_use]
51    pub const fn as_str(self) -> &'static str {
52        match self {
53            Self::Closed => "closed",
54            Self::Open => "open",
55            Self::HalfOpen => "half_open",
56        }
57    }
58}
59
60/// Redaction-safe breaker state for one provider.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CircuitBreakerSnapshot {
63    /// Current state.
64    pub state: CircuitState,
65    /// Consecutive failures observed in the current closed epoch.
66    pub consecutive_failures: u32,
67    /// Remaining recovery delay when open.
68    pub retry_after_ms: u64,
69    /// Calls rejected without touching the provider.
70    pub rejected_calls: u64,
71    /// State transitions since startup.
72    pub transitions: u64,
73}
74
75#[derive(Debug)]
76pub(crate) struct CircuitBreaker {
77    config: CircuitBreakerConfig,
78    inner: Mutex<Inner>,
79}
80
81#[derive(Debug)]
82struct Inner {
83    phase: Phase,
84    epoch: u64,
85    rejected_calls: u64,
86    transitions: u64,
87}
88
89#[derive(Debug)]
90enum Phase {
91    Closed { consecutive_failures: u32 },
92    Open { until: Instant },
93    HalfOpen { in_flight: u32, successes: u32 },
94}
95
96#[derive(Debug, Clone, Copy)]
97enum PermitKind {
98    Closed(u64),
99    HalfOpen(u64),
100    Disabled,
101}
102
103#[derive(Debug, Clone, Copy)]
104enum Outcome {
105    Healthy,
106    Failed,
107    Neutral,
108}
109
110/// One admitted provider attempt. It must be completed or explicitly abandoned.
111#[derive(Debug)]
112pub(crate) struct CircuitPermit<'a> {
113    breaker: &'a CircuitBreaker,
114    kind: PermitKind,
115    finished: bool,
116}
117
118impl CircuitBreaker {
119    pub(crate) fn new(config: CircuitBreakerConfig) -> Result<Self, BridgeError> {
120        if config.enabled
121            && (config.failure_threshold == 0
122                || config.open_duration.is_zero()
123                || config.half_open_max_calls == 0
124                || config.success_threshold == 0)
125        {
126            return Err(BridgeError::new(
127                ErrorCode::Configuration,
128                "enabled circuit-breaker limits must be greater than zero",
129            ));
130        }
131        Ok(Self {
132            config,
133            inner: Mutex::new(Inner {
134                phase: Phase::Closed {
135                    consecutive_failures: 0,
136                },
137                epoch: 0,
138                rejected_calls: 0,
139                transitions: 0,
140            }),
141        })
142    }
143
144    pub(crate) fn acquire(&self, provider: &str) -> Result<CircuitPermit<'_>, BridgeError> {
145        if !self.config.enabled {
146            return Ok(CircuitPermit {
147                breaker: self,
148                kind: PermitKind::Disabled,
149                finished: false,
150            });
151        }
152        let now = Instant::now();
153        let mut inner = self
154            .inner
155            .lock()
156            .unwrap_or_else(std::sync::PoisonError::into_inner);
157        if let Phase::Open { until } = inner.phase
158            && now >= until
159        {
160            inner.phase = Phase::HalfOpen {
161                in_flight: 0,
162                successes: 0,
163            };
164            inner.epoch = inner.epoch.saturating_add(1);
165            inner.transitions = inner.transitions.saturating_add(1);
166        }
167        let epoch = inner.epoch;
168        let kind = match &mut inner.phase {
169            Phase::Closed { .. } => PermitKind::Closed(epoch),
170            Phase::HalfOpen { in_flight, .. } if *in_flight < self.config.half_open_max_calls => {
171                *in_flight = in_flight.saturating_add(1);
172                PermitKind::HalfOpen(epoch)
173            }
174            Phase::Open { until } => {
175                let retry_after_ms = duration_ms(until.saturating_duration_since(now));
176                inner.rejected_calls = inner.rejected_calls.saturating_add(1);
177                return Err(open_error(provider, retry_after_ms));
178            }
179            Phase::HalfOpen { .. } => {
180                inner.rejected_calls = inner.rejected_calls.saturating_add(1);
181                return Err(open_error(provider, duration_ms(self.config.open_duration))
182                    .with_detail("circuit_state", "half_open"));
183            }
184        };
185        drop(inner);
186        Ok(CircuitPermit {
187            breaker: self,
188            kind,
189            finished: false,
190        })
191    }
192
193    pub(crate) fn snapshot(&self) -> CircuitBreakerSnapshot {
194        let now = Instant::now();
195        let inner = self
196            .inner
197            .lock()
198            .unwrap_or_else(std::sync::PoisonError::into_inner);
199        let (state, consecutive_failures, retry_after_ms) = match inner.phase {
200            Phase::Closed {
201                consecutive_failures,
202            } => (CircuitState::Closed, consecutive_failures, 0),
203            Phase::Open { until } => (
204                CircuitState::Open,
205                self.config.failure_threshold,
206                duration_ms(until.saturating_duration_since(now)),
207            ),
208            Phase::HalfOpen { .. } => (CircuitState::HalfOpen, self.config.failure_threshold, 0),
209        };
210        CircuitBreakerSnapshot {
211            state,
212            consecutive_failures,
213            retry_after_ms,
214            rejected_calls: inner.rejected_calls,
215            transitions: inner.transitions,
216        }
217    }
218
219    fn complete(&self, kind: PermitKind, result: Result<(), &BridgeError>) {
220        if matches!(kind, PermitKind::Disabled) {
221            return;
222        }
223        let outcome = match result {
224            Ok(()) => Outcome::Healthy,
225            Err(error) if counts_as_failure(error) => Outcome::Failed,
226            Err(_) => Outcome::Neutral,
227        };
228        let mut inner = self
229            .inner
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        let permit_epoch = match kind {
233            PermitKind::Closed(epoch) | PermitKind::HalfOpen(epoch) => epoch,
234            PermitKind::Disabled => return,
235        };
236        if permit_epoch != inner.epoch {
237            return;
238        }
239        match (&mut inner.phase, kind, outcome) {
240            (
241                Phase::Closed {
242                    consecutive_failures,
243                },
244                PermitKind::Closed(_),
245                Outcome::Healthy,
246            ) => {
247                *consecutive_failures = 0;
248            }
249            (
250                Phase::Closed {
251                    consecutive_failures,
252                },
253                PermitKind::Closed(_),
254                Outcome::Failed,
255            ) => {
256                *consecutive_failures = consecutive_failures.saturating_add(1);
257                if *consecutive_failures >= self.config.failure_threshold {
258                    inner.phase = Phase::Open {
259                        until: Instant::now() + self.config.open_duration,
260                    };
261                    inner.epoch = inner.epoch.saturating_add(1);
262                    inner.transitions = inner.transitions.saturating_add(1);
263                }
264            }
265            (
266                Phase::HalfOpen {
267                    in_flight,
268                    successes,
269                },
270                PermitKind::HalfOpen(_),
271                Outcome::Healthy,
272            ) => {
273                *in_flight = in_flight.saturating_sub(1);
274                *successes = successes.saturating_add(1);
275                if *successes >= self.config.success_threshold && *in_flight == 0 {
276                    inner.phase = Phase::Closed {
277                        consecutive_failures: 0,
278                    };
279                    inner.epoch = inner.epoch.saturating_add(1);
280                    inner.transitions = inner.transitions.saturating_add(1);
281                }
282            }
283            (
284                Phase::HalfOpen { .. },
285                PermitKind::HalfOpen(_),
286                Outcome::Failed | Outcome::Neutral,
287            ) => {
288                inner.phase = Phase::Open {
289                    until: Instant::now() + self.config.open_duration,
290                };
291                inner.epoch = inner.epoch.saturating_add(1);
292                inner.transitions = inner.transitions.saturating_add(1);
293            }
294            _ => {}
295        }
296    }
297
298    fn abandon(&self, kind: PermitKind) {
299        let PermitKind::HalfOpen(epoch) = kind else {
300            return;
301        };
302        let mut inner = self
303            .inner
304            .lock()
305            .unwrap_or_else(std::sync::PoisonError::into_inner);
306        if epoch == inner.epoch
307            && let Phase::HalfOpen { in_flight, .. } = &mut inner.phase
308        {
309            *in_flight = in_flight.saturating_sub(1);
310        }
311    }
312}
313
314impl CircuitPermit<'_> {
315    pub(crate) fn finish<T>(mut self, result: &Result<T, BridgeError>) {
316        self.breaker
317            .complete(self.kind, result.as_ref().map(|_| ()));
318        self.finished = true;
319    }
320}
321
322impl Drop for CircuitPermit<'_> {
323    fn drop(&mut self) {
324        if !self.finished {
325            self.breaker.abandon(self.kind);
326        }
327    }
328}
329
330fn counts_as_failure(error: &BridgeError) -> bool {
331    matches!(
332        error.code,
333        ErrorCode::RateLimited
334            | ErrorCode::Overloaded
335            | ErrorCode::Timeout
336            | ErrorCode::Upstream
337            | ErrorCode::Protocol
338    )
339}
340
341fn open_error(provider: &str, retry_after_ms: u64) -> BridgeError {
342    BridgeError::new(ErrorCode::Overloaded, "provider circuit breaker is open")
343        .retryable(true)
344        .with_provider(provider)
345        .with_detail("circuit_state", "open")
346        .with_detail("retry_after_ms", retry_after_ms)
347}
348
349fn duration_ms(value: Duration) -> u64 {
350    u64::try_from(value.as_millis()).unwrap_or(u64::MAX)
351}
352
353#[cfg(test)]
354mod tests {
355    #![allow(clippy::unwrap_used)]
356
357    use super::*;
358
359    fn config() -> CircuitBreakerConfig {
360        CircuitBreakerConfig {
361            failure_threshold: 2,
362            open_duration: Duration::from_millis(20),
363            ..CircuitBreakerConfig::default()
364        }
365    }
366
367    #[test]
368    fn opens_after_threshold_and_recovers_through_half_open() {
369        let breaker = CircuitBreaker::new(config()).unwrap();
370        for _ in 0..2 {
371            let permit = breaker.acquire("fake").unwrap();
372            let result: Result<(), BridgeError> =
373                Err(BridgeError::new(ErrorCode::Upstream, "injected"));
374            permit.finish(&result);
375        }
376        assert_eq!(breaker.snapshot().state, CircuitState::Open);
377        let rejected = breaker.acquire("fake").unwrap_err();
378        assert_eq!(rejected.details["circuit_state"], "open");
379        std::thread::sleep(Duration::from_millis(25));
380        let permit = breaker.acquire("fake").unwrap();
381        assert_eq!(breaker.snapshot().state, CircuitState::HalfOpen);
382        permit.finish(&Ok::<_, BridgeError>(()));
383        assert_eq!(breaker.snapshot().state, CircuitState::Closed);
384    }
385
386    #[test]
387    fn slow_success_and_request_errors_do_not_open_the_circuit() {
388        let breaker = CircuitBreaker::new(config()).unwrap();
389        for code in [
390            ErrorCode::SafetyRejected,
391            ErrorCode::InvalidRequest,
392            ErrorCode::Cancelled,
393        ] {
394            let permit = breaker.acquire("fake").unwrap();
395            permit.finish(&Err::<(), _>(BridgeError::new(
396                code,
397                "non-provider failure",
398            )));
399        }
400        let permit = breaker.acquire("fake").unwrap();
401        std::thread::sleep(Duration::from_millis(30));
402        permit.finish(&Ok::<_, BridgeError>(()));
403        assert_eq!(breaker.snapshot().state, CircuitState::Closed);
404    }
405
406    #[test]
407    fn stale_closed_permit_cannot_contaminate_a_recovered_epoch() {
408        let policy = CircuitBreakerConfig {
409            failure_threshold: 1,
410            open_duration: Duration::from_millis(5),
411            ..CircuitBreakerConfig::default()
412        };
413        let breaker = CircuitBreaker::new(policy).unwrap();
414        let stale = breaker.acquire("fake").unwrap();
415        let opener = breaker.acquire("fake").unwrap();
416        opener.finish(&Err::<(), _>(BridgeError::new(ErrorCode::Upstream, "open")));
417        std::thread::sleep(Duration::from_millis(8));
418        breaker
419            .acquire("fake")
420            .unwrap()
421            .finish(&Ok::<_, BridgeError>(()));
422        stale.finish(&Err::<(), _>(BridgeError::new(
423            ErrorCode::Upstream,
424            "stale",
425        )));
426        assert_eq!(breaker.snapshot().state, CircuitState::Closed);
427        assert_eq!(breaker.snapshot().consecutive_failures, 0);
428    }
429
430    #[test]
431    fn concurrent_half_open_probes_settle_before_closing() {
432        let policy = CircuitBreakerConfig {
433            failure_threshold: 1,
434            open_duration: Duration::from_millis(5),
435            half_open_max_calls: 2,
436            success_threshold: 1,
437            ..CircuitBreakerConfig::default()
438        };
439        let breaker = CircuitBreaker::new(policy).unwrap();
440        breaker
441            .acquire("fake")
442            .unwrap()
443            .finish(&Err::<(), _>(BridgeError::new(ErrorCode::Upstream, "open")));
444        std::thread::sleep(Duration::from_millis(8));
445        let healthy = breaker.acquire("fake").unwrap();
446        let failed = breaker.acquire("fake").unwrap();
447        healthy.finish(&Ok::<_, BridgeError>(()));
448        assert_eq!(breaker.snapshot().state, CircuitState::HalfOpen);
449        failed.finish(&Err::<(), _>(BridgeError::new(
450            ErrorCode::Upstream,
451            "probe failed",
452        )));
453        assert_eq!(breaker.snapshot().state, CircuitState::Open);
454    }
455}