Skip to content

API reference

Generated from the docstrings. Everything listed here is public API.

Policies

nopanic.retry

Retry policy: re-run a failing call with configurable backoff.

RetryAttempt dataclass

Passed to before_sleep after a failed attempt, before the wait.

Attributes:

Name Type Description
attempt int

1-based index of the attempt that just failed.

exception BaseException | None

The exception that attempt raised, or None when the attempt returned a value rejected by on_result.

delay float

Seconds that will be slept before the next attempt.

result Any

The rejected return value when on_result triggered the retry, otherwise None.

retry

Bases: Policy

Retry a call when it raises a matching exception.

Parameters:

Name Type Description Default
attempts int

Total number of attempts, including the first call.

3
on ExcFilter

Exception type, tuple of types, or predicate selecting which exceptions trigger a retry. Anything else propagates immediately.

Exception
backoff float | Backoff | None

A backoff strategy (see :mod:nopanic.backoff) or a bare number of seconds for a fixed delay. Defaults to capped exponential growth with full jitter.

None
giveup Callable[[BaseException], bool] | None

Optional predicate; returning True for a raised exception stops retrying even if it matches on (e.g. an HTTP 401 among retriable transport errors).

None
before_sleep Callable[[RetryAttempt], None] | None

Optional callback invoked with a :class:RetryAttempt after each failure, before the wait — the natural place to log.

None
honor_retry_after bool

When True (default), an exception carrying a numeric retry_after attribute (seconds) raises the wait to at least that long, plus a small safety margin (5% + 50ms) so the deadline is strictly passed despite OS timer granularity. Works out of the box with CircuitOpen, RateLimited and with any exception you build from an HTTP 429's Retry-After header.

True
retry_after_cap float

Upper bound on an honored retry_after hint. The hint comes from the remote side; without a cap a hostile or buggy server could park the client for arbitrary time.

60.0
max_time float | None

Total time budget in seconds, measured from the start of the first attempt. Retrying stops as soon as the next wait would end past the budget, so no attempt ever starts after the deadline. None (default) means attempts alone bound retrying. An ambient :class:~nopanic.deadline is respected the same way.

None
on_result Callable[[Any], bool] | None

Optional predicate over the return value; returning True treats the result as a failure and retries (an HTTP client that returns a 503 response instead of raising). When retrying stops, the last result is returned as-is. Not applied to streams.

None
budget RetryBudget | None

Optional :class:~nopanic.RetryBudget shared with other retry policies; when it is spent, retrying stops early.

None
name str | None

Diagnostic name carried by this policy's events.

None
clock Callable[[], float]

Monotonic time source for max_time; injectable for tests.

monotonic

When attempts are exhausted the original last exception is re-raised unchanged; there is no wrapper exception to unwrap. If the backoff strategy stops yielding delays, or the max_time budget, deadline or retry budget would be exceeded, retrying stops early the same way. An exception raised inside before_sleep is logged and suppressed — observability hooks are never allowed to break the call they observe.

On a generator or async generator function (an LLM token stream, say), the opening of the stream is retried: failures before the first item are retried like any call, while a failure after items were delivered propagates, since those items cannot be taken back.

nopanic.CircuitBreaker

Bases: Policy

Failure-rate circuit breaker over a sliding time window.

Parameters:

Name Type Description Default
failure_threshold float

Failure rate in [0, 1] that opens the breaker.

0.5
min_calls int

Minimum outcomes in the window before the rate is judged (prevents one early failure from opening a cold breaker).

5
window float

Look-back period for the failure rate, in seconds. This is not a per-call timeout: a slow call is recorded once it finishes.

30.0
window_buckets int

Number of fixed time buckets the window is divided into. Window memory is constant in the bucket count regardless of traffic; outcomes expire in bucket-sized steps, so more buckets means finer expiry precision.

10
reset_timeout float

Seconds to stay open before allowing probe calls.

30.0
half_open_max_calls int

Number of concurrent probe calls allowed while half-open; that many successes close the breaker.

1
on ExcFilter

Which exceptions count as failures. Exceptions not selected are propagated without being recorded at all.

Exception
slow_call_duration float | None

When set, a call taking at least this many seconds counts as slow (whether it succeeded or failed); for a stream, the time to its first item is what counts. A dependency that answers, but too slowly, is often as harmful as one that fails. None (default) disables slow-call tracking and costs nothing.

None
slow_call_threshold float

Slow-call rate in (0, 1] that opens the breaker, judged like failure_threshold. A slow half-open probe counts as a failed probe.

1.0
name str | None

Diagnostic name (appears in CircuitOpen messages and events).

None
on_state_change Callable[[CircuitBreaker, str, str], None] | None

Optional callback (breaker, old_state, new_state) invoked outside the internal lock after every transition. An exception raised by the hook is logged and suppressed — it must not break the guarded call.

None
clock Callable[[], float]

Monotonic time source; injectable for tests.

monotonic

Besides decorating functions (sync, async and generator functions), a breaker guards a block of code via with breaker.guard(): or async with breaker.guard():.

state property

Current state: "closed", "open", "half_open", "forced_open" or "disabled".

Reading the state applies the open → half-open timeout transition, so it always reflects what the next call would see.

metrics property

A consistent snapshot of state and window counters.

reset()

Force the breaker back to closed and clear the window.

force_open()

Reject every call until :meth:reset (maintenance, kill switch).

disable()

Let every call through and record nothing until :meth:reset.

guard()

Context manager guarding a block: with breaker.guard(): ....

Works with with and async with. An exception leaving the block is judged exactly like one raised by a decorated function.

nopanic.BreakerMetrics dataclass

Point-in-time view of a breaker, for health checks and dashboards.

Attributes:

Name Type Description
state str

Current state (see the module docstring).

calls int

Outcomes currently inside the sliding window.

failures int

Failed outcomes inside the window.

slow_calls int

Slow outcomes inside the window (0 unless slow-call tracking is enabled).

failure_rate float

failures / calls, or 0.0 for an empty window.

slow_call_rate float

slow_calls / calls, or 0.0 for an empty window.

nopanic.timeout

Timeout policy: bound how long a single call may take.

timeout

Bases: Policy

Fail with :class:TimeoutError if the call takes longer than seconds.

Parameters:

Name Type Description Default
seconds float

Time limit per call.

required
first_item float | None

For streams only: limit for the first item, which is often slower than the rest (an LLM's time to first token). Defaults to seconds.

None
max_abandoned int | None

Sync calls only: how many timed-out worker threads may still be running before new calls fail fast with TimeoutError instead of starting another thread. Bounds the resources a hung dependency can pin. None means unbounded.

64
name str | None

Diagnostic name carried by this policy's events.

None

Async functions are cancelled cleanly via the event loop. Sync functions are executed on a daemon worker thread and abandoned on timeout — Python cannot kill a running thread, so the underlying work may continue in the background even after the caller has received TimeoutError. Prefer the async path (or the callee's own timeout parameter, e.g. an HTTP client's) when abandonment is not acceptable. The worker runs in a copy of the caller's context, so contextvars (tracing spans, request IDs, structured-logging fields) are visible inside the call.

Only an expiry of this timeout raises a new TimeoutError. A TimeoutError raised by the wrapped function itself (its own I/O timeout, say) propagates unchanged, like any other user exception.

Inside an active :class:~nopanic.deadline the limit is clamped to the time remaining, and a call made after the deadline fails at once.

On an async generator function the limit applies to each item (a stalled stream fails even if it started fine), with first_item for the first one. Sync generators are not supported: a worker thread per item would be the only way to bound them. async with policy.guard(): bounds a block of async code.

abandoned property

Timed-out sync calls whose worker threads are still running.

guard()

Bound a block of async code: async with policy.guard(): ....

nopanic.rate_limit

Bases: _TokenBucket

Throttle calls to at most rate per per seconds (token bucket).

Parameters:

Name Type Description Default
rate float

Number of calls allowed per period.

required
per float

Period length in seconds. rate_limit(10, 1.0) is 10 calls/s.

1.0
burst float | None

Bucket capacity — how many calls may go through back-to-back after an idle stretch. Defaults to rate.

None
max_wait float | None

By default a throttled call waits for its token. If set, calls that would wait longer than this raise :class:RateLimited instead (0 means never wait). Inside an active :class:~nopanic.deadline a call never waits past the deadline.

None
name str | None

Diagnostic name carried by this policy's events.

None
clock Callable[[], float]

Monotonic time source; injectable for tests.

monotonic

Waiting callers reserve their token up front (the bucket may go negative), which keeps ordering roughly FIFO under contention; a waiter that is cancelled gives its token back. A stream takes one token when it opens; with limiter.guard(): takes one for a block.

available_tokens property

Tokens in the bucket right now; negative while callers are queued.

nopanic.adaptive_rate_limit

Bases: _TokenBucket

Token bucket whose rate adapts to server pushback (AIMD).

Parameters:

Name Type Description Default
rate float

Starting (and by default maximum) calls per per seconds.

required
per float

Period length in seconds.

1.0
min_rate float | None

Floor the rate never drops below (same unit as rate). Defaults to rate / 10.

None
max_rate float | None

Ceiling the rate never climbs above. Defaults to rate, i.e. the configured rate is treated as the allowed maximum.

None
increase float

Additive recovery per successful call, in calls-per-per units. Small values recover cautiously.

0.1
decrease float

Multiplicative factor in (0, 1) applied on throttle errors.

0.5
throttle_on ExcFilter

Which exceptions mean "slow down". Defaults to :func:looks_throttled. Matching exceptions still propagate to the caller (pair with retry to also re-attempt them).

looks_throttled
max_wait float | None

As in rate_limit: None waits, otherwise calls that would wait longer raise RateLimited. An ambient :class:~nopanic.deadline is respected the same way.

None
max_block float

Upper bound on how long a server-sent retry_after hint may block the bucket. The hint comes from the remote side; without a cap a hostile or buggy server could freeze the client indefinitely.

60.0
name str | None

Diagnostic name carried by this policy's events.

None
clock Callable[[], float]

Monotonic time source; injectable for tests.

monotonic

A retry_after attribute on a throttle exception additionally blocks the bucket for that many seconds (capped at max_block), so the next calls respect the server's explicit wish. Inspect the live rate via current_rate and the bucket via available_tokens. Streams take one token when they open and report their outcome when they end; guard() does the same for a block.

current_rate property

Current adapted rate, in calls per second.

available_tokens property

Tokens in the bucket right now; negative while callers are queued.

nopanic.looks_throttled(exc)

Best-effort default for "the server told us to slow down".

True when the exception carries a retry_after attribute, an HTTP status of 429 or 503 (checked on the exception itself and on an attached response object, which covers requests and httpx errors). Pass your own predicate for anything smarter.

nopanic's own signals (CircuitOpen, RateLimited, ...) are never pushback: they carry retry_after too, but they come from the local process, and reading them as the server's voice would let a tripped breaker collapse the adaptive rate.

nopanic.bulkhead

Bulkhead policy: cap concurrent calls so one dependency can't sink the ship.

bulkhead

Bases: Policy

Allow at most max_concurrent simultaneous calls through.

Parameters:

Name Type Description Default
max_concurrent int

Concurrency ceiling.

required
max_wait float | None

How long a call may wait for a slot. None (default) waits indefinitely; 0 fails fast; any other value waits up to that many seconds. On expiry :class:BulkheadFull is raised. Inside an active :class:~nopanic.deadline a call never waits past the deadline.

None
name str | None

Diagnostic name used in error messages and events.

None

One instance carries the shared capacity: decorate every call site that should share the same slots with the same instance. Capacity is counted separately for sync callers (one thread semaphore) and for async callers on each event loop (one asyncio semaphore per loop): a bulkhead used from sync code and from two event loops admits up to three times max_concurrent in total. Use separate instances per flavour if that matters to you.

Streams (generator functions) hold their slot until they finish. A block of code is guarded with with bh.guard(): (sync capacity) or async with bh.guard(): (the running loop's capacity).

in_flight property

Calls currently holding a slot (sync plus every event loop).

guard()

Hold a slot for a block: with bh.guard(): / async with bh.guard():.

nopanic.fallback

Fallback policy: degrade gracefully instead of failing.

fallback

Bases: Policy

Return a substitute result when the call raises a matching exception.

Parameters:

Name Type Description Default
handler Any

Either a plain value to return, or a callable receiving the exception and returning the substitute (async callables are awaited when the wrapped function is async).

required
on ExcFilter

Which exceptions trigger the fallback; others propagate.

Exception
name str | None

Diagnostic name carried by this policy's events.

None

Typically the outermost policy in a stack: everything the inner layers could not absorb turns into a degraded-but-valid response.

nopanic.hedge

Hedged requests: trade a little extra load for much better tail latency.

If a call hasn't finished after delay seconds, fire a second identical call and take whichever finishes first ("The Tail at Scale", Dean & Barroso, 2013). Ideal for high-variance backends — LLM APIs, cross-region reads, flaky CDNs.

Only meaningful for idempotent operations, and async-only by nature.

hedge

Bases: Policy

Race duplicate calls against a slow first attempt.

Parameters:

Name Type Description Default
delay float

Seconds to wait before launching each additional attempt.

required
max_hedges int

Maximum number of extra attempts (so total concurrent attempts is at most 1 + max_hedges).

1
on ExcFilter

Which failures are worth hedging. An attempt failing with a matching exception lets the others continue (and launches the next hedge at once); any other exception is permanent: it cancels the remaining attempts and propagates immediately, so a 400-style error is not repeated max_hedges more times.

Exception
name str | None

Diagnostic name carried by this policy's events.

None

The first successful attempt wins and all others are cancelled. If every attempt fails, the last failure is re-raised.

nopanic.cache

Result cache with a stale-while-failing safety net.

Fresh results are served from memory; when the value expires the call runs again, and if that refresh fails, the stale value is served instead of the error (up to stale_ttl past expiry). The dependency being down stops being your outage for as long as your stale window allows.

Refreshes are single-flight: when many callers miss the same key at once, one of them calls the dependency and the rest share its outcome, so an expiring hot key does not stampede a backend at the moment it is weakest.

cache

Bases: Policy

Cache results by arguments; serve stale on refresh failure.

Parameters:

Name Type Description Default
ttl float

Seconds a cached value counts as fresh (served without calling).

required
stale_ttl float

Extra seconds past ttl during which an expired value may still be served, but only when the refresh call fails with an exception matching on. 0 disables stale serving, leaving a plain TTL cache.

0.0
on ExcFilter

Which refresh failures allow serving stale; others propagate.

Exception
maxsize int

Entry limit; least-recently-used entries are evicted.

1024
key Callable[..., Hashable] | None

Optional key(*args, **kwargs) -> hashable to build cache keys (use it when arguments are unhashable). By default the positional and keyword arguments themselves form the key.

None
single_flight bool

When True (default), concurrent misses of the same key share one refresh: a single caller runs the function and the others wait for and receive its outcome (value, stale value or the same exception). Sync and async callers are deduplicated separately, async ones per event loop (or trio run).

True
name str | None

Diagnostic name carried by this policy's events.

None
clock Callable[[], float]

Monotonic time source; injectable for tests.

monotonic

One instance may decorate several functions; entries are keyed per function. Arguments must be hashable unless key is given. Streams (generator functions) cannot be cached and are rejected at decoration.

size property

Entries currently cached (fresh or stale).

clear()

Drop every cached entry.

nopanic.deadline

Deadline propagation: one time budget for a whole operation.

A timeout bounds one call; a deadline bounds everything inside a block, no matter how many retries, waits and nested calls it takes::

with deadline(5.0):              # or: async with deadline(5.0)
    profile = get_profile(uid)   # retries, timeouts, rate-limit waits...
    orders = get_orders(uid)     # ...all share the same 5 seconds

The deadline is ambient (a contextvars value, so it follows the code into awaited coroutines and timeout worker threads) and cooperative: policies inside the block respect it, nothing is killed from outside.

  • timeout clamps its own limit to the time remaining, and fails at once if the deadline has already passed.
  • retry gives up instead of sleeping into a wait that would outlast the deadline.
  • rate_limit, adaptive_rate_limit and bulkhead never wait past it; they reject with RateLimited / BulkheadFull instead.

Nested deadlines can only shorten the budget, never extend it. Your own code can read the remainder with :func:nopanic.time_remaining and pass it on, e.g. as an HTTP client's timeout. To enforce the budget on a call, put a timeout inside; it will use whichever limit is tighter.

deadline is also a policy, so @deadline(5.0) or compose(deadline(10.0), retry(...), timeout(2.0)) gives every call its own fresh budget.

deadline

Bases: Policy

Run a block, or a decorated call, under a time budget of seconds.

Use as with deadline(s): / async with deadline(s): around a block, or as a decorator / compose member to give each call its own budget. One instance must not be entered twice at the same time (create a new one per block); as a decorator it can be shared freely. Generator functions are not supported: a context value set inside a generator would leak into the consumer between items.

nopanic.time_remaining()

Seconds left before the innermost active :class:~nopanic.deadline.

None when no deadline is active. Can be negative once it has passed. Handy for handing the remaining budget to a client's own timeout parameter: client.get(url, timeout=time_remaining()).

nopanic.RetryBudget

A shared allowance of retries; pass it to one or more retry policies.

Parameters:

Name Type Description Default
ratio float

Retries allowed per first attempt inside the window (0.2 means at most one retry for every five calls).

0.2
min_retries_per_sec float

Floor that is always allowed, so a client with little traffic can still retry. Over the window this amounts to min_retries_per_sec * window retries.

1.0
window float

Sliding window in seconds over which calls and retries are counted.

10.0
clock Callable[[], float]

Monotonic time source; injectable for tests.

monotonic

Thread-safe; memory is constant (bucketed counters, like the breaker).

available property

Retries that could be spent right now.

deposit()

Count one first attempt (called by retry).

try_withdraw()

Spend one retry if the budget allows it (called by retry).

nopanic.keyed

Per-key policies: one breaker (or limiter, or bulkhead) per host, tenant, model...

A breaker guarding "the payments API" trips for everyone when one region is down; a rate limit shared by all tenants lets one noisy tenant starve the rest. keyed keeps a separate policy instance per key, created on first use from a factory and bounded by an LRU::

per_host = keyed(
    lambda host: circuit_breaker(name=f"api-{host}", reset_timeout=10.0),
    key=lambda url, **_: urlsplit(url).hostname,
)

@per_host
async def fetch(url, **params): ...

per_host.get("eu.api.example.com").state   # inspect one of them

keyed

Bases: Policy, Generic[PolicyT]

Route each call to its own policy instance, chosen by a key.

Parameters:

Name Type Description Default
factory Callable[[Any], PolicyT]

factory(key) -> policy; called once per new key, outside any lock.

required
key Callable[..., Hashable]

key(*args, **kwargs) -> hashable, computed from each call's arguments.

required
maxsize int

Most instances kept; the least recently used one is dropped beyond that (and with it its state: an evicted open breaker starts again closed).

1024

Supports whatever the produced policies support: an unsupported stream flavour raises TypeError when the stream is iterated, since the policy type is only known once the factory has run.

get(key)

The policy for key, created by the factory if needed.

keys()

Keys with a live instance, least recently used first.

nopanic.compose

Combine several policies into a single decorator.

compose

Stack policies into one decorator; the first argument is outermost.

compose(retry(...), timeout(2.0)) behaves exactly like writing::

@retry(...)      # outermost: retries the whole timed call
@timeout(2.0)    # innermost: bounds each individual attempt
def call(): ...

A typical production stack, outermost to innermost: fallback → retry → circuit_breaker → timeout.

Like a single policy, a composed stack also runs one-off calls without decorating anything: stack.call(fn, *args) / await stack.acall(fn, *args). Those build the wrapper chain per call (a few microseconds); decorate once for hot paths.

call(fn, /, *args, **kwargs)

Run fn(*args, **kwargs) through the whole stack (sync).

acall(fn, /, *args, **kwargs) async

Run and await fn(*args, **kwargs) through the whole stack.

nopanic.Policy

Bases: ABC

Base class for all resilience policies.

Subclasses implement _run_sync and _run_async; instances are then usable as decorators on both sync and async callables, and can be stacked or combined with :func:nopanic.compose. For a one-off call there is nothing to decorate: policy.call(fn, *args) and await policy.acall(fn, *args) run fn under the policy directly.

Policies that can guard a stream (a generator or async generator function, e.g. an LLM token stream) also implement _run_sync_gen / _run_async_gen and list the flavours in _STREAMS. Decorating a generator function with a policy that cannot guard it raises TypeError at decoration time instead of silently doing nothing.

call(fn, /, *args, **kwargs)

Run fn(*args, **kwargs) under this policy (sync).

acall(fn, /, *args, **kwargs) async

Run and await fn(*args, **kwargs) under this policy.

fn only needs to return an awaitable: a bound method of an async client or a lambda wrapping one works as well as an async def.

Backoff

nopanic.backoff

Backoff strategies.

A strategy is any iterable of floats: iterating it yields the delay (in seconds) to sleep before each successive retry. Strategies are plain frozen dataclasses, so they are cheap, hashable and safe to share between policies; each execution of a policy starts a fresh iterator.

full_jitter (capped exponential growth with full jitter, per the AWS architecture blog) is the recommended default for anything that talks to a shared service — it avoids retry stampedes.

Jitter deliberately uses the stdlib PRNG (random): it shapes load, it is not a security boundary, and a CSPRNG would add cost for no benefit. The corresponding lint rule (S311) is suppressed project-wide for this reason.

Backoff

Bases: Protocol

Anything that can be iterated to produce per-retry delays in seconds.

fixed dataclass

The same delay before every retry.

exponential dataclass

Deterministic exponential growth: base, base*factor, ... capped at cap.

full_jitter dataclass

Exponential growth with full jitter: uniform(0, min(cap, base*factor**n)).

The recommended default against shared services; see "Exponential Backoff and Jitter" (AWS Architecture Blog, 2015).

decorrelated_jitter dataclass

Decorrelated jitter: each delay is uniform(base, previous*3), capped.

as_backoff(value)

Coerce a bare number into a :class:fixed strategy.

Events

nopanic.events

Lightweight observability events emitted by every policy.

Subscribe once, see everything your resilience layer does: retries, breaker transitions, rejected calls, throttle adjustments, stale cache serves.

from nopanic import events

cancel = events.subscribe(lambda e: log.info("%s %s %s", e.kind, e.name, e.data))
...
cancel()  # stop listening

Or, for plain logging, just events.log_to(logging.getLogger("resilience")).

Listeners are process-global and called synchronously on the calling thread; keep them fast (hand off to a queue for heavy work). A listener that raises is logged and ignored: observability is never allowed to break the call it observes. When there are no listeners the emission cost is a single attribute read, so the hot path stays effectively free.

Every event carries the emitting policy type, the policy's name (when one was given) and the qualified name of the guarded function. Event kinds currently emitted:

  • retry.attempt_failed (attempt, delay, exception[, result])
  • retry.gave_up (attempt, exception, reason[, result]); reason is one of attempts, giveup, backoff, max_time, deadline, budget
  • breaker.state_change (old, new)
  • breaker.rejected (retry_after)
  • bulkhead.rejected (max_concurrent, max_wait)
  • ratelimit.waited (seconds)
  • ratelimit.rejected (would_wait, max_wait)
  • ratelimit.throttled (old_rate, new_rate, retry_after) [adaptive]
  • timeout.expired (seconds, function)
  • timeout.saturated (abandoned, max_abandoned) [sync]
  • fallback.used (exception)
  • hedge.launched (delay)
  • cache.stale_served (age, exception)

Event dataclass

One observable occurrence inside a policy.

Attributes:

Name Type Description
kind str

Dotted event type, e.g. "retry.attempt_failed".

policy str

Policy type that emitted it, e.g. "retry".

name str | None

The policy instance's name, or None when it has none.

data Mapping[str, Any]

Event-specific payload; see the module docstring.

function str | None

Qualified name of the guarded function, when known.

subscribe(listener)

Register a listener for all policy events; returns an unsubscribe callable.

unsubscribe(listener)

Remove a previously registered listener (no-op if absent).

log_to(logger=None, level=logging.INFO)

Log every event to logger (default nopanic); returns an unsubscribe callable.

One line per event, lazily formatted, with the event fields as extra (nopanic_kind, nopanic_policy, nopanic_name, nopanic_function) for structured-logging handlers.

emit(kind, policy, name=None, fn=None, /, **data)

Deliver an event to all listeners.

Used by every policy, and public for policy authors via :mod:nopanic.extend. name is the policy instance's name, fn the guarded callable (its qualified name is computed only when someone is listening); keyword arguments become Event.data.

Exceptions

nopanic.ResilienceError

Bases: Exception

Base class for all errors raised by nopanic policies.

nopanic.CircuitOpen

Bases: ResilienceError

Raised when a call is rejected because the circuit breaker is open.

Attributes:

Name Type Description
name

Name of the breaker that rejected the call.

retry_after

Seconds until the breaker will transition to half-open and allow a probe call. 0.0 when the breaker is half-open but all probe slots are taken.

nopanic.BulkheadFull

Bases: ResilienceError

Raised when a bulkhead rejects a call because capacity is exhausted.

nopanic.RateLimited

Bases: ResilienceError

Raised when a rate limiter rejects a call instead of waiting.

Only raised when max_wait is set and the required wait exceeds it; by default the rate limiter waits instead of failing.

Attributes:

Name Type Description
retry_after

Seconds until a token would have been available, i.e. the wait that exceeded max_wait (None if not provided). An outer retry honors it like any other retry_after hint.

nopanic.RetryAttempt dataclass

Passed to before_sleep after a failed attempt, before the wait.

Attributes:

Name Type Description
attempt int

1-based index of the attempt that just failed.

exception BaseException | None

The exception that attempt raised, or None when the attempt returned a value rejected by on_result.

delay float

Seconds that will be slept before the next attempt.

result Any

The rejected return value when on_result triggered the retry, otherwise None.

Testing

nopanic.testing

Helpers for testing code that uses nopanic.

Resilience code is full of waiting: backoff between retries, rate-limit queues. Your test suite should not be::

from nopanic import testing

def test_checkout_survives_a_flaky_gateway():
    with testing.instant() as sleeps:
        assert checkout(order) == "paid"
    assert len(sleeps) == 2          # it did back off twice, instantly

def test_breaker_reports_opening():
    with testing.capture_events("breaker.") as seen:
        ...
    assert [e.data["new"] for e in seen] == ["open"]

Both helpers act process-wide for the duration of the with block (all threads), so do not run tests that use them in parallel threads of one process (pytest-xdist uses processes and is fine).

instant()

Skip every wait nopanic would sleep, recording the requested seconds.

Covers retry backoff (including honored retry_after hints) and rate-limit waits, sync and async (async waits still yield to the event loop once). Timeouts are not affected: they bound real work.

capture_events(prefix='')

Collect every event whose kind starts with prefix (default: all).

Extending

nopanic.extend

Public building blocks for writing your own policies.

nopanic's add-on packages (nopanic-otel, nopanic-redis) are built only on this module, and so can yours. Everything here is covered by the same compatibility promise as the rest of the public API.

A minimal policy::

from nopanic.extend import Policy, emit

class audit(Policy):
    __slots__ = ()

    def _run_sync(self, fn, args, kwargs):
        emit("audit.call", "audit", None, fn)
        return fn(*args, **kwargs)

    async def _run_async(self, fn, args, kwargs):
        emit("audit.call", "audit", None, fn)
        return await fn(*args, **kwargs)

Rules your policy should follow to behave like the built-in ones: re-raise user exceptions unchanged, never hold a lock while calling user code, wait only through :func:sleep / :func:asleep (so testing.instant() and deadlines work), respect :func:time_remaining in every wait, and declare stream support in _STREAMS (implementing _run_sync_gen / _run_async_gen) or let decoration of generators fail loudly.