Skip to main content

resilience-kit

Framework-agnostic Python resilience + core-infrastructure kernel. Retries, circuit breakers, throttles, cache, SSRF guard, DNS-pinned HTTP client, audit decorators, field crypto — one package, pluggable backends, two thin adapters for Django and FastAPI.

PyPI Python CI License

Status: 0.2.0 on PyPI (stable, Beta). M0–M8 complete plus the post-audit hardening cycle. v0.2.0 bundles the four-lens audit fixes into one minor: crypto key-rotation (MultiFernet), observability exporters ([prometheus] / [otel] / [sentry]), fintech/PII audit redaction, ASGI-safe DRF throttles, SSRF-redirect + idempotency-key + throttle-clock correctness fixes, a shared Redis client, and supply-chain provenance (CycloneDX SBOM + Sigstore-signed releases). See CHANGELOG.md [0.2.0] and Tagging convention. Design is locked across six docs:

Doc What it answers
PRD.md What's in/out of scope, why, who it's for
ROADMAP.md Feature-level breakdown per milestone (M0–M8) with exit gates
LLD.md Protocols, sequence diagrams, concurrency model, settings schema
DIRECTORY-TREE.md Every file with arrival milestone and required extra
MIGRATION-from-boilerplate-embedded.md Stepwise port from embedded core/resilience/ to the kit
adr/ Numbered architecture decisions (Context / Decision / Consequences / Usage)

APIs marked locked in PRD §5.4 and LLD §2 will not break before 1.0.


Why

django_boilerplate and fastapi_boilerplate were shipping the same resilience kernel against two web frameworks — retry, circuit breaker, Valkey-backed throttles, SSRF guard, DNS-pinned HTTP client, Fernet field crypto, @log_inbound / @log_outbound audit decorators — and it had already drifted. This package extracts the kernel once, makes every backend swappable, and ships thin adapters for both frameworks.

You probably want this if you've ever:

  • Copy-pasted retry / circuit_breaker decorators between two services.
  • Hand-rolled SSRF protection and then wondered if it survives DNS rebinding (it usually doesn't).
  • Wrapped redis-py in a degrades-to-memory shim for the third time.
  • Reached for tenacity and pybreaker and a custom Lua script and a sanitizer and a ContextVar request-id — all to add resilience to one outbound HTTP call.

Install

pip install resilience-kit                        # core: pure-python, no I/O deps
pip install "resilience-kit[fastapi,redis,http]"  # FastAPI app on Valkey
pip install "resilience-kit[django,redis,http]"   # Django app on Valkey
pip install "resilience-kit[all]"                 # everything

Upgrading from 0.1.0rc1? See docs/MIGRATION-rc1-to-v0.1.0.md — quick-path pin bump plus the helper recipes that remove the M7 dogfooding blockers (request_id ContextVar bridging, single-envelope exception handling, legacy env-var translation, exception-bridge contract test).

Available extras

Extra Enables
(none) retry, in-memory breaker/throttle/cache, ssrf guard, audit decorators (noop sink), middleware factories, metrics shim, tasks queue, testing helpers
[redis] Valkey / Redis backends for breaker, throttle, cache
[pybreaker] pybreaker backend for the circuit breaker
[http] DNS-pinned AsyncAPIClient (httpx)
[requests] pinned_requests_session()
[crypto] FernetCipher field-level encryption + MultiFernet key rotation
[audit-postgres] Postgres audit-log backend
[prometheus] prometheus_client-backed MetricsSink for a stock /metrics endpoint
[otel] OpenTelemetry metrics sink + TracingMiddleware (W3C trace-context propagation)
[sentry] SentryMetricsSink — breadcrumbs the kit's failure signals
[django] Django + DRF adapter
[fastapi] FastAPI + Starlette adapter
[all] everything above
[dev] tooling: testcontainers, pytest-asyncio, mypy, ruff

Importing a backend whose extra isn't installed raises MissingExtraError("install resilience-kit[redis]") at import time — no confusing ModuleNotFoundError deep in a stack trace.


Quickstart — the one decorator you'll use the most

from resilience_kit import resilient

@resilient("partner_api")          # circuit breaker (outer) + retry (inner)
async def get_balance(account_id: str) -> Decimal:
    response = await http_client.get(f"/accounts/{account_id}/balance")
    return Decimal(response.json()["balance"])

That's it. Defaults from settings: 3 retries with exponential backoff + jitter, breaker opens after 5 failures, half-opens after 30s. Per-service overrides:

from resilience_kit import registry

registry.register_service("partner_api", {
    "retry":           {"max_attempts": 5, "wait_min": 2, "wait_max": 30},
    "circuit_breaker": {"fail_max": 3,     "reset_timeout": 60},
})

FastAPI

from fastapi import FastAPI, Depends
from resilience_kit.adapters.fastapi import (
    resilience_lifespan,
    install_exception_handlers,
    install_middleware_stack,
    rate_limit,
)

app = FastAPI(lifespan=resilience_lifespan)
install_middleware_stack(app)
install_exception_handlers(app)

@app.get("/accounts/{id}", dependencies=[Depends(rate_limit("ip", "60/min"))])
async def read_account(id: str):
    ...
  • resilience_lifespan starts the recovery monitor + audit dispatcher and mounts /readyz + /healthz.
  • rate_limit(scope, rate) is a FastAPI dependency over the kit's throttle.
  • install_exception_handlers(app) maps kit exceptions (ServiceUnavailableError, RateLimitError, …) to JSON responses.

Django

# settings.py
INSTALLED_APPS = [..., "resilience_kit.adapters.django"]

MIDDLEWARE = [
    "resilience_kit.adapters.django.middleware.RequestIdMiddleware",
    "resilience_kit.adapters.django.middleware.RateLimitHeadersMiddleware",
    ...
]

REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "resilience_kit.adapters.django.drf_throttles.IPThrottle",
        "resilience_kit.adapters.django.drf_throttles.UserTierThrottle",
    ],
    "EXCEPTION_HANDLER": "resilience_kit.adapters.django.exception_handler.handle",
}

RESILIENCE = {
    "BACKEND": "redis",
    "REDIS_URL": env("REDIS_URL"),
    "CRYPTO": {"FIELD_ENCRYPTION_KEY": env("FIELD_ENCRYPTION_KEY")},
}

./manage.py resilience_status shows per-service breaker state. ./manage.py resilience_reset partner_api force-closes one.


What's in the box

Resilience primitives

Primitive Sync Async Backends
@retry(...) / @retry_on_failure(name) ✅ ✅ n/a (pure logic)
@circuit_breaker(name) ✅ ✅ memory (default), pybreaker, redis (atomic Lua)
@resilient(name) — breaker ∘ retry ✅ ✅ —
Throttle — rate_limit(scope, rate) ✅ ✅ memory, redis (global Lua)
Cache — get_cache(alias) ✅ ✅ memory (TTL), redis
Recovery monitor — auto re-probe degraded backends — ✅ —

Scopes: ip · endpoint · user_tier · global · burst · auth. Rate syntax: "60/min", "10/sec", "1000/hour".

Security

from resilience_kit.http_client import AsyncAPIClient
from resilience_kit.crypto import FernetCipher

async with AsyncAPIClient(service="partner_api") as client:
    # SSRF guard + DNS pin + outbound allow-list + breaker + retry + audit — all composed.
    data = await client.get("https://partner.example.com/v1/users/42")

    # Safe retries on writes: one Idempotency-Key, reused across every retry.
    await client.post("https://payments.example/charge", json=body,
                      auto_idempotency_key=True)            # or idempotency_key="charge:42"

token = FernetCipher.encrypt("very secret")
plaintext = FernetCipher.decrypt(token)

The DNS pin closes the classic validate→connect TOCTOU: the URL is validated and the resolved IPs are pinned into the same task-local ContextVar that the custom httpx resolver returns at dispatch time. A malicious zone that returns a public IP at validation and a private IP at request time gets blocked.

For non-idempotent writes, request() (and post/put/patch) accept idempotency_key= or auto_idempotency_key=True. The key is resolved once before the retry loop, so a POST retried after a timeout sends the byte-identical Idempotency-Key on every attempt — the upstream dedupes instead of double-charging. See ADR-0012.

Audit — @log_inbound / @log_outbound

from resilience_kit.audit import log_outbound

@log_outbound(service="partner_api", redact=["card_number", "cvv"])
async def charge(card_number: str, cvv: str, amount: Decimal):
    ...

Pluggable sink: stdlib logging (default), Postgres ([audit-postgres]), Django ORM (via the Django adapter), or your own — see Pluggability below.

Framework-agnostic middleware factories

request_id, body_limit, security_headers, selective_cors, rate_limit_headers, exception_logging — exposed as ASGI/WSGI factories. The Django and FastAPI adapters wrap them; you can also mount them directly in any Starlette / WSGI app.


Pluggability

Every swappable subsystem is a typing.Protocol plus a provider that resolves implementations from (in order):

  1. An explicit callable / instance you pass in.
  2. A RESILIENCE_<SUBSYSTEM>_BACKEND="myapp.module:MyBackend" settings string.
  3. An entry point named in your pyproject.toml.
  4. A builtin (memory, redis, …).

Swappable subsystems at v0.1: cache backend · circuit-breaker backend · throttle backend · audit sink · audit sanitizer · metrics sink · settings source · clock · audit dispatcher.

A third-party entry point whose name matches a kit builtin (e.g. memory) shadows the builtin by design — useful for drop-in replacement, footgun for collisions. Namespace your backend names. See ADR 0004 and LLD §3.

Shipping your own backend

# in your own package's pyproject.toml
[project.entry-points."resilience_kit.cache_backends"]
memcached = "rk_memcached:MemcachedCache"
# rk_memcached.py
from resilience_kit.cache.base import AsyncCache

class MemcachedCache(AsyncCache):
    async def get(self, key): ...
    async def set(self, key, value, ttl=None): ...
    async def incr(self, key, amount=1): ...
    async def delete(self, key): ...
    async def health_check(self): ...
pip install rk-memcached
export RESILIENCE_CACHE_BACKEND=memcached

No fork. No monkey-patching. Same @resilient decorator everywhere.


Configuration

Single ResilienceSettings model (pydantic v2). Resolved through get_settings() indirection so callers never import a global. Loaded from env with the RESILIENCE_ prefix, or from settings.RESILIENCE in Django.

Key Default Notes
backend auto auto / redis / memory / pybreaker
redis_url None when set, [redis] backends become available
defaults.retry.max_attempts 3
defaults.retry.wait_min / wait_max 1 / 10 seconds
defaults.circuit_breaker.fail_max 5
defaults.circuit_breaker.reset_timeout 30 seconds
defaults.circuit_breaker.success_threshold 2 half-open → closed
defaults.throttle.auth_rate 5/min applied to /auth/*
defaults.throttle.fail_mode open Redis-outage behaviour — open (per-pod in-memory fallback) or closed (deny while degraded). See note below.
ssrf.block_private_ips True
ssrf.outbound_allowlist ["*"] exact host or .suffix
crypto.field_encryption_key None required outside dev/test. Supply a real Fernet key (Fernet.generate_key()) — used directly. A passphrase is accepted but SHA-256-derived (deprecated, weak; see #B6).
audit.sink stdlib logging importable string, callable, or entry-point name
audit.redact_fields ["password", "token", "secret", "authorization"]

Full pydantic schema in LLD.md §10. Settings keys are loaded with the RESILIENCE_ prefix and __ nested delimiter (e.g. RESILIENCE_DEFAULTS__RETRY__MAX_ATTEMPTS=5).

Throttle fail-mode (Redis outage). When Redis is unreachable, a fail_mode="open" throttle (the default) degrades to a per-pod in-memory window — so a global 100/min limit across 8 pods becomes effectively 800/min during the outage. For hard upstream limits (e.g. payment APIs), set fail_mode="closed" to deny requests while degraded instead. See ADR-0013.


Exceptions you might catch

from resilience_kit.exceptions import (
    TransientError,           # retryable, transport-layer
    ExternalTimeoutError,     # subtype of TransientError
    ExternalServiceError,     # upstream returned non-success
    ServiceUnavailableError,  # breaker is OPEN — adapter maps to 503
    RateLimitError,           # throttle tripped — adapter maps to 429
    DecryptionError,          # FernetCipher failed (key rotation?)
    ValidationError,          # SSRF guard / config-time validation
)

The Django and FastAPI adapters map these to the right HTTP responses out of the box.


Testing your code that uses the kit

from resilience_kit.testing import reset_all_singletons, FakeClock, FakeAuditSink

@pytest.fixture(autouse=True)
async def _reset():
    await reset_all_singletons()

Integration tests against real backends use testcontainers-redis:

@pytest.mark.integration
async def test_throttle_under_load(redis_url):
    settings.RESILIENCE_REDIS_URL = redis_url
    # ... hammer the throttle, assert exact counts.

Compatibility

  • Python: 3.11, 3.12, 3.13
  • Django: 4.2 LTS, 5.x
  • FastAPI: 0.110+ (Starlette 0.36+)
  • httpx: >=0.27, <0.29
  • Redis / Valkey: Redis 7+, Valkey 8+ (verified in CI against both Docker images)

Roadmap

v0.1 — everything in this README, both adapters, both boilerplates migrated to depend on it. Nine milestones:

Milestone Status
M0 Repo scaffold ✅ shipped
M1 Core primitives, in-memory only ✅ shipped
M2 Redis/Valkey + pybreaker backends ✅ shipped
M3 HTTP client + SSRF + crypto ✅ shipped
M4 Audit + middleware + metrics + entry-point wiring ✅ shipped
M5 FastAPI adapter ✅ shipped
M6 Django adapter ✅ shipped
M7 Boilerplate migrations ✅ shipped
M8a 0.1.0rc1 on PyPI ✅ shipped
M8b Release-prep + v0.1.0 final ✅ shipped

v0.2.0 (shipped 2026-07-04) — post-audit hardening: crypto key-rotation, observability exporters ([prometheus]/[otel]/[sentry]), fintech/PII redaction, ASGI-safe DRF throttles, SSRF-redirect + idempotency-key correctness fixes, shared Redis client, breaker loop-rebind fix, Hypothesis property tests, and supply-chain provenance (SBOM + Sigstore). Bundles the audit's Lanes A + B + C + the Lane D hardening subset.

v0.3.0+ — Flask adapter · Celery adapter · resilience-kit doctor CLI · hosted MkDocs site · co-maintainer onboarding. Tracked in KNOWN-ISSUES.md.

Per-milestone feature list and exit gates: ROADMAP.md. Final file tree with arrival milestones per file: DIRECTORY-TREE.md.


Security

Report vulnerabilities privately via GitHub Security Advisories — full policy in SECURITY.md.


Contributing

Dev setup, the full local CI gate, contract-suite expectations, and the step-by-step for shipping a third-party backend live in CONTRIBUTING.md. The contract test suite under tests/contract/ is the source of truth: any new backend must pass it, parametrized in. make gate runs the full local gate (lint, types, imports, coverage, pip-audit, lock-check, dead-symbols).

By participating you agree to abide by the Code of Conduct (Contributor Covenant v2.1).


License

MIT. See LICENSE.


Metadata

Release files for resilience-kit 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for resilience-kit 0.2.0
File Size Uploaded
resilience_kit-0.2.0.tar.gz 135.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for resilience-kit 0.2.0
File Interpreter ABI Platform
resilience_kit-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 315.2 kB

Release files / resilience_kit-0.2.0.tar.gz

Download URL resilience_kit-0.2.0.tar.gz
Size 135.4 kB
Tags Source
SHA-256 checksum
How to use checksums
af080341d4d425029125220e89ea452e4ddf866941511dc3d7270e2f3c2339e5
BLAKE2b-256 checksum
How to use checksums
b681db626264d85b614235db6dc251def4b8e6ab47cbf0894f865509051c065a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 4, 2026.

Transparency log

Release files / resilience_kit-0.2.0-py3-none-any.whl

Download URL resilience_kit-0.2.0-py3-none-any.whl
Size 179.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5e9e2dfbbba579a7e2f1a8b6f3ae1f1a1db3a6c91f501ce05eda7b2a9cb5467d
BLAKE2b-256 checksum
How to use checksums
eb3f93d88efe21c452589fc77d8ff90efb418617ea6e7de5ea0ce5a12cbc5cee
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 4, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page