Reliability
Retry
Retry transient failures with RetryPolicy and exponential backoff with jitter. How retries, max_attempts and retry_on relate, and how attempts are counted.
Retrying is the most common reliability tool and the easiest to misuse. RelPrim retries only the exceptions you select, waits with exponential backoff and jitter between attempts, and records every attempt in the execution report.
retries versus max_attempts
The decorator and the policy count differently:
| Configuration | Meaning | Total attempts |
|---|---|---|
@resilient(retries=3) |
Three retries after the first attempt | up to 4 |
RetryPolicy(max_attempts=3) |
Three attempts including the first | up to 3 |
@resilient(retries=0) |
No retries | 1 |
| no retry configuration | No retries | 1 |
retries=N is exactly RetryPolicy(max_attempts=N + 1). The decorator counts
retries because that is how people describe the behavior (“retry three times”);
the policy counts attempts because that is what it enforces.
Decorator options
from relprim import resilient
@resilient(retries=3, retry_on=(TemporaryProviderError, ConnectionError))
async def call_provider(prompt: str) -> str:
return await provider.generate(prompt)
retriesmust be anintof 0 or more. Booleans are rejected.retry_onis a tuple ofExceptionsubclasses and defaults to(Exception,). It requiresretries:retry_onon its own raisesValueErrorrather than being silently ignored.- To configure backoff, pass a policy instead:
retry=RetryPolicy(...).retrycannot be combined withretriesorretry_on.
RetryPolicy
from relprim import ExponentialBackoff, RetryPolicy, resilient
retry_policy = RetryPolicy(
max_attempts=4,
backoff=ExponentialBackoff(base_delay_seconds=0.5, max_delay_seconds=8),
retry_on=(TemporaryProviderError,),
)
@resilient(retry=retry_policy)
async def call_provider(prompt: str) -> str:
return await provider.generate(prompt)
| Field | Default | Notes |
|---|---|---|
max_attempts |
3 |
Total attempts, including the first. An int of at least 1. |
backoff |
ExponentialBackoff() |
Delay before each retry. |
retry_on |
(Exception,) |
Non-empty tuple of Exception subclasses. |
sleeper, async_sleeper |
time.sleep, asyncio.sleep |
Injectable, for example to avoid real sleeps in tests. |
Policies are immutable and can be shared between operations.
Choosing what to retry
A failure is retried when it is an instance of a retry_on type and attempts
remain. A failure that does not match ends the primary attempts immediately and
moves on to the fallback, if any.
The failures RelPrim itself raises inside an attempt are matched the same way:
OperationTimeoutErrorwhen the timeout expires;ValidationFailedErrorwhen the result fails validation;CircuitBreakerOpenErrorwhen the circuit breaker rejects the attempt.
With the default (Exception,) all of them are retried. Narrow retry_on to
express what you actually consider transient, for example
(TemporaryProviderError, OperationTimeoutError). Exceptions configured in a
rate-limit policy are handled by that policy first and
do not need to be listed in retry_on.
retry_on accepts only Exception subclasses. BaseException types such as
asyncio.CancelledError or KeyboardInterrupt raise TypeError, so
cancellation can never be configured as retryable.
Exponential backoff with jitter
ExponentialBackoff computes the delay before retry n (1-based) as
base_delay_seconds × multiplier^(n − 1), capped at max_delay_seconds. With
jitter enabled, the actual delay is drawn uniformly from zero to that value,
which spreads out retries from many clients failing at the same time.
| Field | Default | Rule |
|---|---|---|
base_delay_seconds |
0.1 |
Finite, 0 or more, not above max_delay_seconds. |
max_delay_seconds |
10.0 |
Finite, 0 or more. |
multiplier |
2.0 |
Finite, at least 1. |
jitter |
True |
Full jitter: uniform in [0, computed delay]. |
With the defaults, the upper bounds of the first delays are 0.1, 0.2, 0.4 and 0.8 seconds. No delay is computed after the final attempt.
Counting attempts
The execution report records each attempt. Two counters summarize them:
attempt_countis the number of attempts, including a fallback attempt;retry_countis the number of primary attempts after the first one. A fallback attempt is never a retry.
Each failed attempt’s ExecutionError.retryable records whether the failure
was classified as retryable, including on the final attempt.
When structured events are enabled, each retry emits
retry.scheduled with the delay and its source before RelPrim waits.
Cancellation
Cancelling the caller while an attempt runs, or while RelPrim waits before the
next attempt, raises asyncio.CancelledError immediately. No further attempt or
fallback runs.
Builder API
from relprim import RetryPolicy, async_operation
result = await (
async_operation("call_provider", provider.generate)
.with_retry(RetryPolicy(max_attempts=4, retry_on=(TemporaryProviderError,)))
.run("Summarize this ticket")
)
Using RetryPolicy on its own
A policy can also retry a call outside an operation, with
await policy.run_async(operation, *args) for async callables or
policy.run(operation, *args) for synchronous ones. Used this way it returns
the raw value and, after the final failed attempt, raises RetryError with
.attempts and .cause. There is no execution report, and RetryError is
never raised by operations or decorated functions.
