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`, `encrypted`, 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::{Mutex, PoisonError};
38use std::time::{Duration, Instant};
39
40use async_trait::async_trait;
41
42use crate::backend::{Backend, HealthStatus, LockableBackend};
43use crate::client::SharedBackend;
44use crate::error::BackendError;
45use crate::random_unit;
46
47// ── Configuration ────────────────────────────────────────────────────────────
48
49/// Retry policy configuration (truncated exponential backoff with jitter).
50#[derive(Debug, Clone, PartialEq)]
51pub struct RetryConfig {
52    /// Total attempts, including the first (default: 3). `0` behaves as `1`.
53    pub max_attempts: u32,
54    /// Backoff base delay; attempt *n* waits `base_delay * 2^n` (default: 100 ms).
55    pub base_delay: Duration,
56    /// Backoff ceiling (default: 5 s).
57    pub max_delay: Duration,
58    /// Multiply each delay by a random factor in `[0.5, 1.5)` (default: true).
59    pub jitter: bool,
60}
61
62impl Default for RetryConfig {
63    fn default() -> Self {
64        Self {
65            max_attempts: 3,
66            base_delay: Duration::from_millis(100),
67            max_delay: Duration::from_secs(5),
68            jitter: true,
69        }
70    }
71}
72
73/// Circuit breaker configuration.
74///
75/// Defaults mirror the TypeScript SDK's production preset
76/// (`PRODUCTION_RELIABILITY` in `cachekit-ts/src/intents.ts`).
77#[derive(Debug, Clone, PartialEq)]
78pub struct CircuitBreakerConfig {
79    /// Retryable failures within [`Self::rolling_window`] before the circuit
80    /// opens (default: 5).
81    pub failure_threshold: u32,
82    /// Successes in half-open state required to close the circuit (default: 3).
83    pub success_threshold: u32,
84    /// How long the circuit stays open before allowing half-open probes
85    /// (default: 5 s).
86    pub open_timeout: Duration,
87    /// Maximum concurrent probe calls in half-open state (default: 3).
88    pub half_open_max_calls: u32,
89    /// Rolling window for failure counting (default: 60 s).
90    pub rolling_window: Duration,
91}
92
93impl Default for CircuitBreakerConfig {
94    fn default() -> Self {
95        Self {
96            failure_threshold: 5,
97            success_threshold: 3,
98            open_timeout: Duration::from_secs(5),
99            half_open_max_calls: 3,
100            rolling_window: Duration::from_secs(60),
101        }
102    }
103}
104
105/// Backpressure configuration: bound how many backend data operations may be
106/// in flight at once, so a slow or failing backend cannot exhaust the
107/// caller's connection pool or memory.
108///
109/// Defaults mirror the Python SDK's `BackpressureConfig`
110/// (`max_concurrent_requests: 100`, `queue_size: 1000`, `timeout: 0.1s`).
111///
112/// Over-limit calls first join a bounded waiting queue; a caller that finds
113/// the queue full, or that waits longer than [`Self::acquire_timeout`]
114/// without a permit freeing up, is shed with a
115/// [`crate::error::BackendErrorKind::Backpressure`] error — never queued
116/// unboundedly. Shed calls do not reach the backend and do not count toward
117/// opening the circuit breaker.
118///
119/// On the `#[cachekit]` macro's plain path a shed is outage-class — exactly
120/// like `CircuitOpen`, the wrapped function runs uncached (fail-open);
121/// `secure` paths fail closed on a shed like on every other backend error.
122#[derive(Debug, Clone, PartialEq)]
123pub struct BackpressureConfig {
124    /// Maximum backend data operations in flight at once (default: 100).
125    /// `0` behaves as `1`; values above tokio's `Semaphore::MAX_PERMITS`
126    /// (`usize::MAX >> 3`) are clamped to it, so `usize::MAX` reads as
127    /// "effectively unbounded" rather than panicking the builder.
128    pub max_concurrent: usize,
129    /// Maximum callers waiting for a permit before further calls are shed
130    /// immediately (default: 1000). `0` disables waiting entirely: a call
131    /// that cannot take a permit on the spot is shed.
132    pub max_queue: usize,
133    /// How long a queued caller waits for a permit before it is shed
134    /// (default: 100 ms).
135    pub acquire_timeout: Duration,
136}
137
138impl Default for BackpressureConfig {
139    fn default() -> Self {
140        Self {
141            max_concurrent: 100,
142            max_queue: 1000,
143            acquire_timeout: Duration::from_millis(100),
144        }
145    }
146}
147
148/// Reliability stack configuration: which layers to apply around backend ops.
149///
150/// The `Default` enables all layers with production defaults. Disable a
151/// layer by setting its field to `None`:
152///
153/// ```
154/// use cachekit::reliability::ReliabilityConfig;
155///
156/// let retry_only = ReliabilityConfig {
157///     circuit_breaker: None,
158///     backpressure: None,
159///     ..ReliabilityConfig::default()
160/// };
161/// assert!(retry_only.retry.is_some());
162/// ```
163#[derive(Debug, Clone, PartialEq)]
164pub struct ReliabilityConfig {
165    /// Retry policy, or `None` to propagate every error on first failure.
166    pub retry: Option<RetryConfig>,
167    /// Circuit breaker, or `None` to never fail fast.
168    pub circuit_breaker: Option<CircuitBreakerConfig>,
169    /// Concurrency limiter, or `None` for unbounded backend concurrency.
170    pub backpressure: Option<BackpressureConfig>,
171}
172
173impl Default for ReliabilityConfig {
174    fn default() -> Self {
175        Self {
176            retry: Some(RetryConfig::default()),
177            circuit_breaker: Some(CircuitBreakerConfig::default()),
178            backpressure: Some(BackpressureConfig::default()),
179        }
180    }
181}
182
183impl ReliabilityConfig {
184    /// A config with every layer off — the documented preset opt-out.
185    ///
186    /// Prefer this over spelling out a struct literal with all-`None`
187    /// fields: a literal breaks downstream code every time the stack gains
188    /// a layer (it has, twice).
189    ///
190    /// ```
191    /// use cachekit::reliability::ReliabilityConfig;
192    ///
193    /// assert!(ReliabilityConfig::disabled().is_disabled());
194    /// assert!(!ReliabilityConfig::default().is_disabled());
195    /// ```
196    #[must_use]
197    pub fn disabled() -> Self {
198        Self {
199            retry: None,
200            circuit_breaker: None,
201            backpressure: None,
202        }
203    }
204
205    /// `true` when no layer is enabled — the builder skips the (no-op)
206    /// `ReliableBackend` decorator entirely. Lives here, next to the fields,
207    /// so adding a layer cannot silently miss the builder gate again.
208    #[must_use]
209    pub fn is_disabled(&self) -> bool {
210        self.retry.is_none() && self.circuit_breaker.is_none() && self.backpressure.is_none()
211    }
212}
213
214// ── RetryPolicy ──────────────────────────────────────────────────────────────
215
216/// Retries an operation on errors where [`crate::error::BackendErrorKind::is_retryable`] is
217/// true, sleeping a truncated exponential backoff (with jitter) between
218/// attempts. `Permanent` and `Authentication` errors propagate immediately.
219#[derive(Debug)]
220pub(crate) struct RetryPolicy {
221    config: RetryConfig,
222}
223
224impl RetryPolicy {
225    pub(crate) fn new(config: RetryConfig) -> Self {
226        Self { config }
227    }
228
229    fn delay(&self, attempt: u32) -> Duration {
230        let exp = self
231            .config
232            .base_delay
233            .saturating_mul(2u32.saturating_pow(attempt));
234        let capped = exp.min(self.config.max_delay);
235        if self.config.jitter {
236            capped.mul_f64(0.5 + random_unit())
237        } else {
238            capped
239        }
240    }
241
242    pub(crate) async fn execute<T, F, Fut>(&self, f: F) -> Result<T, BackendError>
243    where
244        F: Fn() -> Fut,
245        Fut: Future<Output = Result<T, BackendError>>,
246    {
247        let mut attempt: u32 = 0;
248        loop {
249            match f().await {
250                Ok(v) => return Ok(v),
251                Err(e) if e.kind.is_retryable() && attempt + 1 < self.config.max_attempts => {
252                    tokio::time::sleep(self.delay(attempt)).await;
253                    attempt += 1;
254                }
255                Err(e) => return Err(e),
256            }
257        }
258    }
259}
260
261// ── CircuitBreaker ───────────────────────────────────────────────────────────
262
263/// Circuit breaker states. Test-only until the observability tier (LAB-101)
264/// exposes breaker state at runtime — a public type with no producer is API
265/// noise (expert-panel cut).
266#[cfg(test)]
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub(crate) enum CircuitState {
269    /// Normal operation; calls pass through.
270    Closed,
271    /// Failing fast; calls return a [`crate::error::BackendErrorKind::CircuitOpen`] error
272    /// without reaching the backend.
273    Open,
274    /// Probing recovery with a bounded number of trial calls.
275    HalfOpen,
276}
277
278#[derive(Debug)]
279enum State {
280    Closed,
281    Open { since: Instant },
282    HalfOpen,
283}
284
285#[derive(Debug)]
286struct BreakerInner {
287    state: State,
288    /// Timestamps of counted failures inside the rolling window.
289    failures: Vec<Instant>,
290    half_open_successes: u32,
291    half_open_calls: u32,
292}
293
294/// How a completed call is reported back to the breaker.
295enum Outcome {
296    Success,
297    /// A retryable-kind failure — a backend-health signal.
298    Failure,
299    /// A non-retryable failure (permanent/auth) — request-specific, does not
300    /// count toward opening the circuit but must release its half-open slot,
301    /// or a burst of permanent errors would wedge the breaker half-open.
302    Neutral,
303}
304
305/// State machine: closed → (failures ≥ threshold in window) → open →
306/// (open_timeout elapsed) → half-open → (successes ≥ threshold) → closed,
307/// or (any counted failure) → open.
308#[derive(Debug)]
309pub(crate) struct CircuitBreaker {
310    config: CircuitBreakerConfig,
311    inner: Mutex<BreakerInner>,
312}
313
314impl CircuitBreaker {
315    pub(crate) fn new(config: CircuitBreakerConfig) -> Self {
316        Self {
317            config,
318            inner: Mutex::new(BreakerInner {
319                state: State::Closed,
320                failures: Vec::new(),
321                half_open_successes: 0,
322                half_open_calls: 0,
323            }),
324        }
325    }
326
327    fn lock(&self) -> std::sync::MutexGuard<'_, BreakerInner> {
328        // A poisoned lock means a panic mid-update; breaker state is advisory,
329        // so recovering the guard is strictly better than propagating panics.
330        self.inner.lock().unwrap_or_else(PoisonError::into_inner)
331    }
332
333    /// Current state (transitions open → half-open lazily on inspection).
334    /// Test-only until the observability tier (LAB-101) needs it at runtime.
335    #[cfg(test)]
336    pub(crate) fn state(&self) -> CircuitState {
337        let mut inner = self.lock();
338        self.maybe_half_open(&mut inner);
339        match inner.state {
340            State::Closed => CircuitState::Closed,
341            State::Open { .. } => CircuitState::Open,
342            State::HalfOpen => CircuitState::HalfOpen,
343        }
344    }
345
346    fn maybe_half_open(&self, inner: &mut BreakerInner) {
347        if let State::Open { since } = inner.state {
348            if since.elapsed() >= self.config.open_timeout {
349                inner.state = State::HalfOpen;
350                inner.half_open_successes = 0;
351                inner.half_open_calls = 0;
352            }
353        }
354    }
355
356    /// Admit a call, or fail fast with a circuit-open error.
357    ///
358    /// Returns an RAII [`ProbePermit`]: if the guarded future is cancelled
359    /// (caller timeout/`select!`) or panics before an outcome is recorded,
360    /// the permit's `Drop` releases any half-open probe slot it took —
361    /// otherwise `half_open_max_calls` cancelled probes would wedge the
362    /// breaker half-open forever, fast-failing every call even against a
363    /// recovered backend.
364    fn try_acquire(&self) -> Result<ProbePermit<'_>, BackendError> {
365        let mut inner = self.lock();
366        self.maybe_half_open(&mut inner);
367        match inner.state {
368            State::Closed => Ok(ProbePermit {
369                breaker: self,
370                took_slot: false,
371            }),
372            State::Open { .. } => Err(BackendError::circuit_open(
373                "circuit breaker is open: backend calls are failing fast",
374            )),
375            State::HalfOpen => {
376                if inner.half_open_calls >= self.config.half_open_max_calls {
377                    Err(BackendError::circuit_open(
378                        "circuit breaker is half-open and the probe limit is reached",
379                    ))
380                } else {
381                    inner.half_open_calls += 1;
382                    Ok(ProbePermit {
383                        breaker: self,
384                        took_slot: true,
385                    })
386                }
387            }
388        }
389    }
390
391    fn record(&self, outcome: &Outcome) {
392        let mut inner = self.lock();
393        match outcome {
394            Outcome::Success => {
395                if matches!(inner.state, State::HalfOpen) {
396                    inner.half_open_successes += 1;
397                    if inner.half_open_successes >= self.config.success_threshold {
398                        inner.state = State::Closed;
399                        inner.failures.clear();
400                        inner.half_open_successes = 0;
401                        inner.half_open_calls = 0;
402                    } else {
403                        // Release this probe's slot. `half_open_calls` caps the
404                        // number of *in-flight* probes, so a success that does
405                        // not yet close the breaker must free its slot (exactly
406                        // as `Neutral` does). Without this, a config with
407                        // success_threshold > half_open_max_calls wedges the
408                        // breaker half-open forever: the slots fill, successes
409                        // stall below the threshold, and every subsequent call
410                        // fails fast with CircuitOpen even against a healthy
411                        // backend.
412                        inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
413                    }
414                }
415            }
416            Outcome::Failure => match inner.state {
417                State::HalfOpen => {
418                    inner.state = State::Open {
419                        since: Instant::now(),
420                    };
421                    inner.half_open_successes = 0;
422                    inner.half_open_calls = 0;
423                }
424                State::Closed => {
425                    let now = Instant::now();
426                    inner.failures.push(now);
427                    let window = self.config.rolling_window;
428                    inner.failures.retain(|t| now.duration_since(*t) <= window);
429                    if inner.failures.len() >= self.config.failure_threshold as usize {
430                        inner.state = State::Open { since: now };
431                        inner.failures.clear();
432                    }
433                }
434                // Open without an admitted call cannot report a failure;
435                // ignore rather than extend the open window.
436                State::Open { .. } => {}
437            },
438            Outcome::Neutral => {
439                if matches!(inner.state, State::HalfOpen) {
440                    inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
441                }
442            }
443        }
444    }
445}
446
447// ── ProbePermit ──────────────────────────────────────────────────────────────
448
449/// RAII token for a breaker-admitted call.
450///
451/// Slot accounting lives in exactly one of two places: [`Self::complete`]
452/// (normal return — the outcome arms of `record` own the bookkeeping from
453/// there) or `Drop` (cancel/panic — release the slot like `Neutral`, no
454/// transition). Manual increment/decrement pairs leaked twice before this
455/// guard existed; do not reintroduce them.
456#[derive(Debug)]
457struct ProbePermit<'a> {
458    breaker: &'a CircuitBreaker,
459    /// Whether this admission consumed a half-open probe slot.
460    took_slot: bool,
461}
462
463impl ProbePermit<'_> {
464    /// Report the call's outcome and disarm the drop-release.
465    fn complete(mut self, outcome: &Outcome) {
466        self.took_slot = false;
467        self.breaker.record(outcome);
468    }
469}
470
471impl Drop for ProbePermit<'_> {
472    fn drop(&mut self) {
473        if !self.took_slot {
474            return;
475        }
476        // No outcome was recorded: the guarded future was cancelled mid-await
477        // or panicked. Free the probe slot so the half-open window can keep
478        // probing; if the breaker transitioned meanwhile (counters reset),
479        // the saturating decrement is a no-op.
480        let mut inner = self.breaker.lock();
481        if matches!(inner.state, State::HalfOpen) {
482            inner.half_open_calls = inner.half_open_calls.saturating_sub(1);
483        }
484    }
485}
486
487// ── ConcurrencyLimiter ───────────────────────────────────────────────────────
488
489/// Bounds concurrent backend data operations with a semaphore and a bounded
490/// waiting queue (two-phase, like the Python SDK's `BackpressureController`):
491/// a saturated limiter admits up to `max_queue` waiters for at most
492/// `acquire_timeout` each; everyone else is shed with a
493/// [`crate::error::BackendErrorKind::Backpressure`] error.
494#[derive(Debug)]
495pub(crate) struct ConcurrencyLimiter {
496    semaphore: tokio::sync::Semaphore,
497    /// Callers currently waiting for a permit (phase-2 queue depth).
498    waiting: std::sync::atomic::AtomicUsize,
499    config: BackpressureConfig,
500}
501
502/// RAII guard for a slot in the waiting queue: decrements `waiting` on every
503/// exit path, including cancellation mid-`acquire` (same lesson as
504/// [`ProbePermit`] — manual increment/decrement pairs leak on cancel).
505struct QueueSlot<'a> {
506    waiting: &'a std::sync::atomic::AtomicUsize,
507}
508
509impl Drop for QueueSlot<'_> {
510    fn drop(&mut self) {
511        self.waiting
512            .fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
513    }
514}
515
516impl ConcurrencyLimiter {
517    pub(crate) fn new(config: BackpressureConfig) -> Self {
518        Self {
519            // `Semaphore::new(0)` would shed every call after acquire_timeout
520            // with nothing ever admitted — clamp like RetryConfig's "0
521            // behaves as 1". The upper clamp matters too: `Semaphore::new`
522            // PANICS above `MAX_PERMITS` (usize::MAX >> 3), and usize::MAX
523            // is the natural "effectively unbounded" sentinel a caller will
524            // reach for — a config value must never panic the builder.
525            semaphore: tokio::sync::Semaphore::new(
526                config
527                    .max_concurrent
528                    .clamp(1, tokio::sync::Semaphore::MAX_PERMITS),
529            ),
530            waiting: std::sync::atomic::AtomicUsize::new(0),
531            config,
532        }
533    }
534
535    /// Take a permit, or shed the call.
536    ///
537    /// Phase 1: a free permit is taken on the spot — no queue accounting.
538    /// Phase 2 (saturated): join the bounded waiting queue and wait up to
539    /// `acquire_timeout` for a permit; queue-full and wait-timeout both shed
540    /// with a `Backpressure` error. The returned permit releases on drop, so
541    /// a cancelled or panicking caller can never leak capacity.
542    async fn acquire(&self) -> Result<tokio::sync::SemaphorePermit<'_>, BackendError> {
543        use std::sync::atomic::Ordering;
544
545        if let Ok(permit) = self.semaphore.try_acquire() {
546            return Ok(permit);
547        }
548        if self.waiting.fetch_add(1, Ordering::AcqRel) >= self.config.max_queue {
549            self.waiting.fetch_sub(1, Ordering::AcqRel);
550            return Err(BackendError::backpressure(format!(
551                "backpressure: waiting queue is full (max_queue={}), call shed without reaching the backend",
552                self.config.max_queue
553            )));
554        }
555        let _slot = QueueSlot {
556            waiting: &self.waiting,
557        };
558        match tokio::time::timeout(self.config.acquire_timeout, self.semaphore.acquire()).await {
559            Ok(Ok(permit)) => Ok(permit),
560            // The semaphore is never closed; treat a close defensively as shed.
561            Ok(Err(_closed)) => Err(BackendError::backpressure(
562                "backpressure: limiter unavailable, call shed without reaching the backend",
563            )),
564            Err(_elapsed) => Err(BackendError::backpressure(format!(
565                "backpressure: timed out waiting for a permit after {:?}, call shed without reaching the backend",
566                self.config.acquire_timeout
567            ))),
568        }
569    }
570}
571
572// ── ReliableBackend ──────────────────────────────────────────────────────────
573
574/// Decorator that applies the reliability stack to every cache operation of
575/// an inner [`Backend`]: `backpressure(breaker(retry(op)))`.
576///
577/// - `get`/`set`/`delete`/`exists` take a concurrency-limiter permit, are
578///   retried on retryable errors, and gated by the circuit breaker.
579/// - `health` passes through unguarded — it is a diagnostic and must keep
580///   reporting truthfully while the breaker fails data calls fast (or the
581///   limiter sheds them).
582/// - [`Backend::as_lockable`] forwards to the inner backend so distributed
583///   fill locks bypass the stack (locks are best-effort advisory).
584pub(crate) struct ReliableBackend {
585    inner: SharedBackend,
586    retry: Option<RetryPolicy>,
587    breaker: Option<CircuitBreaker>,
588    limiter: Option<ConcurrencyLimiter>,
589}
590
591impl ReliableBackend {
592    pub(crate) fn new(inner: SharedBackend, config: ReliabilityConfig) -> Self {
593        Self {
594            inner,
595            retry: config.retry.map(RetryPolicy::new),
596            breaker: config.circuit_breaker.map(CircuitBreaker::new),
597            limiter: config.backpressure.map(ConcurrencyLimiter::new),
598        }
599    }
600
601    async fn guarded<T, F, Fut>(&self, f: F) -> Result<T, BackendError>
602    where
603        F: Fn() -> Fut,
604        Fut: Future<Output = Result<T, BackendError>>,
605    {
606        // Outermost layer: one permit per logical operation, held across the
607        // whole breaker/retry sequence (see the module docs for why). A shed
608        // call returns here — before touching breaker state.
609        let _permit = match &self.limiter {
610            Some(limiter) => Some(limiter.acquire().await?),
611            None => None,
612        };
613        let permit = match &self.breaker {
614            Some(cb) => Some(cb.try_acquire()?),
615            None => None,
616        };
617        let result = match &self.retry {
618            Some(retry) => retry.execute(f).await,
619            None => f().await,
620        };
621        if let Some(permit) = permit {
622            let outcome = match &result {
623                Ok(_) => Outcome::Success,
624                Err(e) if e.kind.is_retryable() => Outcome::Failure,
625                Err(_) => Outcome::Neutral,
626            };
627            permit.complete(&outcome);
628        }
629        result
630    }
631}
632
633#[cfg_attr(not(feature = "unsync"), async_trait)]
634#[cfg_attr(feature = "unsync", async_trait(?Send))]
635impl Backend for ReliableBackend {
636    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
637        self.guarded(|| self.inner.get(key)).await
638    }
639
640    async fn set(
641        &self,
642        key: &str,
643        value: Vec<u8>,
644        ttl: Option<Duration>,
645    ) -> Result<(), BackendError> {
646        // Clone per attempt: the inner call consumes the buffer.
647        self.guarded(|| self.inner.set(key, value.clone(), ttl))
648            .await
649    }
650
651    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
652        self.guarded(|| self.inner.delete(key)).await
653    }
654
655    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
656        self.guarded(|| self.inner.exists(key)).await
657    }
658
659    async fn health(&self) -> Result<HealthStatus, BackendError> {
660        self.inner.health().await
661    }
662
663    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
664        self.inner.as_lockable()
665    }
666}
667
668/// Wrap `inner` in a [`ReliableBackend`] and re-share it.
669#[cfg(not(feature = "unsync"))]
670pub(crate) fn wrap_reliable(inner: SharedBackend, config: ReliabilityConfig) -> SharedBackend {
671    std::sync::Arc::new(ReliableBackend::new(inner, config))
672}
673
674/// Wrap `inner` in a [`ReliableBackend`] and re-share it (`?Send` variant).
675#[cfg(feature = "unsync")]
676pub(crate) fn wrap_reliable(inner: SharedBackend, config: ReliabilityConfig) -> SharedBackend {
677    std::rc::Rc::new(ReliableBackend::new(inner, config))
678}
679
680// ── Unit tests ───────────────────────────────────────────────────────────────
681
682#[cfg(test)]
683#[allow(clippy::expect_used)] // test-only: failed acquire/probe should panic loudly
684mod tests {
685    use super::*;
686    use crate::error::BackendErrorKind;
687
688    fn breaker(failure_threshold: u32, open_timeout: Duration) -> CircuitBreaker {
689        CircuitBreaker::new(CircuitBreakerConfig {
690            failure_threshold,
691            success_threshold: 2,
692            open_timeout,
693            half_open_max_calls: 2,
694            rolling_window: Duration::from_secs(60),
695        })
696    }
697
698    /// Admit a call and immediately report its outcome.
699    fn admit_and(cb: &CircuitBreaker, outcome: &Outcome) {
700        let permit = cb.try_acquire().expect("breaker admits the call");
701        permit.complete(outcome);
702    }
703
704    #[test]
705    fn breaker_opens_after_threshold_and_fails_fast() {
706        let cb = breaker(3, Duration::from_secs(60));
707        for _ in 0..3 {
708            admit_and(&cb, &Outcome::Failure);
709        }
710        assert_eq!(cb.state(), CircuitState::Open);
711        let err = cb.try_acquire().expect_err("open breaker fails fast");
712        assert_eq!(err.kind, BackendErrorKind::CircuitOpen);
713        assert!(!err.kind.is_retryable());
714    }
715
716    #[test]
717    fn breaker_ignores_permanent_errors() {
718        let cb = breaker(2, Duration::from_secs(60));
719        for _ in 0..10 {
720            admit_and(&cb, &Outcome::Neutral);
721        }
722        assert_eq!(cb.state(), CircuitState::Closed);
723    }
724
725    #[test]
726    fn breaker_half_open_recovers_on_successes() {
727        let cb = breaker(1, Duration::from_millis(0));
728        admit_and(&cb, &Outcome::Failure);
729        // open_timeout of zero → immediately half-open on next inspection
730        assert_eq!(cb.state(), CircuitState::HalfOpen);
731        for _ in 0..2 {
732            admit_and(&cb, &Outcome::Success);
733        }
734        assert_eq!(cb.state(), CircuitState::Closed);
735    }
736
737    #[test]
738    fn breaker_half_open_reopens_on_failure() {
739        let cb = breaker(1, Duration::from_millis(0));
740        admit_and(&cb, &Outcome::Failure);
741        assert_eq!(cb.state(), CircuitState::HalfOpen);
742        admit_and(&cb, &Outcome::Failure);
743        // Freshly re-opened with a zero timeout flips half-open again on
744        // inspection, so assert via the internal state before inspecting.
745        assert!(matches!(cb.lock().state, State::Open { .. }));
746    }
747
748    #[test]
749    fn breaker_half_open_slot_released_by_neutral_outcome() {
750        let cb = breaker(1, Duration::from_millis(0));
751        admit_and(&cb, &Outcome::Failure);
752        assert_eq!(cb.state(), CircuitState::HalfOpen);
753        // Exhaust both probe slots with permanent errors...
754        admit_and(&cb, &Outcome::Neutral);
755        admit_and(&cb, &Outcome::Neutral);
756        // ...and the breaker still admits probes instead of wedging.
757        let permit = cb
758            .try_acquire()
759            .expect("neutral outcomes release their probe slots");
760        permit.complete(&Outcome::Neutral);
761    }
762
763    #[test]
764    fn breaker_half_open_closes_when_success_threshold_exceeds_probe_cap() {
765        // success_threshold (3) deliberately exceeds half_open_max_calls (1):
766        // with a single in-flight probe slot, the breaker can only ever reach
767        // three successes if each non-closing success RELEASES its slot. Before
768        // the fix this wedged half-open forever — the slot filled after the
769        // first success (which stalled at 1 < 3), so no further probe was
770        // admitted and the breaker never re-closed.
771        let cb = CircuitBreaker::new(CircuitBreakerConfig {
772            failure_threshold: 1,
773            success_threshold: 3,
774            open_timeout: Duration::from_millis(0),
775            half_open_max_calls: 1,
776            rolling_window: Duration::from_secs(60),
777        });
778        admit_and(&cb, &Outcome::Failure);
779        assert_eq!(cb.state(), CircuitState::HalfOpen);
780        for _ in 0..3 {
781            let permit = cb
782                .try_acquire()
783                .expect("a non-closing success must release its probe slot");
784            permit.complete(&Outcome::Success);
785        }
786        assert_eq!(cb.state(), CircuitState::Closed);
787    }
788
789    #[test]
790    fn breaker_dropped_permit_releases_probe_slot() {
791        // A probe future cancelled (caller timeout / select!) or panicked
792        // before recording an outcome must not consume its slot forever:
793        // exhaust every half-open slot with plain drops and the breaker must
794        // still admit probes instead of wedging half-open until restart.
795        let cb = breaker(1, Duration::from_millis(0));
796        admit_and(&cb, &Outcome::Failure);
797        assert_eq!(cb.state(), CircuitState::HalfOpen);
798        for _ in 0..2 {
799            let permit = cb.try_acquire().expect("half-open admits a probe");
800            drop(permit); // cancelled before any outcome
801        }
802        let permit = cb
803            .try_acquire()
804            .expect("dropped permits release their probe slots");
805        permit.complete(&Outcome::Success);
806    }
807
808    #[test]
809    fn breaker_closed_permit_drop_does_not_touch_half_open_accounting() {
810        // A call admitted while CLOSED holds no probe slot; cancelling it
811        // must not free (or corrupt) slots in a half-open window that opened
812        // after its admission.
813        let cb = breaker(1, Duration::from_millis(0));
814        let closed_permit = cb.try_acquire().expect("closed breaker admits calls");
815        // Another call's failure opens the breaker, then zero timeout flips
816        // it half-open with a fresh probe window.
817        admit_and(&cb, &Outcome::Failure);
818        assert_eq!(cb.state(), CircuitState::HalfOpen);
819        let p1 = cb.try_acquire().expect("probe slot 1");
820        let p2 = cb.try_acquire().expect("probe slot 2");
821        drop(closed_permit); // must be a no-op: it never took a slot
822        assert!(
823            cb.try_acquire().is_err(),
824            "probe cap must still be enforced after a closed-state permit drops"
825        );
826        p1.complete(&Outcome::Success);
827        p2.complete(&Outcome::Success);
828        assert_eq!(cb.state(), CircuitState::Closed);
829    }
830
831    #[test]
832    fn reliability_default_enables_backpressure_with_python_parity_defaults() {
833        let config = ReliabilityConfig::default();
834        let bp = config.backpressure.expect("backpressure is on by default");
835        assert_eq!(bp.max_concurrent, 100);
836        assert_eq!(bp.max_queue, 1000);
837        assert_eq!(bp.acquire_timeout, Duration::from_millis(100));
838    }
839
840    #[tokio::test]
841    async fn limiter_clamps_zero_max_concurrent_to_one() {
842        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
843            max_concurrent: 0,
844            max_queue: 0,
845            acquire_timeout: Duration::from_millis(10),
846        });
847        let permit = limiter
848            .acquire()
849            .await
850            .expect("0 behaves as 1 — one permit exists");
851        drop(permit);
852    }
853
854    #[tokio::test]
855    async fn limiter_clamps_huge_max_concurrent_instead_of_panicking() {
856        // usize::MAX is the natural "unbounded" sentinel; Semaphore::new
857        // panics above MAX_PERMITS, so the constructor must clamp.
858        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
859            max_concurrent: usize::MAX,
860            max_queue: 0,
861            acquire_timeout: Duration::from_millis(10),
862        });
863        let permit = limiter.acquire().await.expect("clamped limiter admits");
864        drop(permit);
865    }
866
867    #[tokio::test]
868    async fn limiter_sheds_immediately_when_queue_disabled() {
869        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
870            max_concurrent: 1,
871            max_queue: 0,
872            acquire_timeout: Duration::from_secs(5),
873        });
874        let _held = limiter.acquire().await.expect("first permit");
875        let start = Instant::now();
876        let err = limiter
877            .acquire()
878            .await
879            .expect_err("saturated with no waiting queue");
880        assert_eq!(err.kind, BackendErrorKind::Backpressure);
881        assert!(!err.kind.is_retryable());
882        assert!(
883            start.elapsed() < Duration::from_millis(500),
884            "queue-full sheds immediately, not after acquire_timeout"
885        );
886    }
887
888    #[tokio::test]
889    async fn limiter_waiting_slot_released_on_cancelled_wait() {
890        // A waiter cancelled mid-acquire (caller timeout / select!) must free
891        // its queue slot via the QueueSlot drop guard. With max_queue: 1, a
892        // leaked slot would shed the next waiter instantly as queue-full;
893        // joining the queue (observable as waiting out the acquire_timeout)
894        // proves the slot was released.
895        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
896            max_concurrent: 1,
897            max_queue: 1,
898            acquire_timeout: Duration::from_millis(100),
899        });
900        let _held = limiter.acquire().await.expect("first permit");
901        let cancelled = tokio::time::timeout(Duration::from_millis(20), limiter.acquire()).await;
902        assert!(cancelled.is_err(), "waiter cancelled from outside");
903
904        let start = Instant::now();
905        let err = limiter
906            .acquire()
907            .await
908            .expect_err("permit never frees, waiter times out");
909        assert_eq!(err.kind, BackendErrorKind::Backpressure);
910        assert!(
911            start.elapsed() >= Duration::from_millis(80),
912            "must join the queue and wait out acquire_timeout — an instant \
913             queue-full shed means the cancelled waiter leaked its slot"
914        );
915    }
916
917    #[tokio::test]
918    async fn limiter_sheds_queue_full_at_nonzero_boundary() {
919        // cap 1, queue 1: with the permit held and one waiter parked, a
920        // third caller must shed instantly as queue-full — pinning the
921        // fetch_add boundary arithmetic at a nonzero max_queue.
922        let limiter = ConcurrencyLimiter::new(BackpressureConfig {
923            max_concurrent: 1,
924            max_queue: 1,
925            acquire_timeout: Duration::from_millis(200),
926        });
927        let _held = limiter.acquire().await.expect("first permit");
928        let waiter = async {
929            // Parks in the queue immediately and times out after 200 ms.
930            limiter.acquire().await
931        };
932        let third = async {
933            tokio::time::sleep(Duration::from_millis(50)).await; // waiter parked
934            let start = Instant::now();
935            let err = limiter.acquire().await.expect_err("queue of 1 is full");
936            assert_eq!(err.kind, BackendErrorKind::Backpressure);
937            assert!(
938                start.elapsed() < Duration::from_millis(100),
939                "queue-full sheds instantly, not after the wait timeout"
940            );
941        };
942        let (waited, ()) = tokio::join!(waiter, third);
943        waited.expect_err("the parked waiter itself times out");
944    }
945
946    #[test]
947    fn retry_delay_is_capped_and_jittered() {
948        let policy = RetryPolicy::new(RetryConfig {
949            max_attempts: 5,
950            base_delay: Duration::from_millis(100),
951            max_delay: Duration::from_millis(300),
952            jitter: true,
953        });
954        for attempt in 0..10 {
955            let d = policy.delay(attempt);
956            // cap 300ms × jitter [0.5, 1.5) → strictly under 450ms
957            assert!(d < Duration::from_millis(450), "attempt {attempt}: {d:?}");
958        }
959        let no_jitter = RetryPolicy::new(RetryConfig {
960            jitter: false,
961            ..RetryConfig::default()
962        });
963        assert_eq!(no_jitter.delay(0), Duration::from_millis(100));
964        assert_eq!(no_jitter.delay(1), Duration::from_millis(200));
965        assert_eq!(no_jitter.delay(20), Duration::from_secs(5));
966    }
967}