Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
| `cachekitio` | ✅ | HTTP backend for [api.cachekit.io](https://api.cachekit.io) via [reqwest](https://crates.io/crates/reqwest) + rustls |
| `encryption` | ✅ | Zero-knowledge AES-256-GCM via [cachekit-core](https://crates.io/crates/cachekit-core) |
| `l1` | ✅ | In-process L1 cache via [moka](https://crates.io/crates/moka), with stale-while-revalidate (native) |
| `reliability` | ✅ | Retry with backoff + jitter, circuit breaker, distributed fill locks (native only) |
| `reliability` | ✅ | Retry with backoff + jitter, circuit breaker, backpressure, distributed fill locks (native only) |
| `redis` | ❌ | Redis backend via [fred](https://crates.io/crates/fred) (native only) |
| `memcached` | ❌ | Memcached backend via [rust-memcache](https://crates.io/crates/memcache) (native only) |
| `file` | ❌ | Local filesystem backend, byte-compatible with cachekit-py's File backend (native only) |
Expand Down Expand Up @@ -391,11 +391,12 @@ With the `reliability` feature (default, native only), the `production`, `encryp
|:------|:-------------|:---------|
| **Retry** | Truncated exponential backoff + jitter on transient/timeout errors (`BackendErrorKind::is_retryable`); permanent and auth errors propagate immediately | 3 attempts, 100 ms base, 5 s cap, jitter ×[0.5, 1.5) |
| **Circuit breaker** | closed → open after N retryable failures in a rolling window; fails fast (`BackendErrorKind::CircuitOpen`) while open; half-open probes recovery | threshold 5, window 60 s, open 5 s, 3 probes, close after 3 successes |
| **Graceful degradation** | On outage-class backend failure (transient, timeout, open breaker), `#[cachekit]`-wrapped functions run uncached (fail-open); permanent/auth errors propagate — a wrong API key fails loudly. `secure` paths fail **closed** on everything — encrypted workloads never silently degrade | built into the macro |
| **Backpressure** | Bounds concurrent backend data ops with a semaphore + bounded waiting queue; over-limit calls are shed with `BackendErrorKind::Backpressure` before reaching the backend — a slow backend can't exhaust the caller's connection pool or memory | 100 concurrent, 1 000 queued, 100 ms wait (Python SDK parity) |
| **Graceful degradation** | On outage-class backend failure (transient, timeout, open breaker, backpressure shed), `#[cachekit]`-wrapped functions run uncached (fail-open); permanent/auth errors propagate — a wrong API key fails loudly. `secure` paths fail **closed** on everything — encrypted workloads never silently degrade | built into the macro |
| **Single-flight** | Concurrent misses of one key collapse to a single execution: per-key in-process lock, plus a distributed fill lock across processes on lock-capable backends (cachekit.io, Redis) | in-process always on; cross-process 5 s lock, 100 ms polls |
| **Stale-while-revalidate** | Stale-but-unexpired L1 hits are served immediately while one single-flight-deduplicated background task re-executes the function ([details](#stale-while-revalidate-swr)) | on by default with `l1` (native); threshold 0.5 × entry TTL ±10% jitter |

Retry sits *inside* the breaker (one exhausted retry sequence = one breaker failure), degradation, single-flight, and SWR sit in the `#[cachekit]` macro around the read path — the same composition as the TypeScript SDK's `ReliabilityExecutor` and the Python decorator.
Retry sits *inside* the breaker (one exhausted retry sequence = one breaker failure) and backpressure sits *outside* both — one permit per logical operation, held across the whole retry sequence, so retry amplification is bounded and shed calls never skew breaker state. Degradation, single-flight, and SWR sit in the `#[cachekit]` macro around the read path — the same composition as the TypeScript SDK's `ReliabilityExecutor` and the Python decorator.

```rust,ignore
use std::time::Duration;
Expand All @@ -409,9 +410,9 @@ let cache = CacheKit::production("redis://localhost:6379").await?
})
.build()?;

// Opt a preset out: a config with both layers `None` applies no wrapping.
// Opt a preset out: a config with all layers `None` applies no wrapping.
let bare = CacheKit::production("redis://localhost:6379").await?
.reliability(ReliabilityConfig { retry: None, circuit_breaker: None })
.reliability(ReliabilityConfig { retry: None, circuit_breaker: None, backpressure: None })
.build()?;
```

Expand Down
27 changes: 17 additions & 10 deletions crates/cachekit-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@ fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
/// must be `Send`. Refresh failures are absorbed: the stale value keeps
/// serving until hard expiry, where the blocking path surfaces errors.
/// - **Graceful degradation**: on an outage-class backend failure —
/// transient, timeout, or an open circuit breaker — the plain path fails
/// *open*: the function executes uncached and its result is returned.
/// transient, timeout, an open circuit breaker, or a backpressure shed —
/// the plain path fails *open*: the function executes uncached and its
/// result is returned.
/// Permanent and authentication errors propagate even on the plain path
/// (a wrong API key must fail loudly, not silently disable caching
/// forever). With `secure`, *every* backend and decryption error fails
Expand Down Expand Up @@ -422,14 +423,19 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
}

// Graceful degradation (LAB-518): on an OUTAGE-class backend failure —
// retryable (transient/timeout) or a fast-failing open circuit breaker —
// the plain path fails OPEN: the wrapped function runs uncached, so a
// cache outage costs performance, not availability. Permanent and
// authentication errors PROPAGATE even on the plain path: a wrong API
// key that silently fell open would run uncached forever with zero
// signal while looking healthy (expert-panel finding). The `secure`
// path stays fail-CLOSED on everything: backend and decryption errors
// reach the caller, so an encrypted workload never silently degrades.
// retryable (transient/timeout), a fast-failing open circuit breaker,
// or a backpressure shed (LAB-729) — the plain path fails OPEN: the
// wrapped function runs uncached, so a cache outage costs performance,
// not availability. A shed is the same class as CircuitOpen (the call
// never reached the backend), and failing open adds no work a cold-miss
// storm doesn't already create: the limiter bounds backend cache ops,
// not origin executions, and single-flight still dedupes those.
// Permanent and authentication errors PROPAGATE even on the plain path:
// a wrong API key that silently fell open would run uncached forever
// with zero signal while looking healthy (expert-panel finding). The
// `secure` path stays fail-CLOSED on everything: backend and decryption
// errors reach the caller, so an encrypted workload never silently
// degrades.
let fail_open_arm = if args.secure {
quote! {}
} else {
Expand All @@ -439,6 +445,7 @@ fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
|| matches!(
__ck_be.kind,
cachekit::error::BackendErrorKind::CircuitOpen
| cachekit::error::BackendErrorKind::Backpressure
) => {}
}
};
Expand Down
13 changes: 9 additions & 4 deletions crates/cachekit/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -975,11 +975,12 @@ impl CacheKitBuilder {
}

/// Wrap the backend in the reliability stack (retry with exponential
/// backoff + jitter, circuit breaker) — see [`crate::reliability`].
/// backoff + jitter, circuit breaker, backpressure) — see
/// [`crate::reliability`].
///
/// Enabled by default with production settings by the `production`,
/// `encrypted`, and `io` intent presets; off for `minimal` and for
/// manually-built clients. To opt a preset out, pass a config with both
/// manually-built clients. To opt a preset out, pass a config with all
/// layers `None` — an empty config applies no wrapping at all.
#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
pub fn reliability(mut self, config: crate::reliability::ReliabilityConfig) -> Self {
Expand Down Expand Up @@ -1073,11 +1074,15 @@ impl CacheKitBuilder {
};

// Apply the reliability stack last so it decorates the final backend.
// A config with neither layer set is the documented opt-out: skip the
// A config with no layer set is the documented opt-out: skip the
// (no-op) decorator entirely.
#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
let backend = match self.reliability {
Some(config) if config.retry.is_some() || config.circuit_breaker.is_some() => {
Some(config)
if config.retry.is_some()
|| config.circuit_breaker.is_some()
|| config.backpressure.is_some() =>
{
crate::reliability::wrap_reliable(backend, config)
}
_ => backend,
Expand Down
16 changes: 16 additions & 0 deletions crates/cachekit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ pub enum BackendErrorKind {
/// the backend. Not retryable *now*; the breaker re-probes on its own
/// schedule (see `reliability::CircuitBreakerConfig::open_timeout`).
CircuitOpen,
/// The concurrency limiter shed this call before it reached the backend:
/// the waiting queue was full, or no permit freed up within the acquire
/// timeout. Not retryable *now* — an immediate retry would re-join the
/// same overloaded queue (see `reliability::BackpressureConfig`).
Backpressure,
}

impl BackendErrorKind {
Expand All @@ -70,6 +75,7 @@ impl std::fmt::Display for BackendErrorKind {
Self::Timeout => write!(f, "timeout"),
Self::Authentication => write!(f, "authentication"),
Self::CircuitOpen => write!(f, "circuit-open"),
Self::Backpressure => write!(f, "backpressure"),
}
}
}
Expand Down Expand Up @@ -140,6 +146,16 @@ impl BackendError {
}
}

/// Create a backpressure backend error (call shed by the concurrency
/// limiter, backend not reached).
pub fn backpressure(message: impl Into<String>) -> Self {
Self {
kind: BackendErrorKind::Backpressure,
message: message.into(),
source: None,
}
}

/// Sanitize error messages to strip API keys (CWE-532).
pub fn sanitize_message(msg: &str, api_key: &str) -> String {
if api_key.is_empty() {
Expand Down
23 changes: 14 additions & 9 deletions crates/cachekit/src/intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
//! | `encrypted` | Redis | On | AES-256-GCM | Yes | On | 600 s |
//! | `io` | cachekit.io | On | No | n/a (HTTP) | On | 3 600 s |
//!
//! ¹ Retry with backoff + jitter and a circuit breaker around backend ops
//! (requires the `reliability` feature, on by default — see
//! [`crate::reliability`]). Override via [`CacheKitBuilder::reliability`];
//! a config with both layers `None` disables the stack entirely.
//! ¹ Retry with backoff + jitter, a circuit breaker, and backpressure
//! (bounded backend concurrency) around backend ops (requires the
//! `reliability` feature, on by default — see [`crate::reliability`]).
//! Override via [`CacheKitBuilder::reliability`]; a config with all layers
//! `None` disables the stack entirely.

use std::time::Duration;

Expand Down Expand Up @@ -43,8 +44,9 @@ impl CacheKit {
/// connection is not re-established)
/// * L1 cache: **off**
/// * Encryption: **no**
/// * Reliability: **off** — no retry, no circuit breaker; every backend
/// error propagates on first failure
/// * Reliability: **off** — no retry, no circuit breaker, no
/// backpressure; every backend error propagates on first failure and
/// backend concurrency is unbounded
/// * Default TTL: **300 s**
///
/// Good for: product catalogs, public data, development.
Expand Down Expand Up @@ -82,7 +84,8 @@ impl CacheKit {
/// exponential backoff after a dropped connection)
/// * L1 cache: **on** (1 000 entries)
/// * Encryption: **no**
/// * Reliability: **on** — retry with backoff + jitter, circuit breaker
/// * Reliability: **on** — retry with backoff + jitter, circuit
/// breaker, backpressure (max 100 concurrent backend ops)
/// * Default TTL: **600 s**
///
/// Good for: user sessions, API responses, production services.
Expand Down Expand Up @@ -124,7 +127,8 @@ impl CacheKit {
/// exponential backoff after a dropped connection)
/// * L1 cache: **on** (1 000 entries, stores ciphertext)
/// * Encryption: **AES-256-GCM** with HKDF-SHA256
/// * Reliability: **on** — retry with backoff + jitter, circuit breaker
/// * Reliability: **on** — retry with backoff + jitter, circuit
/// breaker, backpressure (max 100 concurrent backend ops)
/// * Default TTL: **600 s**
/// * Tenant ID: `"default"` (override via
/// [`.encryption_from_bytes()`](CacheKitBuilder::encryption_from_bytes))
Expand Down Expand Up @@ -178,7 +182,8 @@ impl CacheKit {
/// * L1 cache: **on** (1 000 entries)
/// * Encryption: **no** (add via
/// [`.encryption()`](CacheKitBuilder::encryption))
/// * Reliability: **on** — retry with backoff + jitter, circuit breaker
/// * Reliability: **on** — retry with backoff + jitter, circuit
/// breaker, backpressure (max 100 concurrent backend ops)
/// * Default TTL: **3 600 s**
///
/// Good for: serverless, edge compute, managed caching without Redis.
Expand Down
2 changes: 1 addition & 1 deletion crates/cachekit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub use cachekit_macros::cachekit;
pub use flight::SingleFlight;

#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
pub use reliability::{CircuitBreakerConfig, ReliabilityConfig, RetryConfig};
pub use reliability::{BackpressureConfig, CircuitBreakerConfig, ReliabilityConfig, RetryConfig};

// ── Shared jitter source ─────────────────────────────────────────────────────

Expand Down
Loading