Skip to content
RelPrim docsRetry

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)
  • retries must be an int of 0 or more. Booleans are rejected.
  • retry_on is a tuple of Exception subclasses and defaults to (Exception,). It requires retries: retry_on on its own raises ValueError rather than being silently ignored.
  • To configure backoff, pass a policy instead: retry=RetryPolicy(...). retry cannot be combined with retries or retry_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:

  • OperationTimeoutError when the timeout expires;
  • ValidationFailedError when the result fails validation;
  • CircuitBreakerOpenError when 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_count is the number of attempts, including a fallback attempt;
  • retry_count is 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.