Skip to main content

cachekit/
reliability.rs

1//! Reliability tier: backpressure (bounded backend concurrency), retry with
2//! exponential backoff + jitter, and a closed/open/half-open circuit breaker
3//! around backend operations.
4//!
5//! All three are composed by a private `ReliableBackend` decorator around any
6//! [`crate::backend::Backend`], applied by the builder when a [`ReliabilityConfig`] is set
7//! (see [`crate::CacheKitBuilder::reliability`]). The intent presets
8//! `production`, `secure`, and `io` enable it by default; `minimal` does
9//! not — mirroring the TypeScript SDK's preset posture.
10//!
11//! Composition order is `backpressure(breaker(retry(op)))`:
12//!
13//! - The retry loop is *inside* the breaker (matching the TypeScript SDK's
14//!   `ReliabilityExecutor`), so one exhausted retry sequence counts as a
15//!   single breaker failure, and a fast-failing open breaker never spends
16//!   time retrying.
17//! - The concurrency limiter is *outermost*: one permit per logical cache
18//!   operation, held across the entire breaker/retry sequence. That bounds
19//!   in-flight work including retry amplification (K callers mid-backoff are
20//!   still K permits — new work queues behind them instead of piling onto a
21//!   struggling backend), and a shed call never touches breaker counters or
22//!   half-open probe slots, so the breaker keeps measuring backend health,
23//!   not caller-side overload. A permit holder never re-enters the limiter
24//!   (backend ops don't nest), so holding permits across retry backoff
25//!   cannot deadlock.
26//!
27//! Unlike the TypeScript breaker (which counts every error), only errors
28//! classified retryable by [`crate::error::BackendErrorKind::is_retryable`] (`Transient`,
29//! `Timeout`) count toward opening the circuit: they are the backend-health
30//! signals. `Permanent` / `Authentication` errors are request-specific — five
31//! malformed requests must not cut off healthy traffic.
32//!
33//! Requires a tokio runtime for backoff timers (`redis` and `cachekitio`
34//! backends already do). Not available on wasm32 targets.
35
36use std::future::Future;
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::sync::{Arc, Mutex, PoisonError};
39use std::time::{Duration, Instant};
40
41use async_trait::async_trait;
42
43use crate::backend::{Backend, Freshness, HealthStatus, LockableBackend};
44use crate::client::SharedBackend;
45use crate::error::BackendError;
46use crate::random_unit;
47
48// ── Configuration ────────────────────────────────────────────────────────────
49
50/// Retry policy configuration (truncated exponential backoff with jitter).
51///
52/// Under [`CacheKit::io`](crate::CacheKit::io), all attempts of one operation
53/// also share one deadline: 5 s for a read (`get`, `exists`) and 10 s for a
54/// write (`set`, `delete`), matching the cachekit.io backend's per-attempt
55/// timeouts. An attempt still running at the deadline is cancelled and the
56/// operation fails with a `Timeout` error, and no backoff sleep starts that
57/// would end past it. A cachekit.io request that stops answering therefore
58/// costs one attempt, not `max_attempts` of them, while fast failures (a
59/// `503`, a dropped connection) still get every attempt that fits. Other
60/// backends, and a backend set on the builder after `io`, get no deadline:
61/// their operations run to their own timeouts.
62#[derive(Debug, Clone, PartialEq)]
63pub struct RetryConfig {
64    /// Total attempts, including the first (default: 3). `0` behaves as `1`.
65    pub max_attempts: u32,
66    /// Backoff base delay; attempt *n* waits `base_delay * 2^n` (default: 100 ms).
67    pub base_delay: Duration,
68    /// Backoff ceiling (default: 5 s).
69    pub max_delay: Duration,
70    /// Multiply each delay by a random factor in `[0.5, 1.5)` (default: true).
71    pub jitter: bool,
72}
73
74impl Default for RetryConfig {
75    fn default() -> Self {
76        Self {
77            max_attempts: 3,
78            base_delay: Duration::from_millis(100),
79            max_delay: Duration::from_secs(5),
80            jitter: true,
81        }
82    }
83}
84
85/// Circuit breaker configuration.
86///
87/// Defaults mirror the TypeScript SDK's production preset
88/// (`PRODUCTION_RELIABILITY` in `cachekit-ts/src/intents.ts`).
89#[derive(Debug, Clone, PartialEq)]
90pub struct CircuitBreakerConfig {
91    /// Retryable failures within [`Self::rolling_window`] before the circuit
92    /// opens (default: 5).
93    pub failure_threshold: u32,
94    /// Successes in half-open state required to close the circuit (default: 3).
95    pub success_threshold: u32,
96    /// How long the circuit stays open before allowing half-open probes
97    /// (default: 5 s).
98    pub open_timeout: Duration,
99    /// Maximum concurrent probe calls in half-open state (default: 3).
100    pub half_open_max_calls: u32,
101    /// Rolling window for failure counting (default: 60 s).
102    pub rolling_window: Duration,
103}
104
105impl Default for CircuitBreakerConfig {
106    fn default() -> Self {
107        Self {
108            failure_threshold: 5,
109            success_threshold: 3,
110            open_timeout: Duration::from_secs(5),
111            half_open_max_calls: 3,
112            rolling_window: Duration::from_secs(60),
113        }
114    }
115}
116
117/// Backpressure configuration: bound how many backend data operations may be
118/// in flight at once, so a slow or failing backend cannot exhaust the
119/// caller's connection pool or memory.
120///
121/// Defaults mirror the Python SDK's `BackpressureConfig`
122/// (`max_concurrent_requests: 100`, `queue_size: 1000`, `timeout: 0.1s`).
123///
124/// Over-limit calls first join a bounded waiting queue; a caller that finds
125/// the queue full, or that waits longer than [`Self::acquire_timeout`]
126/// without a permit freeing up, is shed with a
127/// [`crate::error::BackendErrorKind::Backpressure`] error — never queued
128/// unboundedly. Shed calls do not reach the backend and do not count toward
129/// opening the circuit breaker.
130///
131/// On the `#[cachekit]` macro's plain path a shed is outage-class — exactly
132/// like `CircuitOpen`, the wrapped function runs uncached (fail-open);
133/// `secure` paths fail closed on a shed like on every other backend error.
134#[derive(Debug, Clone, PartialEq)]
135pub struct BackpressureConfig {
136    /// Maximum backend data operations in flight at once (default: 100).
137    /// `0` behaves as `1`; values above tokio's `Semaphore::MAX_PERMITS`
138    /// (`usize::MAX >> 3`) are clamped to it, so `usize::MAX` reads as
139    /// "effectively unbounded" rather than panicking the builder.
140    pub max_concurrent: usize,
141    /// Maximum callers waiting for a permit before further calls are shed
142    /// immediately (default: 1000). `0` disables waiting entirely: a call
143    /// that cannot take a permit on the spot is shed.
144    pub max_queue: usize,
145    /// How long a queued caller waits for a permit before it is shed
146    /// (default: 100 ms).
147    pub acquire_timeout: Duration,
148}
149
150impl Default for BackpressureConfig {
151    fn default() -> Self {
152        Self {
153            max_concurrent: 100,
154            max_queue: 1000,
155            acquire_timeout: Duration::from_millis(100),
156        }
157    }
158}
159
160/// Reliability stack configuration: which layers to apply around backend ops.
161///
162/// The `Default` enables all layers with production defaults. Disable a
163/// layer by setting its field to `None`:
164///
165/// ```
166/// use cachekit::reliability::ReliabilityConfig;
167///
168/// let retry_only = ReliabilityConfig {
169///     circuit_breaker: None,
170///     backpressure: None,
171///     ..ReliabilityConfig::default()
172/// };
173/// assert!(retry_only.retry.is_some());
174/// ```
175#[derive(Debug, Clone, PartialEq)]
176pub struct ReliabilityConfig {
177    /// Retry policy, or `None` to propagate every error on first failure.
178    pub retry: Option<RetryConfig>,
179    /// Circuit breaker, or `None` to never fail fast.
180    pub circuit_breaker: Option<CircuitBreakerConfig>,
181    /// Concurrency limiter, or `None` for unbounded backend concurrency.
182    pub backpressure: Option<BackpressureConfig>,
183}
184
185impl Default for ReliabilityConfig {
186    fn default() -> Self {
187        Self {
188            retry: Some(RetryConfig::default()),
189            circuit_breaker: Some(CircuitBreakerConfig::default()),
190            backpressure: Some(BackpressureConfig::default()),
191        }
192    }
193}
194
195impl ReliabilityConfig {
196    /// A config with every layer off — the documented preset opt-out.
197    ///
198    /// Prefer this over spelling out a struct literal with all-`None`
199    /// fields: a literal breaks downstream code every time the stack gains
200    /// a layer (it has, twice).
201    ///
202    /// ```
203    /// use cachekit::reliability::ReliabilityConfig;
204    ///
205    /// assert!(ReliabilityConfig::disabled().is_disabled());
206    /// assert!(!ReliabilityConfig::default().is_disabled());
207    /// ```
208    #[must_use]
209    pub fn disabled() -> Self {
210        Self {
211            retry: None,
212            circuit_breaker: None,
213            backpressure: None,
214        }
215    }
216
217    /// `true` when no layer is enabled — the builder skips the (no-op)
218    /// `ReliableBackend` decorator entirely. Lives here, next to the fields,
219    /// so adding a layer cannot silently miss the builder gate again.
220    #[must_use]
221    pub fn is_disabled(&self) -> bool {
222        self.retry.is_none() && self.circuit_breaker.is_none() && self.backpressure.is_none()
223    }
224}
225
226// ── RetryPolicy ──────────────────────────────────────────────────────────────
227
228/// One deadline shared by every attempt of an operation (see [`RetryConfig`]).
229/// Crate-private and set only by [`CacheKit::io`](crate::CacheKit::io): the
230/// budgets match the cachekit.io client's per-attempt timeouts, and a
231/// backend with slower healthy operations must not be cut short by them.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub(crate) struct RetryDeadlines {
234    /// For `get`, `get_with_freshness` and `exists`.
235    pub(crate) read: Duration,
236    /// For `set` and `delete`.
237    pub(crate) write: Duration,
238}
239
240/// The cachekit.io deadlines: one per-attempt timeout each.
241#[cfg(feature = "cachekitio")]
242pub(crate) const CACHEKITIO_DEADLINES: RetryDeadlines = RetryDeadlines {
243    read: Duration::from_secs(5),
244    write: Duration::from_secs(10),
245};
246
247/// Retries an operation on errors where [`crate::error::BackendErrorKind::is_retryable`] is
248/// true, sleeping a truncated exponential backoff (with jitter) between
249/// attempts, all inside one deadline. `Permanent` and `Authentication` errors,
250/// and a quota or balance deny, propagate immediately.
251#[derive(Debug)]
252pub(crate) struct RetryPolicy {
253    config: RetryConfig,
254}
255
256impl RetryPolicy {
257    pub(crate) fn new(config: RetryConfig) -> Self {
258        Self { config }
259    }
260
261    fn delay(&self, attempt: u32) -> Duration {
262        let exp = self
263            .config
264            .base_delay
265            .saturating_mul(2u32.saturating_pow(attempt));
266        let capped = exp.min(self.config.max_delay);
267        if self.config.jitter {
268            capped.mul_f64(0.5 + random_unit())
269        } else {
270            capped
271        }
272    }
273
274    /// Run `f` until it succeeds, fails for good, or `budget` (if any) runs out.
275    ///
276    /// The tokio clock measures the budget, so a paused test runtime controls it.
277    pub(crate) async fn execute<T, F, Fut>(
278        &self,
279        budget: Option<Duration>,
280        f: F,
281    ) -> Result<T, BackendError>
282    where
283        F: Fn() -> Fut,
284        Fut: Future<Output = Result<T, BackendError>>,
285    {
286        let deadline = budget.map(|budget| tokio::time::Instant::now() + budget);
287        let mut attempt: u32 = 0;
288        loop {
289            let result = match deadline {
290                Some(deadline) => match tokio::time::timeout_at(deadline, f()).await {
291                    Ok(result) => result,
292                    Err(_elapsed) => {
293                        return Err(BackendError::timeout(format!(
294                            "operation did not complete within its {:?} deadline ({} attempt(s))",
295                            budget.unwrap_or_default(),
296                            attempt + 1
297                        )))
298                    }
299                },
300                None => f().await,
301            };
302            match result {
303                Ok(v) => return Ok(v),
304                Err(e)
305                    if e.kind.is_retryable()
306                        && !e.is_quota_denied()
307                        && attempt + 1 < self.config.max_attempts =>
308                {
309                    let delay = self.delay(attempt);
310                    // A retry that cannot start before the deadline would only
311                    // turn this error into a deadline timeout: return it now.
312                    if deadline.is_some_and(|d| tokio::time::Instant::now() + delay >= d) {
313                        return Err(e);
314                    }
315                    tokio::time::sleep(delay).await;
316                    attempt += 1;
317                }
318                Err(e) => return Err(e),
319            }
320        }
321    }
322}
323
324// ── CircuitBreaker ───────────────────────────────────────────────────────────
325
326/// Circuit breaker state, as reported by
327/// [`CacheKit::circuit_state`](crate::CacheKit::circuit_state) and carried by
328/// the `tracing` transition events (`from` / `to` fields).
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum CircuitState {
331    /// Normal operation; calls pass through.
332    Closed,
333    /// Failing fast; calls return a [`crate::error::BackendErrorKind::CircuitOpen`] error
334    /// without reaching the backend.
335    Open,
336    /// Probing recovery with a bounded number of trial calls.
337    HalfOpen,
338}
339
340#[derive(Debug)]
341enum State {
342    Closed,
343    Open { since: Instant },
344    HalfOpen,
345}
346
347impl From<&State> for CircuitState {
348    fn from(state: &State) -> Self {
349        match state {
350            State::Closed => Self::Closed,
351            State::Open { .. } => Self::Open,
352            State::HalfOpen => Self::HalfOpen,
353        }
354    }
355}
356
357#[derive(Debug)]
358struct BreakerInner {
359    state: State,
360    /// Timestamps of counted failures inside the rolling window.
361    failures: Vec<Instant>,
362    half_open_successes: u32,
363    half_open_calls: u32,
364    /// Number of state transitions so far; stamps each [`Transition`] with
365    /// its `seq` under the lock.
366    transitions: u64,
367}
368
369/// How a completed call is reported back to the breaker.
370enum Outcome {
371    Success,
372    /// A retryable-kind failure — a backend-health signal.
373    Failure,
374    /// A non-retryable failure (permanent/auth) — request-specific, does not
375    /// count toward opening the circuit but must release its half-open slot,
376    /// or a burst of permanent errors would wedge the breaker half-open.
377    Neutral,
378}
379
380/// State machine: closed → (failures ≥ threshold in window) → open →
381/// (open_timeout elapsed) → half-open → (successes ≥ threshold) → closed,
382/// or (any counted failure) → open.
383#[derive(Debug)]
384pub(crate) struct CircuitBreaker {
385    /// Process-unique id, stable for the breaker's lifetime; the `breaker`
386    /// field on transition events. Several clients under one subscriber each
387    /// restart `seq` at 1, so `(breaker, seq)` is the ordering key.
388    id: u64,
389    config: CircuitBreakerConfig,
390    inner: Mutex<BreakerInner>,
391}
392
393/// Source of [`CircuitBreaker::id`]s. Per process: fleet-wide correlation
394/// combines the field with the host/process context the subscriber adds.
395static BREAKER_IDS: AtomicU64 = AtomicU64::new(0);
396
397impl CircuitBreaker {
398    pub(crate) fn new(config: CircuitBreakerConfig) -> Self {
399        Self {
400            id: BREAKER_IDS.fetch_add(1, Ordering::Relaxed) + 1,
401            config,
402            inner: Mutex::new(BreakerInner {
403                state: State::Closed,
404                failures: Vec::new(),
405                half_open_successes: 0,
406                half_open_calls: 0,
407                transitions: 0,
408            }),
409        }
410    }
411
412    fn lock(&self) -> std::sync::MutexGuard<'_, BreakerInner> {
413        // A poisoned lock means a panic mid-update; breaker state is advisory,
414        // so recovering the guard is strictly better than propagating panics.
415        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
416    }
417
418    /// Current state (transitions open → half-open lazily on inspection).
419    pub(crate) fn state(&self) -> CircuitState {
420        let mut inner = self.lock();
421        let change = self.maybe_half_open(&mut inner);
422        let state = CircuitState::from(&inner.state);
423        drop(inner);
424        emit(change);
425        state
426    }
427
428    fn maybe_half_open(&self, inner: &mut BreakerInner) -> Option<Transition> {
429        let State::Open { since } = inner.state else {
430            return None;
431        };
432        if since.elapsed() < self.config.open_timeout {
433            return None;
434        }
435        let change = transition(self.id, inner, State::HalfOpen);
436        inner.half_open_successes = 0;
437        inner.half_open_calls = 0;
438        Some(change)
439    }
440
441    /// Admit a call, or fail fast with a circuit-open error.
442    ///
443    /// Returns an RAII [`ProbePermit`]: if the guarded future is cancelled
444    /// (caller timeout/`select!`) or panics before an outcome is recorded,
445    /// the permit's `Drop` releases any half-open probe slot it took —
446    /// otherwise `half_open_max_calls` cancelled probes would wedge the
447    /// breaker half-open forever, fast-failing every call even against a
448    /// recovered backend.
449    fn try_acquire(&self) -> Result<ProbePermit<'_>, BackendError> {
450        let mut inner = self.lock();
451        let change = self.maybe_half_open(&mut inner);
452        let admitted = match inner.state {
453            State::Closed => Ok(ProbePermit {
454                breaker: self,
455                took_slot: false,
456            }),
457            State::Open { .. } => Err(BackendError::circuit_open(
458                "circuit breaker is open: backend calls are failing fast",
459            )),
460            State::HalfOpen => {
461                if inner.half_open_calls >= self.config.half_open_max_calls {
462                    Err(BackendError::circuit_open(
463                        "circuit breaker is half-open and the probe limit is reached",
464                    ))
465                } else {
466                    inner.half_open_calls += 1;
467                    Ok(ProbePermit {
468                        breaker: self,
469                        took_slot: true,
470                    })
471                }
472            }
473        };
474        drop(inner);
475        emit(change);
476        admitted
477    }
478
479    fn record(&self, outcome: &Outcome) {
480        let mut inner = self.lock();
481        let mut change = None;
482        match outcome {
483            Outcome::Success => {
484                if matches!(inner.state, State::HalfOpen) {
485                    inner.half_open_successes += 1;
486                    if inner.half_open_successes >= self.config.success_threshold {
487                        change = Some(transition(self.id, &mut inner, State::Closed));
488                        inner.failures.clear();
489                        inner.half_open_successes = 0;
490                        inner.half_open_calls = 0;
491                    } else {
492                        // Release this probe's slot. `half_open_calls` caps the
493                        // number of *in-flight* probes, so a success that does
494                        // not yet close the breaker must free its slot (exactly
495                        // as `Neutral` does). Without this, a config with
496                        // success_threshold > half_open_max_calls wedges the
497                        // breaker half-open forever: the slots fill, successes
498                        // stall below the threshold, and every subsequent call
499                        // fails fast with CircuitOpen even against a healthy
500                        // backend.
501                        inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
502                    }
503                }
504            }
505            Outcome::Failure => match inner.state {
506                State::HalfOpen => {
507                    let now = Instant::now();
508                    change = Some(transition(self.id, &mut inner, State::Open { since: now }));
509                    inner.half_open_successes = 0;
510                    inner.half_open_calls = 0;
511                }
512                State::Closed => {
513                    let now = Instant::now();
514                    inner.failures.push(now);
515                    let window = self.config.rolling_window;
516                    inner.failures.retain(|t| now.duration_since(*t) <= window);
517                    if inner.failures.len() >= self.config.failure_threshold as usize {
518                        change = Some(transition(self.id, &mut inner, State::Open { since: now }));
519                        inner.failures.clear();
520                    }
521                }
522                // Open without an admitted call cannot report a failure;
523                // ignore rather than extend the open window.
524                State::Open { .. } => {}
525            },
526            Outcome::Neutral => {
527                if matches!(inner.state, State::HalfOpen) {
528                    inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
529                }
530            }
531        }
532        drop(inner);
533        emit(change);
534    }
535}
536
537/// A breaker state change handed back by [`transition`] so the caller can
538/// report it once the breaker lock is released.
539///
540/// `(breaker, seq)` is the ordering key. `seq` is assigned under the lock and
541/// counts this breaker's transitions; `breaker` says which breaker, since
542/// several clients under one subscriber each restart `seq` at 1. Emission
543/// happens after unlock, so two transitions can reach a subscriber in the
544/// wrong wall-clock order (`Closed → Open` committed, unlock, a zero
545/// `open_timeout` lets another caller commit and emit `Open → HalfOpen`
546/// first); a consumer that groups by `breaker` and orders by `seq`
547/// reconstructs each true timeline.
548#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
549#[derive(Debug, Clone, Copy)]
550struct Transition {
551    breaker: u64,
552    seq: u64,
553    from: CircuitState,
554    to: CircuitState,
555}
556
557/// Apply a breaker state change under the lock and return it for [`emit`].
558fn transition(breaker: u64, inner: &mut BreakerInner, to: State) -> Transition {
559    inner.transitions += 1;
560    let change = Transition {
561        breaker,
562        seq: inner.transitions,
563        from: CircuitState::from(&inner.state),
564        to: CircuitState::from(&to),
565    };
566    inner.state = to;
567    change
568}
569
570/// Report a transition on the `tracing` target (feature `tracing`): `warn`
571/// when the circuit opens — traffic is now failing fast — `info` for half-open
572/// and closed. Fields: `breaker`, `seq`, `from`, `to`.
573///
574/// Must be called with the breaker lock **released**: a subscriber may react
575/// to the event by calling
576/// [`CacheKit::circuit_state`](crate::CacheKit::circuit_state), which takes
577/// the same lock, and a synchronous writer must not stall every other
578/// worker's `try_acquire` for the duration of the write. The price is that
579/// emission order is not transition order under contention — see
580/// [`Transition`]; `(breaker, seq)` is what a consumer orders by.
581fn emit(change: Option<Transition>) {
582    #[cfg(feature = "tracing")]
583    if let Some(Transition {
584        breaker,
585        seq,
586        from,
587        to,
588    }) = change
589    {
590        if to == CircuitState::Open {
591            tracing::warn!(breaker, seq, from = ?from, to = ?to, "circuit breaker opened");
592        } else {
593            tracing::info!(breaker, seq, from = ?from, to = ?to, "circuit breaker transitioned");
594        }
595    }
596    #[cfg(not(feature = "tracing"))]
597    let _ = change;
598}
599
600// ── ProbePermit ──────────────────────────────────────────────────────────────
601
602/// RAII token for a breaker-admitted call.
603///
604/// Slot accounting lives in exactly one of two places: [`Self::complete`]
605/// (normal return — the outcome arms of `record` own the bookkeeping from
606/// there) or `Drop` (cancel/panic — release the slot like `Neutral`, no
607/// transition). Manual increment/decrement pairs leaked twice before this
608/// guard existed; do not reintroduce them.
609#[derive(Debug)]
610struct ProbePermit<'a> {
611    breaker: &'a CircuitBreaker,
612    /// Whether this admission consumed a half-open probe slot.
613    took_slot: bool,
614}
615
616impl ProbePermit<'_> {
617    /// Report the call's outcome and disarm the drop-release.
618    fn complete(mut self, outcome: &Outcome) {
619        self.took_slot = false;
620        self.breaker.record(outcome);
621    }
622}
623
624impl Drop for ProbePermit<'_> {
625    fn drop(&mut self) {
626        if !self.took_slot {
627            return;
628        }
629        // No outcome was recorded: the guarded future was cancelled mid-await
630        // or panicked. Free the probe slot so the half-open window can keep
631        // probing; if the breaker transitioned meanwhile (counters reset),
632        // the saturating decrement is a no-op.
633        let mut inner = self.breaker.lock();
634        if matches!(inner.state, State::HalfOpen) {
635            inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
636        }
637    }
638}
639
640// ── ConcurrencyLimiter ───────────────────────────────────────────────────────
641
642/// Bounds concurrent backend data operations with a semaphore and a bounded
643/// waiting queue (two-phase, like the Python SDK's `BackpressureController`):
644/// a saturated limiter admits up to `max_queue` waiters for at most
645/// `acquire_timeout` each; everyone else is shed with a
646/// [`crate::error::BackendErrorKind::Backpressure`] error.
647#[derive(Debug)]
648pub(crate) struct ConcurrencyLimiter {
649    semaphore: tokio::sync::Semaphore,
650    /// Callers currently waiting for a permit (phase-2 queue depth).
651    waiting: std::sync::atomic::AtomicUsize,
652    config: BackpressureConfig,
653}
654
655/// RAII guard for a slot in the waiting queue: decrements `waiting` on every
656/// exit path, including cancellation mid-`acquire` (same lesson as
657/// [`ProbePermit`] — manual increment/decrement pairs leak on cancel).
658struct QueueSlot<'a> {
659    waiting: &'a std::sync::atomic::AtomicUsize,
660}
661
662impl Drop for QueueSlot<'_> {
663    fn drop(&mut self) {
664        self.waiting
665            .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
666    }
667}
668
669impl ConcurrencyLimiter {
670    pub(crate) fn new(config: BackpressureConfig) -> Self {
671        Self {
672            // `Semaphore::new(0)` would shed every call after acquire_timeout
673            // with nothing ever admitted — clamp like RetryConfig's "0
674            // behaves as 1". The upper clamp matters too: `Semaphore::new`
675            // PANICS above `MAX_PERMITS` (usize::MAX >> 3), and usize::MAX
676            // is the natural "effectively unbounded" sentinel a caller will
677            // reach for — a config value must never panic the builder.
678            semaphore: tokio::sync::Semaphore::new(
679                config
680                    .max_concurrent
681                    .clamp(1, tokio::sync::Semaphore::MAX_PERMITS),
682            ),
683            waiting: std::sync::atomic::AtomicUsize::new(0),
684            config,
685        }
686    }
687
688    /// Take a permit, or shed the call.
689    ///
690    /// Phase 1: a free permit is taken on the spot — no queue accounting.
691    /// Phase 2 (saturated): join the bounded waiting queue and wait up to
692    /// `acquire_timeout` for a permit; queue-full and wait-timeout both shed
693    /// with a `Backpressure` error. The returned permit releases on drop, so
694    /// a cancelled or panicking caller can never leak capacity.
695    async fn acquire(&self) -> Result<tokio::sync::SemaphorePermit<'_>, BackendError> {
696        use std::sync::atomic::Ordering;
697
698        if let Ok(permit) = self.semaphore.try_acquire() {
699            return Ok(permit);
700        }
701        if self.waiting.fetch_add(1, Ordering::AcqRel) >= self.config.max_queue {
702            self.waiting.fetch_sub(1, Ordering::AcqRel);
703            return Err(BackendError::backpressure(format!(
704                "backpressure: waiting queue is full (max_queue={}), call shed without reaching the backend",
705                self.config.max_queue
706            )));
707        }
708        let _slot = QueueSlot {
709            waiting: &self.waiting,
710        };
711        match tokio::time::timeout(self.config.acquire_timeout, self.semaphore.acquire()).await {
712            Ok(Ok(permit)) => Ok(permit),
713            // The semaphore is never closed; treat a close defensively as shed.
714            Ok(Err(_closed)) => Err(BackendError::backpressure(
715                "backpressure: limiter unavailable, call shed without reaching the backend",
716            )),
717            Err(_elapsed) => Err(BackendError::backpressure(format!(
718                "backpressure: timed out waiting for a permit after {:?}, call shed without reaching the backend",
719                self.config.acquire_timeout
720            ))),
721        }
722    }
723}
724
725// ── ReliableBackend ──────────────────────────────────────────────────────────
726
727/// Decorator that applies the reliability stack to every cache operation of
728/// an inner [`Backend`]: `backpressure(breaker(retry(op)))`.
729///
730/// - `get`/`set`/`delete`/`exists` take a concurrency-limiter permit, are
731///   retried on retryable errors, and gated by the circuit breaker.
732/// - `health` passes through unguarded — it is a diagnostic and must keep
733///   reporting truthfully while the breaker fails data calls fast (or the
734///   limiter sheds them).
735/// - [`Backend::as_lockable`] forwards to the inner backend so distributed
736///   fill locks bypass the stack (locks are best-effort advisory).
737pub(crate) struct ReliableBackend {
738    inner: SharedBackend,
739    retry: Option<RetryPolicy>,
740    /// Shared with the owning `CacheKit` so
741    /// [`CacheKit::circuit_state`](crate::CacheKit::circuit_state) can read
742    /// it through the type-erased backend.
743    breaker: Option<Arc<CircuitBreaker>>,
744    limiter: Option<ConcurrencyLimiter>,
745    deadlines: Option<RetryDeadlines>,
746}
747
748impl ReliableBackend {
749    pub(crate) fn new(
750        inner: SharedBackend,
751        config: ReliabilityConfig,
752        deadlines: Option<RetryDeadlines>,
753    ) -> Self {
754        Self {
755            inner,
756            deadlines,
757            retry: config.retry.map(RetryPolicy::new),
758            breaker: config
759                .circuit_breaker
760                .map(|config| Arc::new(CircuitBreaker::new(config))),
761            limiter: config.backpressure.map(ConcurrencyLimiter::new),
762        }
763    }
764
765    /// Run `f` through the stack; `budget` is the retry deadline for this op, if any.
766    async fn guarded<T, F, Fut>(&self, budget: Option<Duration>, f: F) -> Result<T, BackendError>
767    where
768        F: Fn() -> Fut,
769        Fut: Future<Output = Result<T, BackendError>>,
770    {
771        // Outermost layer: one permit per logical operation, held across the
772        // whole breaker/retry sequence (see the module docs for why). A shed
773        // call returns here — before touching breaker state.
774        let _permit = match &self.limiter {
775            Some(limiter) => Some(limiter.acquire().await?),
776            None => None,
777        };
778        let permit = match &self.breaker {
779            Some(cb) => Some(cb.try_acquire()?),
780            None => None,
781        };
782        let result = match &self.retry {
783            Some(retry) => retry.execute(budget, f).await,
784            None => f().await,
785        };
786        if let Some(permit) = permit {
787            let outcome = match &result {
788                Ok(_) => Outcome::Success,
789                Err(e) if e.kind.is_retryable() => Outcome::Failure,
790                Err(_) => Outcome::Neutral,
791            };
792            permit.complete(&outcome);
793        }
794        result
795    }
796}
797
798#[cfg_attr(not(feature = "unsync"), async_trait)]
799#[cfg_attr(feature = "unsync", async_trait(?Send))]
800impl Backend for ReliableBackend {
801    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
802        self.guarded(self.deadlines.map(|d| d.read), || self.inner.get(key))
803            .await
804    }
805
806    // Forwarded, not defaulted: the default would call `self.get` and drop
807    // the inner backend's freshness, unbounding the L1 backfill.
808    async fn get_with_freshness(
809        &self,
810        key: &str,
811    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
812        self.guarded(self.deadlines.map(|d| d.read), || {
813            self.inner.get_with_freshness(key)
814        })
815        .await
816    }
817
818    async fn set(
819        &self,
820        key: &str,
821        value: Vec<u8>,
822        ttl: Option<Duration>,
823    ) -> Result<(), BackendError> {
824        // Clone per attempt: the inner call consumes the buffer.
825        self.guarded(self.deadlines.map(|d| d.write), || {
826            self.inner.set(key, value.clone(), ttl)
827        })
828        .await
829    }
830
831    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
832        self.guarded(self.deadlines.map(|d| d.write), || self.inner.delete(key))
833            .await
834    }
835
836    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
837        self.guarded(self.deadlines.map(|d| d.read), || self.inner.exists(key))
838            .await
839    }
840
841    async fn health(&self) -> Result<HealthStatus, BackendError> {
842        self.inner.health().await
843    }
844
845    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
846        self.inner.as_lockable()
847    }
848}
849
850/// Wrap `inner` in a [`ReliableBackend`] and re-share it, handing back the
851/// breaker (if configured) for the client's state accessor.
852pub(crate) fn wrap_reliable(
853    inner: SharedBackend,
854    config: ReliabilityConfig,
855    deadlines: Option<RetryDeadlines>,
856) -> (SharedBackend, Option<Arc<CircuitBreaker>>) {
857    let reliable = ReliableBackend::new(inner, config, deadlines);
858    let breaker = reliable.breaker.clone();
859    #[cfg(not(feature = "unsync"))]
860    let shared: SharedBackend = std::sync::Arc::new(reliable);
861    #[cfg(feature = "unsync")]
862    let shared: SharedBackend = std::rc::Rc::new(reliable);
863    (shared, breaker)
864}
865
866// ── Unit tests ───────────────────────────────────────────────────────────────
867
868#[cfg(test)]
869#[allow(clippy::expect_used)] // test-only: failed acquire/probe should panic loudly
870mod tests {
871    use std::pin::Pin;
872    use std::task::{Context, Poll, Waker};
873
874    use super::*;
875    use crate::error::BackendErrorKind;
876
877    fn breaker(failure_threshold: u32, open_timeout: Duration) -> CircuitBreaker {
878        CircuitBreaker::new(CircuitBreakerConfig {
879            failure_threshold,
880            success_threshold: 2,
881            open_timeout,
882            half_open_max_calls: 2,
883            rolling_window: Duration::from_secs(60),
884        })
885    }
886
887    /// Admit a call and immediately report its outcome.
888    fn admit_and(cb: &CircuitBreaker, outcome: &Outcome) {
889        let permit = cb.try_acquire().expect("breaker admits the call");
890        permit.complete(outcome);
891    }
892
893    #[test]
894    fn breaker_opens_after_threshold_and_fails_fast() {
895        let cb = breaker(3, Duration::from_secs(60));
896        for _ in 0..3 {
897            admit_and(&cb, &Outcome::Failure);
898        }
899        assert_eq!(cb.state(), CircuitState::Open);
900        let err = cb.try_acquire().expect_err("open breaker fails fast");
901        assert_eq!(err.kind, BackendErrorKind::CircuitOpen);
902        assert!(!err.kind.is_retryable());
903    }
904
905    #[test]
906    fn breaker_ignores_permanent_errors() {
907        let cb = breaker(2, Duration::from_secs(60));
908        for _ in 0..10 {
909            admit_and(&cb, &Outcome::Neutral);
910        }
911        assert_eq!(cb.state(), CircuitState::Closed);
912    }
913
914    #[test]
915    fn breaker_half_open_recovers_on_successes() {
916        let cb = breaker(1, Duration::from_millis(0));
917        admit_and(&cb, &Outcome::Failure);
918        // open_timeout of zero → immediately half-open on next inspection
919        assert_eq!(cb.state(), CircuitState::HalfOpen);
920        for _ in 0..2 {
921            admit_and(&cb, &Outcome::Success);
922        }
923        assert_eq!(cb.state(), CircuitState::Closed);
924    }
925
926    #[test]
927    fn breaker_half_open_reopens_on_failure() {
928        let cb = breaker(1, Duration::from_millis(0));
929        admit_and(&cb, &Outcome::Failure);
930        assert_eq!(cb.state(), CircuitState::HalfOpen);
931        admit_and(&cb, &Outcome::Failure);
932        // Freshly re-opened with a zero timeout flips half-open again on
933        // inspection, so assert via the internal state before inspecting.
934        assert!(matches!(cb.lock().state, State::Open { .. }));
935    }
936
937    #[test]
938    fn breaker_half_open_slot_released_by_neutral_outcome() {
939        let cb = breaker(1, Duration::from_millis(0));
940        admit_and(&cb, &Outcome::Failure);
941        assert_eq!(cb.state(), CircuitState::HalfOpen);
942        // Exhaust both probe slots with permanent errors...
943        admit_and(&cb, &Outcome::Neutral);
944        admit_and(&cb, &Outcome::Neutral);
945        // ...and the breaker still admits probes instead of wedging.
946        let permit = cb
947            .try_acquire()
948            .expect("neutral outcomes release their probe slots");
949        permit.complete(&Outcome::Neutral);
950    }
951
952    #[test]
953    fn breaker_half_open_closes_when_success_threshold_exceeds_probe_cap() {
954        // success_threshold (3) deliberately exceeds half_open_max_calls (1):
955        // with a single in-flight probe slot, the breaker can only ever reach
956        // three successes if each non-closing success RELEASES its slot. Before
957        // the fix this wedged half-open forever — the slot filled after the
958        // first success (which stalled at 1 < 3), so no further probe was
959        // admitted and the breaker never re-closed.
960        let cb = CircuitBreaker::new(CircuitBreakerConfig {
961            failure_threshold: 1,
962            success_threshold: 3,
963            open_timeout: Duration::from_millis(0),
964            half_open_max_calls: 1,
965            rolling_window: Duration::from_secs(60),
966        });
967        admit_and(&cb, &Outcome::Failure);
968        assert_eq!(cb.state(), CircuitState::HalfOpen);
969        for _ in 0..3 {
970            let permit = cb
971                .try_acquire()
972                .expect("a non-closing success must release its probe slot");
973            permit.complete(&Outcome::Success);
974        }
975        assert_eq!(cb.state(), CircuitState::Closed);
976    }
977
978    #[test]
979    fn breaker_dropped_permit_releases_probe_slot() {
980        // A probe future cancelled (caller timeout / select!) or panicked
981        // before recording an outcome must not consume its slot forever:
982        // exhaust every half-open slot with plain drops and the breaker must
983        // still admit probes instead of wedging half-open until restart.
984        let cb = breaker(1, Duration::from_millis(0));
985        admit_and(&cb, &Outcome::Failure);
986        assert_eq!(cb.state(), CircuitState::HalfOpen);
987        for _ in 0..2 {
988            let permit = cb.try_acquire().expect("half-open admits a probe");
989            drop(permit); // cancelled before any outcome
990        }
991        let permit = cb
992            .try_acquire()
993            .expect("dropped permits release their probe slots");
994        permit.complete(&Outcome::Success);
995    }
996
997    #[test]
998    fn breaker_closed_permit_drop_does_not_touch_half_open_accounting() {
999        // A call admitted while CLOSED holds no probe slot; cancelling it
1000        // must not free (or corrupt) slots in a half-open window that opened
1001        // after its admission.
1002        let cb = breaker(1, Duration::from_millis(0));
1003        let closed_permit = cb.try_acquire().expect("closed breaker admits calls");
1004        // Another call's failure opens the breaker, then zero timeout flips
1005        // it half-open with a fresh probe window.
1006        admit_and(&cb, &Outcome::Failure);
1007        assert_eq!(cb.state(), CircuitState::HalfOpen);
1008        let p1 = cb.try_acquire().expect("probe slot 1");
1009        let p2 = cb.try_acquire().expect("probe slot 2");
1010        drop(closed_permit); // must be a no-op: it never took a slot
1011        assert!(
1012            cb.try_acquire().is_err(),
1013            "probe cap must still be enforced after a closed-state permit drops"
1014        );
1015        p1.complete(&Outcome::Success);
1016        p2.complete(&Outcome::Success);
1017        assert_eq!(cb.state(), CircuitState::Closed);
1018    }
1019
1020    #[test]
1021    fn reliability_default_enables_backpressure_with_python_parity_defaults() {
1022        let config = ReliabilityConfig::default();
1023        let bp = config.backpressure.expect("backpressure is on by default");
1024        assert_eq!(bp.max_concurrent, 100);
1025        assert_eq!(bp.max_queue, 1000);
1026        assert_eq!(bp.acquire_timeout, Duration::from_millis(100));
1027    }
1028
1029    #[tokio::test]
1030    async fn limiter_clamps_zero_max_concurrent_to_one() {
1031        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1032            max_concurrent: 0,
1033            max_queue: 0,
1034            acquire_timeout: Duration::from_millis(10),
1035        });
1036        let permit = limiter
1037            .acquire()
1038            .await
1039            .expect("0 behaves as 1 — one permit exists");
1040        drop(permit);
1041    }
1042
1043    #[tokio::test]
1044    async fn limiter_clamps_huge_max_concurrent_instead_of_panicking() {
1045        // usize::MAX is the natural "unbounded" sentinel; Semaphore::new
1046        // panics above MAX_PERMITS, so the constructor must clamp.
1047        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1048            max_concurrent: usize::MAX,
1049            max_queue: 0,
1050            acquire_timeout: Duration::from_millis(10),
1051        });
1052        let permit = limiter.acquire().await.expect("clamped limiter admits");
1053        drop(permit);
1054    }
1055
1056    #[tokio::test]
1057    async fn limiter_sheds_immediately_when_queue_disabled() {
1058        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1059            max_concurrent: 1,
1060            max_queue: 0,
1061            acquire_timeout: Duration::from_secs(5),
1062        });
1063        let _held = limiter.acquire().await.expect("first permit");
1064        let start = Instant::now();
1065        let err = limiter
1066            .acquire()
1067            .await
1068            .expect_err("saturated with no waiting queue");
1069        assert_eq!(err.kind, BackendErrorKind::Backpressure);
1070        assert!(!err.kind.is_retryable());
1071        assert!(
1072            start.elapsed() < Duration::from_millis(500),
1073            "queue-full sheds immediately, not after acquire_timeout"
1074        );
1075    }
1076
1077    /// Polls `fut` exactly once, with a waker that does nothing. On a
1078    /// saturated limiter, a caller that joins the waiting queue is `Pending`
1079    /// after this poll and a queue-full shed is already `Ready`, so neither
1080    /// outcome depends on a timer or on how the scheduler runs the test.
1081    fn poll_once<F: Future>(fut: Pin<&mut F>) -> Poll<F::Output> {
1082        fut.poll(&mut Context::from_waker(Waker::noop()))
1083    }
1084
1085    #[tokio::test]
1086    async fn limiter_waiting_slot_released_on_cancelled_wait() {
1087        // A waiter cancelled mid-acquire (caller timeout / select!) must free
1088        // its queue slot via the QueueSlot drop guard. With max_queue: 1, a
1089        // leaked slot would shed the next waiter as queue-full on its first
1090        // poll; parking in the queue instead proves the slot was released.
1091        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1092            max_concurrent: 1,
1093            max_queue: 1,
1094            acquire_timeout: Duration::from_millis(100),
1095        });
1096        let _held = limiter.acquire().await.expect("first permit");
1097        let mut cancelled = Box::pin(limiter.acquire());
1098        assert!(
1099            poll_once(cancelled.as_mut()).is_pending(),
1100            "waiter parks in the queue"
1101        );
1102        drop(cancelled); // the caller gives up mid-wait
1103
1104        let mut next = Box::pin(limiter.acquire());
1105        assert!(
1106            poll_once(next.as_mut()).is_pending(),
1107            "next waiter must park in the queue — a queue-full shed means \
1108             the cancelled waiter leaked its slot"
1109        );
1110        let err = next
1111            .await
1112            .expect_err("permit never frees, waiter times out");
1113        assert_eq!(err.kind, BackendErrorKind::Backpressure);
1114    }
1115
1116    #[tokio::test]
1117    async fn limiter_sheds_queue_full_at_nonzero_boundary() {
1118        // cap 1, queue 1: with the permit held and one waiter parked, a
1119        // third caller must shed instantly as queue-full — pinning the
1120        // fetch_add boundary arithmetic at a nonzero max_queue.
1121        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
1122            max_concurrent: 1,
1123            max_queue: 1,
1124            acquire_timeout: Duration::from_millis(200),
1125        });
1126        let _held = limiter.acquire().await.expect("first permit");
1127        let mut waiter = Box::pin(limiter.acquire());
1128        assert!(
1129            poll_once(waiter.as_mut()).is_pending(),
1130            "waiter parks in the queue"
1131        );
1132        // Ready on the first poll is the queue-full shed: every other shed
1133        // path is only reachable after parking.
1134        let third = poll_once(Box::pin(limiter.acquire()).as_mut());
1135        assert!(
1136            matches!(&third, Poll::Ready(Err(err)) if err.kind == BackendErrorKind::Backpressure),
1137            "queue of 1 is full, so the third caller sheds on its first poll: {third:?}"
1138        );
1139        waiter
1140            .await
1141            .expect_err("the parked waiter itself times out");
1142    }
1143
1144    #[test]
1145    fn retry_delay_is_capped_and_jittered() {
1146        let policy = RetryPolicy::new(RetryConfig {
1147            max_attempts: 5,
1148            base_delay: Duration::from_millis(100),
1149            max_delay: Duration::from_millis(300),
1150            jitter: true,
1151        });
1152        for attempt in 0..10 {
1153            let d = policy.delay(attempt);
1154            // cap 300ms × jitter [0.5, 1.5) → strictly under 450ms
1155            assert!(d < Duration::from_millis(450), "attempt {attempt}: {d:?}");
1156        }
1157        let no_jitter = RetryPolicy::new(RetryConfig {
1158            jitter: false,
1159            ..RetryConfig::default()
1160        });
1161        assert_eq!(no_jitter.delay(0), Duration::from_millis(100));
1162        assert_eq!(no_jitter.delay(1), Duration::from_millis(200));
1163        assert_eq!(no_jitter.delay(20), Duration::from_secs(5));
1164    }
1165
1166    #[tokio::test]
1167    async fn a_quota_deny_is_not_retried() {
1168        let calls = AtomicU64::new(0);
1169        let policy = RetryPolicy::new(RetryConfig::default());
1170
1171        let err = policy
1172            .execute(None, || async {
1173                calls.fetch_add(1, Ordering::SeqCst);
1174                Err::<(), _>(BackendError::from_http_status(429, b"").with_quota_denied())
1175            })
1176            .await
1177            .expect_err("denied");
1178
1179        assert!(err.is_quota_denied());
1180        assert_eq!(calls.load(Ordering::SeqCst), 1);
1181    }
1182}
1183
1184// ── Retry deadline (CacheKit::io) ────────────────────────────────────────────
1185
1186/// The deadline is crate-private, so these run in-crate rather than in
1187/// `tests/reliability_tests.rs`.
1188#[cfg(all(test, feature = "cachekitio"))]
1189#[allow(clippy::expect_used)] // test-only
1190mod deadline_tests {
1191    use std::collections::HashMap;
1192    use std::sync::atomic::{AtomicU32, Ordering};
1193    use std::sync::Arc;
1194    use std::time::Duration;
1195
1196    use async_trait::async_trait;
1197
1198    use super::{CircuitState, ReliabilityConfig, RetryConfig, CACHEKITIO_DEADLINES};
1199    use crate::backend::{Backend, HealthStatus};
1200    use crate::client::SharedBackend;
1201    use crate::error::{BackendError, BackendErrorKind};
1202    use crate::{CacheKit, CachekitError};
1203
1204    /// Every data op sleeps `latency` on the tokio clock, then fails with `kind`;
1205    /// `None` never completes. Counts calls.
1206    #[derive(Debug, Clone)]
1207    struct SlowBackend {
1208        latency: Option<Duration>,
1209        kind: BackendErrorKind,
1210        calls: Arc<AtomicU32>,
1211    }
1212
1213    impl SlowBackend {
1214        fn new_with_handle(
1215            latency: Option<Duration>,
1216            kind: BackendErrorKind,
1217        ) -> (SharedBackend, Self) {
1218            let backend = Self {
1219                latency,
1220                kind,
1221                calls: Arc::new(AtomicU32::new(0)),
1222            };
1223            let handle = backend.clone();
1224            #[cfg(not(feature = "unsync"))]
1225            let shared: SharedBackend = Arc::new(backend);
1226            #[cfg(feature = "unsync")]
1227            let shared: SharedBackend = std::rc::Rc::new(backend);
1228            (shared, handle)
1229        }
1230
1231        fn calls(&self) -> u32 {
1232            self.calls.load(Ordering::SeqCst)
1233        }
1234
1235        async fn op<T>(&self) -> Result<T, BackendError> {
1236            self.calls.fetch_add(1, Ordering::SeqCst);
1237            match self.latency {
1238                Some(latency) => tokio::time::sleep(latency).await,
1239                None => std::future::pending().await,
1240            }
1241            Err(BackendError {
1242                kind: self.kind.clone(),
1243                message: "scripted failure".to_owned(),
1244                source: None,
1245            })
1246        }
1247    }
1248
1249    #[cfg_attr(not(feature = "unsync"), async_trait)]
1250    #[cfg_attr(feature = "unsync", async_trait(?Send))]
1251    impl Backend for SlowBackend {
1252        async fn get(&self, _key: &str) -> Result<Option<Vec<u8>>, BackendError> {
1253            self.op().await
1254        }
1255
1256        async fn set(
1257            &self,
1258            _key: &str,
1259            _value: Vec<u8>,
1260            _ttl: Option<Duration>,
1261        ) -> Result<(), BackendError> {
1262            self.op().await
1263        }
1264
1265        async fn delete(&self, _key: &str) -> Result<bool, BackendError> {
1266            self.op().await
1267        }
1268
1269        async fn exists(&self, _key: &str) -> Result<bool, BackendError> {
1270            self.op().await
1271        }
1272
1273        async fn health(&self) -> Result<HealthStatus, BackendError> {
1274            Ok(HealthStatus {
1275                is_healthy: true,
1276                latency_ms: 0.0,
1277                backend_type: "slow".to_owned(),
1278                details: HashMap::new(),
1279            })
1280        }
1281    }
1282
1283    /// The `CacheKit::io` stack (default layers plus the cachekit.io deadlines)
1284    /// around `backend`, with jitter off so virtual time is exact.
1285    fn default_stack(backend: SharedBackend) -> CacheKit {
1286        let mut builder = CacheKit::builder()
1287            .backend(backend)
1288            .reliability(ReliabilityConfig {
1289                retry: Some(RetryConfig {
1290                    jitter: false,
1291                    ..RetryConfig::default()
1292                }),
1293                ..ReliabilityConfig::default()
1294            })
1295            .no_l1();
1296        builder.retry_deadlines = Some(CACHEKITIO_DEADLINES);
1297        builder.build().expect("client builds")
1298    }
1299
1300    fn backend_kind(err: &CachekitError) -> Option<&BackendErrorKind> {
1301        match err {
1302            CachekitError::Backend(b) => Some(&b.kind),
1303            _ => None,
1304        }
1305    }
1306
1307    /// Run `op` and return its result with the virtual time it took.
1308    async fn timed<T>(op: impl std::future::Future<Output = T>) -> (T, Duration) {
1309        let start = tokio::time::Instant::now();
1310        let out = op.await;
1311        (out, start.elapsed())
1312    }
1313
1314    #[tokio::test(start_paused = true)]
1315    async fn a_stalled_get_fails_at_the_5_s_read_deadline() {
1316        let (backend, handle) = SlowBackend::new_with_handle(None, BackendErrorKind::Timeout);
1317        let client = default_stack(backend);
1318
1319        let (result, elapsed) = timed(client.get::<u32>("k")).await;
1320
1321        let err = result.expect_err("stalled");
1322        assert_eq!(backend_kind(&err), Some(&BackendErrorKind::Timeout));
1323        assert_eq!(elapsed, Duration::from_secs(5));
1324        assert_eq!(handle.calls(), 1);
1325    }
1326
1327    #[tokio::test(start_paused = true)]
1328    async fn a_stalled_set_fails_at_the_10_s_write_deadline() {
1329        let (backend, handle) = SlowBackend::new_with_handle(None, BackendErrorKind::Timeout);
1330        let client = default_stack(backend);
1331
1332        let (result, elapsed) = timed(client.set("k", &7u32)).await;
1333
1334        let err = result.expect_err("stalled");
1335        assert_eq!(backend_kind(&err), Some(&BackendErrorKind::Timeout));
1336        assert_eq!(elapsed, Duration::from_secs(10));
1337        assert_eq!(handle.calls(), 1);
1338    }
1339
1340    /// The old cachekit.io shape: each attempt times out after 30 s. Without the
1341    /// deadline this took 3 x 30 s + 0.3 s backoff = 90.3 s.
1342    #[tokio::test(start_paused = true)]
1343    async fn a_30_s_attempt_timeout_is_cut_to_the_deadline() {
1344        let (backend, handle) =
1345            SlowBackend::new_with_handle(Some(Duration::from_secs(30)), BackendErrorKind::Timeout);
1346        let client = default_stack(backend);
1347
1348        let (result, elapsed) = timed(client.get::<u32>("k")).await;
1349
1350        result.expect_err("timed out");
1351        assert_eq!(elapsed, Duration::from_secs(5));
1352        assert_eq!(handle.calls(), 1);
1353    }
1354
1355    #[tokio::test(start_paused = true)]
1356    async fn fast_failures_still_get_every_attempt() {
1357        let (backend, handle) =
1358            SlowBackend::new_with_handle(Some(Duration::ZERO), BackendErrorKind::Transient);
1359        let client = default_stack(backend);
1360
1361        let (result, elapsed) = timed(client.get::<u32>("k")).await;
1362
1363        let err = result.expect_err("always fails");
1364        assert_eq!(backend_kind(&err), Some(&BackendErrorKind::Transient));
1365        assert_eq!(handle.calls(), 3);
1366        assert_eq!(elapsed, Duration::from_millis(300), "100 + 200 ms backoff");
1367    }
1368
1369    #[tokio::test(start_paused = true)]
1370    async fn no_backoff_starts_past_the_deadline() {
1371        // 4.95 s attempt + 100 ms backoff would start attempt 2 past 5 s: return
1372        // the attempt's own error rather than a deadline timeout.
1373        let (backend, handle) = SlowBackend::new_with_handle(
1374            Some(Duration::from_millis(4950)),
1375            BackendErrorKind::Transient,
1376        );
1377        let client = default_stack(backend);
1378
1379        let (result, elapsed) = timed(client.get::<u32>("k")).await;
1380
1381        let err = result.expect_err("fails");
1382        assert_eq!(backend_kind(&err), Some(&BackendErrorKind::Transient));
1383        assert_eq!(handle.calls(), 1);
1384        assert_eq!(elapsed, Duration::from_millis(4950));
1385    }
1386
1387    #[tokio::test(start_paused = true)]
1388    async fn serial_stalled_gets_open_the_breaker_inside_its_window() {
1389        let (backend, handle) = SlowBackend::new_with_handle(None, BackendErrorKind::Timeout);
1390        let client = default_stack(backend);
1391
1392        // Five 5 s failures fit the 60 s rolling window; at 90 s each they never did.
1393        for _ in 0..5 {
1394            client.get::<u32>("k").await.expect_err("stalled");
1395        }
1396        assert_eq!(client.circuit_state(), Some(CircuitState::Open));
1397
1398        let calls = handle.calls();
1399        let err = client.get::<u32>("k").await.expect_err("open");
1400        assert_eq!(backend_kind(&err), Some(&BackendErrorKind::CircuitOpen));
1401        assert_eq!(handle.calls(), calls, "an open breaker sends nothing");
1402    }
1403}