Skip to main content

gateway_core/
circuit.rs

1use std::{
2    collections::HashMap,
3    sync::Mutex,
4    time::{Duration, Instant},
5};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CircuitState {
9    Closed,
10    Open,
11    HalfOpen,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CircuitDecision {
16    Allow { probe: bool },
17    Skip,
18}
19
20#[derive(Debug, Clone, Copy)]
21struct Circuit {
22    state: CircuitState,
23    failures: u32,
24    phase_started: Option<Instant>,
25}
26
27impl Default for Circuit {
28    fn default() -> Self {
29        Self {
30            state: CircuitState::Closed,
31            failures: 0,
32            phase_started: None,
33        }
34    }
35}
36
37pub struct CircuitBreaker {
38    threshold: u32,
39    cooldown: Duration,
40    circuits: Mutex<HashMap<String, Circuit>>,
41}
42
43impl CircuitBreaker {
44    pub fn new(threshold: u32, cooldown: Duration) -> Self {
45        Self {
46            threshold: threshold.max(1),
47            cooldown,
48            circuits: Mutex::new(HashMap::new()),
49        }
50    }
51
52    pub fn allow(&self, provider: &str) -> CircuitDecision {
53        self.allow_at(provider, Instant::now())
54    }
55
56    pub fn record_success(&self, provider: &str) {
57        let mut circuits = self.lock();
58        let circuit = circuits.entry(provider.to_owned()).or_default();
59        circuit.state = CircuitState::Closed;
60        circuit.failures = 0;
61        circuit.phase_started = None;
62    }
63
64    pub fn record_failure(&self, provider: &str) {
65        self.record_failure_at(provider, Instant::now());
66    }
67
68    pub fn state(&self, provider: &str) -> CircuitState {
69        self.lock()
70            .get(provider)
71            .map_or(CircuitState::Closed, |circuit| circuit.state)
72    }
73
74    pub fn snapshot(&self) -> Vec<(String, CircuitState)> {
75        self.lock()
76            .iter()
77            .map(|(provider, circuit)| (provider.clone(), circuit.state))
78            .collect()
79    }
80
81    /// The phase the next request would find, without being that request.
82    ///
83    /// [`Self::snapshot`] reports the phase stored, which is the phase the last
84    /// request left behind: a target whose cooldown has elapsed still reads
85    /// `Open` until something calls [`Self::allow`] and moves it. That is the
86    /// right answer for bookkeeping and the wrong one for a reader describing
87    /// what this process would do now — during recovery it reports a target as
88    /// refused that the next request would in fact probe.
89    ///
90    /// So the cooldown is applied to the *answer* and not to the breaker: an open
91    /// or half-open circuit past its cooldown reports [`CircuitState::HalfOpen`],
92    /// the phase in which a probe is allowed, and nothing is written. A reader
93    /// cannot spend a target's probe budget by looking at it.
94    pub fn observed(&self) -> Vec<(String, CircuitState)> {
95        self.observed_at(Instant::now())
96    }
97
98    fn observed_at(&self, now: Instant) -> Vec<(String, CircuitState)> {
99        self.lock()
100            .iter()
101            .map(|(provider, circuit)| {
102                let phase = match circuit.state {
103                    CircuitState::Open | CircuitState::HalfOpen
104                        if elapsed(circuit.phase_started, now) >= self.cooldown =>
105                    {
106                        CircuitState::HalfOpen
107                    }
108                    held => held,
109                };
110                (provider.clone(), phase)
111            })
112            .collect()
113    }
114
115    fn allow_at(&self, provider: &str, now: Instant) -> CircuitDecision {
116        let mut circuits = self.lock();
117        let circuit = circuits.entry(provider.to_owned()).or_default();
118        match circuit.state {
119            CircuitState::Closed => CircuitDecision::Allow { probe: false },
120            CircuitState::Open if elapsed(circuit.phase_started, now) >= self.cooldown => {
121                circuit.state = CircuitState::HalfOpen;
122                circuit.phase_started = Some(now);
123                CircuitDecision::Allow { probe: true }
124            }
125            CircuitState::HalfOpen if elapsed(circuit.phase_started, now) >= self.cooldown => {
126                circuit.phase_started = Some(now);
127                CircuitDecision::Allow { probe: true }
128            }
129            CircuitState::Open | CircuitState::HalfOpen => CircuitDecision::Skip,
130        }
131    }
132
133    fn record_failure_at(&self, provider: &str, now: Instant) {
134        let mut circuits = self.lock();
135        let circuit = circuits.entry(provider.to_owned()).or_default();
136        match circuit.state {
137            CircuitState::Closed => {
138                circuit.failures = circuit.failures.saturating_add(1);
139                if circuit.failures >= self.threshold {
140                    circuit.state = CircuitState::Open;
141                    circuit.phase_started = Some(now);
142                }
143            }
144            CircuitState::HalfOpen => {
145                circuit.state = CircuitState::Open;
146                circuit.failures = self.threshold;
147                circuit.phase_started = Some(now);
148            }
149            CircuitState::Open => {}
150        }
151    }
152
153    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Circuit>> {
154        self.circuits
155            .lock()
156            .unwrap_or_else(std::sync::PoisonError::into_inner)
157    }
158}
159
160fn elapsed(started: Option<Instant>, now: Instant) -> Duration {
161    started.map_or(Duration::MAX, |started| {
162        now.saturating_duration_since(started)
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn opens_skips_probes_and_recovers() {
172        let breaker = CircuitBreaker::new(2, Duration::from_secs(10));
173        let now = Instant::now();
174        breaker.record_failure_at("openai", now);
175        breaker.record_failure_at("openai", now);
176        assert_eq!(breaker.allow_at("openai", now), CircuitDecision::Skip);
177        assert_eq!(
178            breaker.allow_at("openai", now + Duration::from_secs(10)),
179            CircuitDecision::Allow { probe: true }
180        );
181        breaker.record_success("openai");
182        assert_eq!(breaker.state("openai"), CircuitState::Closed);
183    }
184
185    #[test]
186    fn an_elapsed_cooldown_is_visible_before_a_request_spends_it() {
187        let breaker = CircuitBreaker::new(2, Duration::from_secs(10));
188        let now = Instant::now();
189        breaker.record_failure_at("openai", now);
190        breaker.record_failure_at("openai", now);
191
192        assert_eq!(
193            breaker.observed_at(now),
194            vec![("openai".to_owned(), CircuitState::Open)],
195            "a target inside its cooldown is one this replica is refusing"
196        );
197
198        let recovered = now + Duration::from_secs(10);
199        assert_eq!(
200            breaker.observed_at(recovered),
201            vec![("openai".to_owned(), CircuitState::HalfOpen)],
202            "a target whose cooldown elapsed is one the next request would probe"
203        );
204        assert_eq!(
205            breaker.snapshot(),
206            vec![("openai".to_owned(), CircuitState::Open)],
207            "and looking at it moved nothing"
208        );
209        assert_eq!(
210            breaker.allow_at("openai", recovered),
211            CircuitDecision::Allow { probe: true },
212            "so the probe is still the next request's to spend"
213        );
214    }
215}