All guides
No guide matches your search.
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.
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
| Kind | What you receive | Example |
|---|---|---|
| Your failure | Your own exception, unchanged, with its own class and message. Reqtide never wraps it. | The upstream timed out |
| A Reqtide decision | A typed exception that implements ProblemCarrier and ReqtideException | The rate limit refused the call, the circuit is open, or the deadline passed |
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.
$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:
order 42Step 2: Catch a refusal and read its reason
// 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:
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 laterreason()returns aReasonCode. Branch on it, never ongetMessage().problem()returns aProblem. It is a redacted, JSON-serializable report shaped after RFC 9457 (type,title,detail) with Reqtide members (category,code,stage,severity,retryabilityand ids). The exception came out ofexecute(), so it names the operation withoperationId,profileVersionandcorrelationId.- Reqtide builds a
Problemfrom 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
titleanddetailis not, so do not parse it. Ignore unknown members.
Retryability
| Value | Meaning |
|---|---|
after_wait | A policy refused the call. Try again after a wait. |
transient | Retrying later may succeed, and no side-effect uncertainty exists. |
never | Fix the configuration or the code. Repeating the same call cannot help. |
unknown | A mutation may have been applied. Reconcile instead of repeating blindly. |
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
// 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:
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.
// 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:
disposition: rejected
dispatched: 0
problem code: rate.exhaustedThe 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().
// 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:
disposition: failed
dispatched: 2
problem code: attempts_exhausted
last business failure: Reqtide\Examples\ErrorHandling\UpstreamTimeoutThe 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
// 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:
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, thereason()of an event or thereason()of a diagnostic. Keep adefaultbranch when youmatchoverReasonCode. New cases are added in minor releases, so an exhaustivematchover it is unsupported. Thematchin step 2 is overRetryability. - 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()returnsnullfor them. - The full registry is the
ReasonCodeenum insrc/Error/ReasonCode.php(opens in a new tab).
Common mistakes
- Catching
\Throwableand 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.exhaustedrefusal at once in a loop. Its retryability isafter_wait. - Logging
$e->getMessage()of a Reqtide exception as a stable identifier. Logreason()->valueandproblem()->type()instead.
Was this page helpful?