Skip to main content

hey_sdk/resilience/
mod.rs

1//! Three ways to stop a struggling HEY from taking the caller down with it: a circuit
2//! breaker that gives up on an operation that keeps failing, a bulkhead that caps how many
3//! calls of one kind run at once, and a rate limiter that keeps the caller inside a budget
4//! of its own.
5//!
6//! Each layer is installed on the builder and keeps its counters per scope — the service
7//! and operation as the model names them, `Boxes.ListBoxes` — so a failing search does not
8//! shut down the mailbox reads next to it.
9//!
10//! A call meets them at the gate, before anything is sent. The bulkhead is the one that may
11//! hold it there: a scope already running all it may keeps the caller waiting for up to
12//! [`BulkheadConfig::max_wait`] and refuses only when no room comes free.
13//!
14//! ```no_run
15//! use hey_sdk::resilience::{CircuitBreakerConfig, ResilienceConfig};
16//! use hey_sdk::{Client, Config, StaticTokenProvider};
17//!
18//! # fn build() -> Result<Client, hey_sdk::Error> {
19//! Client::builder(Config::default())
20//!     .token_provider(StaticTokenProvider::new("token"))
21//!     .circuit_breaker(CircuitBreakerConfig {
22//!         failure_threshold: 3,
23//!         ..CircuitBreakerConfig::default()
24//!     })
25//!     .build()
26//! # }
27//! ```
28//!
29//! Or all three at their defaults with [`ClientBuilder::resilience`] and
30//! [`ResilienceConfig::default`].
31//!
32//! # Where this parts company with Go
33//!
34//! Go gates through a separate `GatingHooks` interface and its chain stops at the first
35//! member that implements it, so installing two resilience layers there silently runs only
36//! the outer one. Here gating is part of [`Hooks`] itself and each layer holds the hooks it
37//! wrapped, so every installed layer is asked. Two layers means two gates, which is what
38//! asking for both should mean.
39
40mod bulkhead;
41mod circuit_breaker;
42mod rate_limit;
43
44use std::collections::HashMap;
45use std::fmt;
46use std::sync::{Arc, Mutex, PoisonError};
47use std::time::{Duration, Instant};
48
49use async_trait::async_trait;
50
51use crate::client::ClientBuilder;
52use crate::error::{Error, ErrorCode};
53use crate::observability::{Hooks, OperationInfo, OperationState, RequestInfo, RequestResult};
54
55pub use bulkhead::{Bulkhead, BulkheadConfig, BulkheadPermit};
56pub use circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
57pub use rate_limit::{RateLimitConfig, RateLimiter};
58
59/// Every resilience layer at once. A member left `None` is a layer left off;
60/// [`ResilienceConfig::default`] turns all three on at their own defaults.
61#[derive(Debug, Clone)]
62pub struct ResilienceConfig {
63    /// The breaker every scope gets one of.
64    pub circuit_breaker: Option<CircuitBreakerConfig>,
65    /// The bulkhead every scope gets one of.
66    pub bulkhead: Option<BulkheadConfig>,
67    /// The one limiter every scope spends from.
68    pub rate_limit: Option<RateLimitConfig>,
69}
70
71impl Default for ResilienceConfig {
72    fn default() -> ResilienceConfig {
73        ResilienceConfig {
74            circuit_breaker: Some(CircuitBreakerConfig::default()),
75            bulkhead: Some(BulkheadConfig::default()),
76            rate_limit: Some(RateLimitConfig::default()),
77        }
78    }
79}
80
81impl ResilienceConfig {
82    /// No layer at all: what to start from when turning on one or two by hand, and what
83    /// [`ClientBuilder::circuit_breaker`] and its neighbours build on.
84    pub fn none() -> ResilienceConfig {
85        ResilienceConfig {
86            circuit_breaker: None,
87            bulkhead: None,
88            rate_limit: None,
89        }
90    }
91}
92
93impl ClientBuilder {
94    /// Installs the layers the config asks for, keeping whatever hooks the builder already
95    /// carries: they still hear about every operation and request.
96    #[must_use]
97    pub fn resilience(mut self, config: ResilienceConfig) -> ClientBuilder {
98        self.hooks = Arc::new(ResilienceHooks::new(self.hooks.clone(), config));
99        self
100    }
101
102    /// Installs the circuit breaker alone.
103    #[must_use]
104    pub fn circuit_breaker(self, config: CircuitBreakerConfig) -> ClientBuilder {
105        self.resilience(ResilienceConfig {
106            circuit_breaker: Some(config),
107            ..ResilienceConfig::none()
108        })
109    }
110
111    /// Installs the bulkhead alone.
112    #[must_use]
113    pub fn bulkhead(self, config: BulkheadConfig) -> ClientBuilder {
114        self.resilience(ResilienceConfig {
115            bulkhead: Some(config),
116            ..ResilienceConfig::none()
117        })
118    }
119
120    /// Installs the rate limiter alone.
121    #[must_use]
122    pub fn rate_limit(self, config: RateLimitConfig) -> ClientBuilder {
123        self.resilience(ResilienceConfig {
124            rate_limit: Some(config),
125            ..ResilienceConfig::none()
126        })
127    }
128}
129
130/// Whether a failed operation counts against the scope's circuit breaker. A call the SDK
131/// refused for itself does not — the breaker would be counting its own work — and neither
132/// does anything HEY answered for itself, however unwelcome. What is left is HEY failing to
133/// answer: a network error or a 5xx.
134///
135/// Go also trips on any error that is not its own `*Error`, since a stray error from
136/// somewhere else says nothing about HEY's health. Every error here is [`Error`], so that
137/// case has no counterpart; the nearest thing, an [`ErrorCode::Api`] carrying a 5xx, trips.
138pub fn should_trip_circuit(error: &Error) -> bool {
139    match error.code() {
140        ErrorCode::CircuitOpen | ErrorCode::BulkheadFull | ErrorCode::RateLimit => false,
141        ErrorCode::Network => true,
142        _ => error.http_status().is_some_and(|status| status >= 500),
143    }
144}
145
146/// Where a breaker or a limiter reads the time. Tests hand one they move themselves.
147#[derive(Clone)]
148pub struct Clock(Arc<dyn Fn() -> Instant + Send + Sync>);
149
150impl Clock {
151    /// A clock that asks `now` each time it is read.
152    pub fn new(now: impl Fn() -> Instant + Send + Sync + 'static) -> Clock {
153        Clock(Arc::new(now))
154    }
155
156    /// The moment it is, as this clock has it.
157    pub fn now(&self) -> Instant {
158        (self.0)()
159    }
160}
161
162impl Default for Clock {
163    fn default() -> Clock {
164        Clock::new(Instant::now)
165    }
166}
167
168impl fmt::Debug for Clock {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.write_str("Clock")
171    }
172}
173
174/// The [`Hooks`] the layers run behind: they gate an operation before it is sent, hold the
175/// bulkhead permit for as long as it runs, and tell the breaker how it went. The hooks the
176/// builder already carried are wrapped rather than replaced, and hear everything they would
177/// have heard on their own.
178pub struct ResilienceHooks {
179    inner: Arc<dyn Hooks>,
180    circuit_breakers: Option<Registry<CircuitBreaker>>,
181    bulkheads: Option<Registry<Bulkhead>>,
182    rate_limiter: Option<RateLimiter>,
183    /// Permits taken in [`Hooks::on_operation_gate`], waiting for the
184    /// [`Hooks::on_operation_start`] that carries them into the operation. Go keys the same
185    /// handoff by a counter it puts in the context; the SDK calls start straight after a
186    /// gate it let through, with nothing awaited in between, so a permit is only ever
187    /// waiting for the operation that took it. Permits from one scope are interchangeable,
188    /// which is all the scope needs to key them by.
189    pending: Mutex<HashMap<String, Vec<BulkheadPermit>>>,
190}
191
192impl ResilienceHooks {
193    /// The layers `config` asks for, wrapped around `inner`.
194    pub fn new(inner: Arc<dyn Hooks>, config: ResilienceConfig) -> ResilienceHooks {
195        ResilienceHooks {
196            inner,
197            circuit_breakers: config
198                .circuit_breaker
199                .map(|config| Registry::new(move || CircuitBreaker::new(config.clone()))),
200            bulkheads: config
201                .bulkhead
202                .map(|config| Registry::new(move || Bulkhead::new(config.clone()))),
203            rate_limiter: config.rate_limit.map(RateLimiter::new),
204            pending: Mutex::default(),
205        }
206    }
207
208    /// The layers in the order a call meets them: the breaker says whether this operation is
209    /// worth trying, the bulkhead makes room for it, and the limiter spends a token. A
210    /// permit taken on the way is released by being dropped when a later layer refuses.
211    ///
212    /// Waiting for room is the bulkhead's own doing: [`Bulkhead::acquire`] holds the caller
213    /// for up to [`BulkheadConfig::max_wait`] before giving up on the scope.
214    async fn admit(&self, scope: &str) -> Result<Option<BulkheadPermit>, Error> {
215        if let Some(breakers) = &self.circuit_breakers
216            && !breakers.get(scope).allow()
217        {
218            return Err(Error::circuit_open());
219        }
220
221        let permit = match &self.bulkheads {
222            Some(bulkheads) => Some(bulkheads.get(scope).acquire().await?),
223            None => None,
224        };
225
226        if let Some(limiter) = &self.rate_limiter
227            && !limiter.allow()
228        {
229            return Err(Error::rate_limited());
230        }
231
232        Ok(permit)
233    }
234
235    fn record(&self, scope: &str, outcome: Result<(), &Error>) {
236        if let Some(breakers) = &self.circuit_breakers {
237            let breaker = breakers.get(scope);
238            match outcome {
239                Ok(()) => breaker.record_success(),
240                Err(error) if should_trip_circuit(error) => breaker.record_failure(),
241                Err(_) => {}
242            }
243        }
244    }
245}
246
247#[async_trait]
248impl Hooks for ResilienceHooks {
249    async fn on_operation_gate(&self, op: &OperationInfo) -> Result<(), Error> {
250        let scope = scope_of(op);
251        let permit = self.admit(&scope).await?;
252        self.inner.on_operation_gate(op).await?;
253        if let Some(permit) = permit {
254            self.pending
255                .lock()
256                .unwrap_or_else(PoisonError::into_inner)
257                .entry(scope)
258                .or_default()
259                .push(permit);
260        }
261        Ok(())
262    }
263
264    fn on_operation_start(&self, op: &OperationInfo) -> OperationState {
265        let permit = self
266            .pending
267            .lock()
268            .unwrap_or_else(PoisonError::into_inner)
269            .get_mut(&scope_of(op))
270            .and_then(Vec::pop);
271        Some(Box::new(Held {
272            permit,
273            inner: self.inner.on_operation_start(op),
274        }))
275    }
276
277    fn on_operation_end(
278        &self,
279        op: &OperationInfo,
280        state: OperationState,
281        outcome: Result<(), &Error>,
282        duration: Duration,
283    ) {
284        let held = match state.and_then(|state| state.downcast::<Held>().ok()) {
285            Some(held) => *held,
286            None => Held::default(),
287        };
288        drop(held.permit);
289        self.record(&scope_of(op), outcome);
290        self.inner
291            .on_operation_end(op, held.inner, outcome, duration);
292    }
293
294    fn on_request_start(&self, info: &RequestInfo) {
295        self.inner.on_request_start(info);
296    }
297
298    /// HEY asking for a wait is worth more than the limiter's own accounting, so a 429 arms
299    /// the limiter for as long as it asked — a minute when it did not say. A 503 is only
300    /// sometimes about load, so it arms the limiter only when it named a wait.
301    fn on_request_end(&self, info: &RequestInfo, result: &RequestResult<'_>) {
302        if let Some(limiter) = &self.rate_limiter {
303            let asked = result.retry_after.filter(|seconds| *seconds > 0);
304            match result.status.map(|status| status.as_u16()) {
305                Some(429) => limiter.set_retry_after_in(Duration::from_secs(asked.unwrap_or(60))),
306                Some(503) => {
307                    if let Some(seconds) = asked {
308                        limiter.set_retry_after_in(Duration::from_secs(seconds));
309                    }
310                }
311                _ => {}
312            }
313        }
314        self.inner.on_request_end(info, result);
315    }
316
317    fn on_retry(&self, info: &RequestInfo, next_attempt: u32, cause: &Error) {
318        self.inner.on_retry(info, next_attempt, cause);
319    }
320}
321
322/// What an operation carries from its start to its end: the bulkhead permit it holds, and
323/// whatever the wrapped hooks made for themselves.
324#[derive(Default)]
325struct Held {
326    permit: Option<BulkheadPermit>,
327    inner: OperationState,
328}
329
330fn scope_of(op: &OperationInfo) -> String {
331    format!("{}.{}", op.service, op.operation)
332}
333
334/// One breaker or bulkhead per scope, built on first sight and kept for the life of the
335/// client.
336struct Registry<T> {
337    build: Box<dyn Fn() -> T + Send + Sync>,
338    entries: Mutex<HashMap<String, Arc<T>>>,
339}
340
341impl<T> Registry<T> {
342    fn new(build: impl Fn() -> T + Send + Sync + 'static) -> Registry<T> {
343        Registry {
344            build: Box::new(build),
345            entries: Mutex::default(),
346        }
347    }
348
349    fn get(&self, scope: &str) -> Arc<T> {
350        self.entries
351            .lock()
352            .unwrap_or_else(PoisonError::into_inner)
353            .entry(scope.to_string())
354            .or_insert_with(|| Arc::new((self.build)()))
355            .clone()
356    }
357}
358
359/// A clock a test moves itself, and the handle it moves it by.
360#[cfg(test)]
361pub(crate) fn test_clock() -> (Clock, Arc<Mutex<Instant>>) {
362    let now = Arc::new(Mutex::new(Instant::now()));
363    let reading = now.clone();
364    (Clock::new(move || *reading.lock().unwrap()), now)
365}
366
367#[cfg(test)]
368pub(crate) fn advance(clock: &Arc<Mutex<Instant>>, elapsed: Duration) {
369    let mut now = clock.lock().unwrap();
370    *now += elapsed;
371}
372
373#[cfg(test)]
374mod tests {
375    use std::borrow::Cow;
376
377    use super::*;
378
379    #[test]
380    fn the_default_config_installs_every_layer() {
381        let config = ResilienceConfig::default();
382
383        assert!(config.circuit_breaker.is_some());
384        assert!(config.bulkhead.is_some());
385        assert!(config.rate_limit.is_some());
386    }
387
388    #[test]
389    fn only_what_hey_failed_to_answer_trips_the_breaker() {
390        let cases = [
391            (Error::circuit_open(), false),
392            (Error::bulkhead_full(), false),
393            (Error::rate_limited(), false),
394            (Error::rate_limit(Some(3)), false),
395            (
396                Error::network(std::io::Error::other("connection refused")),
397                true,
398            ),
399            (Error::api(500, "boom"), true),
400            (Error::api(503, "unavailable"), true),
401            (Error::api(400, "bad request"), false),
402            (Error::auth("authentication required"), false),
403            (Error::usage("bad argument"), false),
404            (Error::not_found("box", 1), false),
405        ];
406
407        for (error, expected) in cases {
408            assert_eq!(expected, should_trip_circuit(&error), "{error}");
409        }
410    }
411
412    #[test]
413    fn a_registry_keeps_one_breaker_per_scope() {
414        let registry = Registry::new(|| CircuitBreaker::new(CircuitBreakerConfig::default()));
415
416        assert!(Arc::ptr_eq(
417            &registry.get("scope1"),
418            &registry.get("scope1")
419        ));
420        assert!(!Arc::ptr_eq(
421            &registry.get("scope1"),
422            &registry.get("scope2")
423        ));
424    }
425
426    #[test]
427    fn a_registry_keeps_one_bulkhead_per_scope() {
428        let registry = Registry::new(|| Bulkhead::new(BulkheadConfig::default()));
429
430        assert!(Arc::ptr_eq(
431            &registry.get("scope1"),
432            &registry.get("scope1")
433        ));
434        assert!(!Arc::ptr_eq(
435            &registry.get("scope1"),
436            &registry.get("scope2")
437        ));
438    }
439
440    #[tokio::test]
441    async fn a_breaker_only_config_refuses_once_the_scope_has_failed_enough() {
442        let hooks = hooks(ResilienceConfig {
443            circuit_breaker: Some(CircuitBreakerConfig {
444                failure_threshold: 2,
445                ..CircuitBreakerConfig::default()
446            }),
447            ..ResilienceConfig::none()
448        });
449
450        fail(&hooks, &operation("Boxes", "ListBoxes")).await;
451        assert!(
452            hooks
453                .on_operation_gate(&operation("Boxes", "ListBoxes"))
454                .await
455                .is_ok()
456        );
457        fail(&hooks, &operation("Boxes", "ListBoxes")).await;
458
459        let refused = hooks
460            .on_operation_gate(&operation("Boxes", "ListBoxes"))
461            .await
462            .unwrap_err();
463        assert_eq!(ErrorCode::CircuitOpen, refused.code());
464        assert_eq!("circuit breaker is open", refused.message());
465        assert!(
466            hooks
467                .on_operation_gate(&operation("Boxes", "GetBox"))
468                .await
469                .is_ok()
470        );
471    }
472
473    #[tokio::test]
474    async fn a_bulkhead_with_no_wait_refuses_a_scope_that_is_already_busy() {
475        let hooks = hooks(ResilienceConfig {
476            bulkhead: Some(BulkheadConfig {
477                max_concurrent: 1,
478                max_wait: Duration::ZERO,
479            }),
480            ..ResilienceConfig::none()
481        });
482        let op = operation("Boxes", "ListBoxes");
483
484        hooks.on_operation_gate(&op).await.unwrap();
485        let state = hooks.on_operation_start(&op);
486
487        let refused = hooks.on_operation_gate(&op).await.unwrap_err();
488        assert_eq!(ErrorCode::BulkheadFull, refused.code());
489        assert_eq!("bulkhead is full", refused.message());
490        assert!(
491            hooks
492                .on_operation_gate(&operation("Boxes", "GetBox"))
493                .await
494                .is_ok()
495        );
496
497        hooks.on_operation_end(&op, state, Ok(()), Duration::ZERO);
498        assert!(hooks.on_operation_gate(&op).await.is_ok());
499    }
500
501    #[tokio::test]
502    async fn a_limiter_only_config_refuses_once_the_budget_is_spent() {
503        let hooks = hooks(ResilienceConfig {
504            rate_limit: Some(RateLimitConfig {
505                requests_per_second: 0.0001,
506                burst_size: 1,
507                ..RateLimitConfig::default()
508            }),
509            ..ResilienceConfig::none()
510        });
511        let op = operation("Boxes", "ListBoxes");
512
513        hooks.on_operation_gate(&op).await.unwrap();
514
515        let refused = hooks.on_operation_gate(&op).await.unwrap_err();
516        assert_eq!(ErrorCode::RateLimit, refused.code());
517        assert_eq!("rate limit exceeded", refused.message());
518        assert_eq!(None, refused.http_status());
519    }
520
521    /// The limiter refusing has to give back the permit the bulkhead just handed out, or the
522    /// scope would lose a slot to every refusal.
523    #[tokio::test]
524    async fn a_refused_call_gives_back_the_permit_it_took() {
525        let hooks = hooks(ResilienceConfig {
526            bulkhead: Some(BulkheadConfig {
527                max_concurrent: 1,
528                max_wait: Duration::ZERO,
529            }),
530            rate_limit: Some(RateLimitConfig {
531                requests_per_second: 0.0001,
532                burst_size: 1,
533                ..RateLimitConfig::default()
534            }),
535            ..ResilienceConfig::none()
536        });
537        let op = operation("Boxes", "ListBoxes");
538
539        hooks.on_operation_gate(&op).await.unwrap();
540        let state = hooks.on_operation_start(&op);
541        hooks.on_operation_end(&op, state, Ok(()), Duration::ZERO);
542
543        assert_eq!(
544            ErrorCode::RateLimit,
545            hooks.on_operation_gate(&op).await.unwrap_err().code()
546        );
547        assert_eq!(
548            1,
549            hooks
550                .bulkheads
551                .as_ref()
552                .unwrap()
553                .get("Boxes.ListBoxes")
554                .available()
555        );
556    }
557
558    /// And so does a layer installed underneath this one refusing, which is how two
559    /// resilience layers stack.
560    #[tokio::test]
561    async fn a_call_refused_below_gives_back_the_permit_too() {
562        let hooks = ResilienceHooks::new(
563            Arc::new(Refusing),
564            ResilienceConfig {
565                bulkhead: Some(BulkheadConfig {
566                    max_concurrent: 1,
567                    max_wait: Duration::ZERO,
568                }),
569                ..ResilienceConfig::none()
570            },
571        );
572        let op = operation("Boxes", "ListBoxes");
573
574        let refused = hooks.on_operation_gate(&op).await.unwrap_err();
575
576        assert_eq!(ErrorCode::Usage, refused.code());
577        assert_eq!(
578            1,
579            hooks
580                .bulkheads
581                .as_ref()
582                .unwrap()
583                .get("Boxes.ListBoxes")
584                .available()
585        );
586    }
587
588    #[tokio::test]
589    async fn the_hooks_underneath_still_hear_everything() {
590        let recorder = Arc::new(Recorder::default());
591        let hooks = ResilienceHooks::new(recorder.clone(), ResilienceConfig::default());
592        let op = operation("Boxes", "ListBoxes");
593
594        hooks.on_operation_gate(&op).await.unwrap();
595        let state = hooks.on_operation_start(&op);
596        hooks.on_operation_end(&op, state, Ok(()), Duration::ZERO);
597
598        assert_eq!(
599            vec!["gate", "start", "end carrying its own"],
600            recorder.entries()
601        );
602    }
603
604    fn hooks(config: ResilienceConfig) -> ResilienceHooks {
605        ResilienceHooks::new(Arc::new(crate::observability::NoopHooks), config)
606    }
607
608    async fn fail(hooks: &ResilienceHooks, op: &OperationInfo) {
609        hooks.on_operation_gate(op).await.unwrap();
610        let state = hooks.on_operation_start(op);
611        hooks.on_operation_end(op, state, Err(&Error::api(500, "boom")), Duration::ZERO);
612    }
613
614    fn operation(service: &'static str, operation: &'static str) -> OperationInfo {
615        OperationInfo {
616            service: Cow::Borrowed(service),
617            operation: Cow::Borrowed(operation),
618            resource_type: Cow::Borrowed("box"),
619            is_mutation: false,
620            resource_id: None,
621        }
622    }
623
624    #[derive(Default)]
625    struct Recorder {
626        entries: Mutex<Vec<String>>,
627    }
628
629    impl Recorder {
630        fn entries(&self) -> Vec<String> {
631            self.entries.lock().unwrap().clone()
632        }
633
634        fn record(&self, entry: &str) {
635            self.entries.lock().unwrap().push(entry.to_string());
636        }
637    }
638
639    #[async_trait]
640    impl Hooks for Recorder {
641        async fn on_operation_gate(&self, _op: &OperationInfo) -> Result<(), Error> {
642            self.record("gate");
643            Ok(())
644        }
645
646        fn on_operation_start(&self, _op: &OperationInfo) -> OperationState {
647            self.record("start");
648            Some(Box::new("its own"))
649        }
650
651        fn on_operation_end(
652            &self,
653            _op: &OperationInfo,
654            state: OperationState,
655            _outcome: Result<(), &Error>,
656            _duration: Duration,
657        ) {
658            let carried = match state.and_then(|state| state.downcast::<&str>().ok()) {
659                Some(carried) => *carried,
660                None => "nothing",
661            };
662            self.record(&format!("end carrying {carried}"));
663        }
664    }
665
666    /// A layer installed before the resilience ones that turns everything away.
667    struct Refusing;
668
669    #[async_trait]
670    impl Hooks for Refusing {
671        async fn on_operation_gate(&self, _op: &OperationInfo) -> Result<(), Error> {
672            Err(Error::usage("blocked"))
673        }
674    }
675}