Skip to content
RelPrim docsCore concepts

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:

  1. Idempotency resolves the key. A duplicate call joins an execution in progress or replays a stored result instead of running the steps below.
  2. 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.
  3. 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.
  4. After the final primary failure, if a fallback chain is configured and its FallbackPolicy matches the failure, the candidates run in order and each result is validated.
  5. The outcome is an OperationResult on success, or OperationExecutionError with the execution report on failure.
One execution of an operation. Idempotency, when configured, wraps all of it.

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.CancelledError and other BaseExceptions such as KeyboardInterrupt;
  • 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.