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}