Skip to the guide
All guides

Error handling

Tell your own exceptions from Reqtide decisions. Read the reason code and the problem report, and see how detailed mode returns the same facts as data.

app Reqtide PHP 0.1.0checked reading 8 minutes

On this page

Every code block comes from examples/07-error-handling.php (opens in a new tab). The ideas come first, then the code.

Two kinds of failure

KindWhat you receiveExample
Your failureYour own exception, unchanged, with its own class and message. Reqtide never wraps it.The upstream timed out
A Reqtide decisionA typed exception that implements ProblemCarrier and ReqtideExceptionThe rate limit refused the call, the circuit is open, or the deadline passed
The two kinds of failure and what each one gives you.

A third group is a configuration mistake. It raises InvalidConfigurationException, which is also a ProblemCarrier, before anything is dispatched.

Step 1: Set up a pipeline that refuses

The pipeline admits one call per hour. The first call goes through and the second call is refused.

PHP
$base = static fn(): PipelineBuilder => PipelineBuilder::profile('orders', 'v1')
    ->partition('shop', 'prod', 'orders-api')
    ->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative);

// A pipeline that admits exactly one call per hour, so the second call is refused.
$pipeline = $base()
    ->rateLimit(PipelineBuilder::tokenBucket(1, 1, Duration::seconds(3600)))
    ->compileInMemory(ExceptionClassifier::create());

$fetchOrder = static fn(): string => 'order 42';

echo $pipeline->execute($fetchOrder, $pipeline->newContext(Duration::seconds(30), operationId: 'orders-1')), "\n";

The output so far:

Text
order 42

Step 2: Catch a refusal and read its reason

PHP
// 1. A refusal is a typed exception. Every policy exception implements ProblemCarrier.
try {
    $pipeline->execute($fetchOrder, $pipeline->newContext(Duration::seconds(30), operationId: 'orders-2'));
} catch (ReqtideException $e) {
    if (!$e instanceof ProblemCarrier) {
        throw $e;
    }
    echo get_class($e), "\n";
    echo 'reason: ', $e->reason()->value, "\n";
    echo json_encode($e->problem(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n";

    // 2. Decide what to do from the machine-readable fields, not from the message.
    echo match ($e->problem()->retryability()) {
        Retryability::AfterWait => "action: ask the caller to try again later\n",
        Retryability::Transient => "action: retry now\n",
        Retryability::Never => "action: fix the configuration or the code\n",
        Retryability::Unknown => "action: reconcile before repeating\n",
    };
}

The output:

Text
Enaxon\Reqtide\Exception\AdmissionRejectedException
reason: rate.exhausted
{
    "type": "tag:enaloo.com,2026:reqtide:problem:rate.exhausted",
    "title": "Rate limit exhausted",
    "category": "admission",
    "code": "rate.exhausted",
    "stage": "admission",
    "severity": "info",
    "retryability": "after_wait",
    "detail": "Rate limit exhausted. Category: admission; retryability: after_wait; stage: admission.",
    "operationId": "orders-2",
    "profileVersion": "v1",
    "attemptId": null,
    "correlationId": "orders-2",
    "causes": []
}
action: ask the caller to try again later
  • reason() returns a ReasonCode. Branch on it, never on getMessage().
  • problem() returns a Problem. It is a redacted, JSON-serializable report shaped after RFC 9457 (type, title, detail) with Reqtide members (category, code, stage, severity, retryability and ids). The exception came out of execute(), so it names the operation with operationId, profileVersion and correlationId.
  • Reqtide builds a Problem from code metadata only. Exception messages, request data and secrets never enter it. An underlying cause appears as a class name and a code.
  • The member names and their order are part of the API. The wording of title and detail is not, so do not parse it. Ignore unknown members.

Retryability

ValueMeaning
after_waitA policy refused the call. Try again after a wait.
transientRetrying later may succeed, and no side-effect uncertainty exists.
neverFix the configuration or the code. Repeating the same call cannot help.
unknownA mutation may have been applied. Reconcile instead of repeating blindly.
The four retryability values of a problem and what each one means.

These values describe the Reqtide decision. They say nothing about your business result.

Categories and severity

category() is one of policy, infrastructure, configuration, classification, fallback, admission, retry, accounting or preparation.

severity() is advisory. It uses the PSR-3 level names (debug, info, notice, warning, error, critical) as plain strings, because Core has no PSR-3 dependency. No registry code uses critical today. The refusals in this guide are info, because a refusal means the protection is working.

Step 3: Your own exceptions pass through

PHP
// 3. Your own exceptions are never wrapped. They come out exactly as your callable threw them.
$failing = $base()->compileInMemory(ExceptionClassifier::create());
try {
    $failing->execute(static fn(): string => throw new UpstreamTimeout('upstream timed out'), $failing->newContext(Duration::seconds(30)));
} catch (UpstreamTimeout $e) {
    echo 'business exception: ', $e::class, ' (', $e->getMessage(), ")\n";
}

The output:

Text
business exception: Reqtide\Examples\ErrorHandling\UpstreamTimeout (upstream timed out)

Step 4: Detailed mode returns the same facts as data

executeDetailed() returns a refusal as data in an ExecutionCompletion instead of throwing it.

PHP
// 4. Detailed mode returns the same facts as data instead of throwing.
$completion = $pipeline->executeDetailed($fetchOrder, $pipeline->newContext(Duration::seconds(30), operationId: 'orders-3'));
echo 'disposition: ', $completion->disposition->value, "\n";
echo 'dispatched: ', $completion->summary->dispatched, "\n";
echo 'problem code: ', $completion->problem()?->code->value, "\n";

The output:

Text
disposition: rejected
dispatched: 0
problem code: rate.exhausted

The dispositions are succeeded, failed, rejected, deferred, cancelled, deadline_exceeded, fallback_succeeded and fallback_failed.

The completion also carries the per-attempt records, bounded diagnostics and a summary of counts (dispatched, retries, admittedNotDispatched, fallbackExecutions and more). problem() is null when Reqtide has nothing to report, for example after a success or a plain business failure.

Step 5: When the retry policy gives up

When retries stop because of the retry policy itself, your last exception is kept as the result. Reqtide records the reason as a problem in the retry stage.

A refusal by another protection on a later attempt behaves differently. The protections are the circuit, bulkhead, rate limit and cooldown. The refusal becomes the result, and your last exception is attached as getPrevious().

PHP
// 5. When the retry policy gives up, the last business failure is kept, and the reason is recorded.
$retrying = $base()
    ->retry(2, BackoffMode::Constant, Duration::milliseconds(5), Duration::milliseconds(5), JitterMode::None)
    ->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10))
    ->compileInMemory(ExceptionClassifier::create()->retryOn(UpstreamTimeout::class));
$safety = OperationSafety::replayableRead(ReplaySafetyContract::forReadOnlyCallable('get-order', Duration::seconds(3600)));
$completion = $retrying->executeDetailed(
    static fn(): string => throw new UpstreamTimeout('upstream timed out'),
    $retrying->newContext(Duration::seconds(30), $safety, operationId: 'orders-4'),
);
echo 'disposition: ', $completion->disposition->value, "\n";
echo 'dispatched: ', $completion->summary->dispatched, "\n";
echo 'problem code: ', $completion->problem()?->code->value, "\n";
echo 'last business failure: ', $completion->terminalOutcome?->exception()::class, "\n";

The output:

Text
disposition: failed
dispatched: 2
problem code: attempts_exhausted
last business failure: Reqtide\Examples\ErrorHandling\UpstreamTimeout

The simple execute() call would throw your UpstreamTimeout here. Detailed mode also tells you why the retries stopped: attempts_exhausted.

Step 6: Configuration mistakes name the option

PHP
// 6. A configuration mistake is an InvalidConfigurationException that names the option. It is raised before
//    anything is dispatched: while building the pipeline, or when a call lacks what a retry requires.
try {
    $base()->retry(3, BackoffMode::Constant, Duration::milliseconds(5), Duration::milliseconds(5), JitterMode::None)
        ->compileInMemory(ExceptionClassifier::create());
} catch (InvalidConfigurationException $e) {
    echo $e->getMessage(), "\n";
}
try {
    $retrying->execute(static fn(): string => 'order 42', $retrying->newContext(Duration::seconds(30)));
} catch (InvalidConfigurationException $e) {
    echo $e->getMessage(), "\n";
}

The output:

Text
retry(maxAttempts=3) requires retryBudget(): retries must be paid for so a failing dependency is not hammered. Every call also needs an explicit replay-safety assertion, for example ReplaySafetyContract::forReadOnlyCallable(); Reqtide never infers that repeating a callable is safe.
maxAttempts greater than one requires an explicit replay-safety contract; the operation is Unsafe.

Using the registry

  • Branch on $exception->reason(), $problem->code, the reason() of an event or the reason() of a diagnostic. Keep a default branch when you match over ReasonCode. New cases are added in minor releases, so an exhaustive match over it is unsupported. The match in step 2 is over Retryability.
  • The registry is append-only. A published value never changes meaning and is never reused. Changing the category or the retryability of a code is a breaking change. Severity and wording are advisory. A retired code stays emitted for at least one minor release.
  • Codes from foreign components are not registry members. Your classifier's own outcome codes and the reasons of a third-party backend are examples. ReasonCode::tryFrom() returns null for them.
  • The full registry is the ReasonCode enum in src/Error/ReasonCode.php (opens in a new tab).

Common mistakes

  • Catching \Throwable and treating every exception as an upstream failure. A refusal is not your callable failing. When the pipeline refuses, your callable was never invoked.
  • Retrying a rate.exhausted refusal at once in a loop. Its retryability is after_wait.
  • Logging $e->getMessage() of a Reqtide exception as a stable identifier. Log reason()->value and problem()->type() instead.