Skip to content
RelPrim docsFallbacks

Reliability

Fallbacks

Recover from a failed primary operation with a fallback function or an ordered chain of candidates. Candidate order, FallbackPolicy, validation of fallback results and how fallbacks appear in reports.

A fallback produces a usable result when the primary operation cannot: a secondary provider, a cached value, a degraded response. In RelPrim, the fallback runs once, after the primary attempts are exhausted, and is recorded as one additional attempt.

A single fallback

from relprim import resilient


async def call_backup(prompt: str) -> str:
    return await backup_provider.generate(prompt)


@resilient(retries=2, timeout=10, fallback=call_backup)
async def call_primary(prompt: str) -> str:
    return await primary_provider.generate(prompt)

The fallback receives the same arguments as the decorated function. Its name in reports and events is its __name__, here call_backup. When the fallback returns a different type, the result is typed as the union: OperationResult[str | CachedAnswer].

Fallback chains

For several candidates, or to name them explicitly, use fallback_chain:

from relprim import fallback_chain, resilient

backups = fallback_chain(
    ("secondary_provider", call_secondary),
    ("response_cache", read_cached_response),
)


@resilient(retries=2, timeout=10, fallbacks=backups)
async def call_primary(prompt: str) -> str:
    return await primary_provider.generate(prompt)

In the builder API, use .with_fallbacks(backups). fallback and fallbacks are mutually exclusive.

Candidates are tried in order. The first one that returns a valid result wins, and the remaining candidates do not run. Candidates are not retried.

When the fallback runs

The chain runs after the final primary failure, which is one of:

  • the last allowed attempt failed;
  • a failure did not match retry_on, so no retry was allowed;
  • a rate-limit delay exceeded the maximum wait.

It runs only if the chain’s FallbackPolicy matches that failure:

from relprim import FallbackPolicy, OperationTimeoutError, fallback_chain

backups = fallback_chain(
    ("secondary_provider", call_secondary),
    policy=FallbackPolicy(fallback_on=(ProviderUnavailableError, OperationTimeoutError)),
)

fallback_on defaults to (Exception,). It is applied twice: to the primary failure, deciding whether the chain starts, and to each candidate’s failure, deciding whether the next candidate is tried. A non-matching failure is not routed elsewhere; it ends the execution, so programming errors are not masked by a backup provider.

Validation of candidates

When the operation has a validation policy, each candidate’s result is validated with it. An invalid result is a candidate failure (ValidationFailedError), so the next candidate is tried just as if the candidate had raised. A fallback can therefore recover from a primary result that failed validation, but it can never return an invalid value.

Validation events for a candidate carry fallback_candidate_name.

When every candidate fails

The execution fails with OperationExecutionError whose .cause is a FallbackChainError. Its .failures holds each candidate’s exception in candidate order, including any ValidationFailedError:

from relprim import FallbackChainError, OperationExecutionError

try:
    await call_primary("Summarize this ticket")
except OperationExecutionError as error:
    if isinstance(error.cause, FallbackChainError):
        for failure in error.cause.failures:
            print(type(failure).__name__, failure)

Timeouts and circuit breakers

Candidates run without the operation’s timeout policy and outside its circuit breaker. They are a separate path, often to a separate dependency, and the primary’s limits do not describe them. If a candidate needs a deadline, give it one:

import asyncio


async def call_secondary(prompt: str) -> str:
    async with asyncio.timeout(5):
        return await secondary_provider.generate(prompt)

An open circuit breaker is a common reason to fall back: the rejected primary attempt fails with CircuitBreakerOpenError, which the default policy matches.

Reports and events

The fallback is recorded as one ExecutionAttempt with fallback=True, numbered after the last primary attempt. It counts in attempt_count but not in retry_count: fallback attempts are not retries.

Where Keys
Report metadata fallback_used is True once a fallback attempt ran.
Successful fallback attempt fallback_failed (False), fallback_candidate_name, fallback_candidate_index (position in the chain, from 0), fallback_failure_count (candidates that failed first).
Failed fallback attempt fallback_failed (True), fallback_failure_count.
Events fallback.started, then fallback.succeeded or fallback.failed, alongside attempt.succeeded or attempt.failed for the fallback attempt.

See Structured events for every payload.

Using a chain on its own

await chain.run(*args) runs the candidates outside an operation and returns a FallbackResult with value, candidate_name, candidate_index and failures, or raises FallbackChainError. No validation, report or events are involved in that case.