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.
