Overview
Core concepts
The model behind every RelPrim operation, from attempts, retries and fallbacks to execution reports and structured events, plus the fixed order in which policies apply.
Operations
An operation is a named async callable plus the policies that govern it.
The name appears in execution reports and structured events, so choose a stable,
specific one such as create_payment rather than call or handler.
You create an operation with the @resilient decorator or with
async_operation(name, callable). Either way, the result is the same model:
calling the operation runs one execution.
Attempts
An execution consists of one or more attempts. A primary attempt calls your operation. If it fails and retrying is allowed, another primary attempt follows. If the primary attempts are exhausted and a fallback chain is configured, the fallback runs as one further attempt.
| Term | Meaning |
|---|---|
| Primary attempt | One call of the operation itself, inside the circuit breaker and timeout. |
| Retry | A primary attempt after the first one. retry_count counts these. |
| Fallback attempt | The run of the fallback chain, recorded as a single attempt with fallback=True. |
attempt_count |
All attempts, including the fallback attempt. |
Attempt numbers are 1-based, and the fallback attempt is numbered after the last primary attempt.
Execution order
The order in which policies apply is fixed. It does not depend on the order of decorator arguments or builder calls:
- Idempotency resolves the key. A duplicate call joins an execution in progress or replays a stored result instead of running the steps below.
- Primary attempt, up to the retry policy’s
max_attempts:- the circuit breaker admits or rejects the attempt;
- the timeout policy bounds the call to your operation;
- the validation policy checks the returned value.
- On a failure with attempts remaining, the rate-limit policy or the retry backoff selects a delay, RelPrim waits, and the next primary attempt starts.
- After the final primary failure, if a fallback chain is configured and
its
FallbackPolicymatches the failure, the candidates run in order and each result is validated. - The outcome is an
OperationResulton success, orOperationExecutionErrorwith the execution report on failure.
Two consequences are worth knowing early:
- Validation runs inside the circuit breaker, so a rejected result counts as a breaker failure, just like an exception.
- Fallback candidates run without the timeout policy and outside the circuit breaker. Give a candidate its own timeout if it needs one.
What counts as a failure
A primary attempt fails when your operation raises an Exception, when its
timeout expires (OperationTimeoutError), when the circuit breaker rejects it
(CircuitBreakerOpenError) or when its result fails validation
(ValidationFailedError). All four are handled the same way: the retry policy
decides whether to retry, then the fallback policy decides whether to fall
back.
Some things are deliberately not operation failures and propagate unchanged, without retries or fallbacks:
asyncio.CancelledErrorand otherBaseExceptions such asKeyboardInterrupt;- exceptions raised by an event sink;
- errors from the idempotency key factory, and
IdempotencyReentryError.
Configuration mistakes never become operation failures either. Invalid or
conflicting options raise ValueError or TypeError when a policy is
constructed or resilient(...) is called, before anything runs.
Results
A successful execution returns OperationResult[T]:
value: what your operation, or the fallback candidate that succeeded, returned;report: the execution report.
A failed execution raises OperationExecutionError with .cause (the final
failure) and .report. There is no “failed result” object to check: success and
failure are distinguished by return versus raise.
Low-level primitives used on their own, such as RetryPolicy.run_async(...)
or TimeoutPolicy.run_async(...), return raw values and do not produce reports.
Execution reports
An ExecutionReport describes one execution after the fact:
| Field | Description |
|---|---|
operation_name |
The operation’s name. |
status |
ExecutionStatus: succeeded, failed or timed_out. |
started_at, duration_seconds |
Timezone-aware UTC start time and total duration. |
attempts |
A tuple of ExecutionAttempt, one per attempt. |
attempt_count, retry_count, retried |
Counts derived from the attempts. |
last_attempt, last_error |
The final attempt and the error of the last failed attempt. |
metadata |
Read-only mapping of JSON scalars, such as fallback_used. |
Each ExecutionAttempt has attempt_number, status, started_at,
duration_seconds, error, metadata and fallback. Errors are stored as
ExecutionError summaries (type, message, module, retryable) rather
than exception objects, so a report is safe to serialize with to_dict().
The metadata keys that are part of the stable contract are listed in Structured events.
Structured events
While an execution runs, RelPrim can emit structured events such as
attempt.failed or fallback.started to an EventEmitter. Events are opt-in
and delivered inline to the sinks you configure: in memory, SQLite,
OpenTelemetry or your own. Reports describe an execution after it ends; events
describe it as it happens. See Structured events.
Idempotency wraps everything
With an idempotency policy, one execution, including all of its retries, timeouts, validation and fallbacks, runs under one key. Concurrent duplicate calls join it, and later calls replay its successful result without running anything. Joined and replayed calls receive the report of the original execution and emit no events of their own.
Cancellation
Cancellation always wins. Cancelling the task that awaits an operation raises
asyncio.CancelledError from wherever the execution is (inside an attempt, a
retry delay, a rate-limit wait, a fallback candidate or an event sink) and
nothing further runs. Cancellation is never retried, never triggers a fallback,
is never recorded by a circuit breaker and is never cached by idempotency.
Decorator or builder
Both APIs configure the same AsyncOperation.
from relprim import RetryPolicy, TimeoutPolicy, async_operation, resilient
@resilient(retries=3, timeout=10)
async def generate(prompt: str) -> str:
return await provider.generate(prompt)
generate_operation = (
async_operation("generate", provider.generate)
.with_retry(RetryPolicy(max_attempts=4))
.with_timeout(TimeoutPolicy(seconds=10))
)
result = await generate_operation.run("Summarize this ticket")
@resilient(...) |
async_operation(...) |
|
|---|---|---|
| Best for | Wrapping a function you define | Operations assembled at runtime, shared configuration |
| Configuration | Simple options (retries=3) or policy objects (retry=RetryPolicy(...)) |
Policy objects only (.with_retry(...)) |
| Name | Defaults to the function’s __name__ |
Always explicit |
| Static typing | Keeps the signature; validators, fallbacks and key factories are checked at runtime | Also checks validators, fallbacks and key factories against the operation’s types |
Builder methods never mutate the operation; each returns a new one, and calling
the same method twice keeps the last policy. Each simple decorator option and
its policy counterpart are mutually exclusive: passing both raises ValueError.
The API reference lists the full mapping.
