Building the Polly that Python never had
Every app that calls external APIs faces the same four problems: transient failures, dead dependencies, rate limits, and slow responses. Java solved this with resilience4j. .NET solved it with Polly. Python never had a unified answer: tenacity does retries, pybreaker does circuit breaking (sync only, aging), and everything else you wire by hand.
These patterns only pay off when they compose: a timeout per attempt, retries that respect an open circuit, a fallback that absorbs the rest. Wiring that from three libraries with three philosophies is the code nobody wants to own. So I built nopanic.
One decorator API, sync and async
from nopanic import compose, fallback, retry, circuit_breaker, timeout, backoff, CircuitOpen
llm = circuit_breaker(failure_threshold=0.5, reset_timeout=20.0, name="llm")
resilient = compose(
fallback(lambda e: "temporarily unavailable", on=(ConnectionError, CircuitOpen, TimeoutError)),
retry(attempts=3, on=(ConnectionError, TimeoutError), backoff=backoff.full_jitter(0.2)),
llm,
timeout(30.0),
)
@resilient
async def ask(prompt: str) -> str: ...
Reading inside out: each attempt gets 30 seconds, outcomes feed the breaker, transient failures retry with jitter, and whatever is left becomes a degraded answer instead of a traceback. The same decorators work on plain sync functions. Zero runtime dependencies.
Design choices that mattered
No wrapper exceptions. Exhausted retries re-raise the original error. Your except ConnectionError: keeps working.
Injectable time. Breakers, rate limiters and caches take a clock= parameter, so the test suite (141 tests) runs in under two seconds with zero sleeps.
Observability as a stream. events.subscribe() sees everything every policy does. With no listeners the cost is one attribute read (~0.1µs).
Measured overhead. Every policy costs 0.15 to 1.3 microseconds per call on the success path. A fast HTTP round trip is 5,000+ µs.
Validation: retrofitting a 232k-star repo
To test the API against reality, I took network scripts from geekcomputers/Python and added resilience: three decorators per call site, no restructuring. The exercise even improved the design: my first retry policy naively retried a 403, which led to the documented “retry 429/5xx, never other 4xx” recipe.
The bug that only exists on Windows
The best story came from CI. Tests passed everywhere except Windows on Python 3.11/3.12. After reproducing locally and instrumenting, the data showed time.sleep(0.05) waking after 47ms by the monotonic clock, and sub-quantum sleeps returning instantly. My retry honored a server's Retry-After by sleeping exactly that long, then racing the deadline and losing, burning attempts against a still-closed breaker window.
The fix belongs in the library, not the test: “retry after X” means “not before X”, so honored hints now sleep X × 1.05 + 50ms, still capped so a hostile server cannot park your client. If your code races deadlines exactly, Windows will eventually teach you the same lesson.
Adaptive rate limiting: stop hardcoding guesses
The newest addition applies TCP's AIMD congestion control to API quotas: a 429 cuts your rate multiplicatively, successes recover it additively, and Retry-After blocks the bucket for exactly that long. You converge on the rate the server actually sustains.
pip install nopanic — GitHub: github.com/dagdelenemre/nopanic
Feedback and issues welcome, especially from Polly and resilience4j users.