1use std::{
4 sync::Mutex,
5 time::{Duration, Instant},
6};
7
8use imagegen_bridge_core::{BridgeError, ErrorCode};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct CircuitBreakerConfig {
13 pub enabled: bool,
15 pub failure_threshold: u32,
17 pub open_duration: Duration,
19 pub half_open_max_calls: u32,
21 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CircuitState {
40 Closed,
42 Open,
44 HalfOpen,
46}
47
48impl CircuitState {
49 #[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#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CircuitBreakerSnapshot {
63 pub state: CircuitState,
65 pub consecutive_failures: u32,
67 pub retry_after_ms: u64,
69 pub rejected_calls: u64,
71 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#[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}