All guides
No guide matches your search.
Retry and budget
Configure retries and size a retry budget. Learn the three things a retry needs, and what Reqtide does when a server says "retry after 60 seconds".
On this page
The retry budget belongs to one PHP process, and Reqtide never interrupts a running callback. See Limits.
The code blocks come from examples/06-getting-started.php (opens in a new tab), examples/07-error-handling.php (opens in a new tab) and examples/09-choosing-policies.php (opens in a new tab). A script runs them and compares them with the recorded output.
A retry needs three things
Reqtide never retries on its own initiative. It makes a second attempt only when all three of these hold:
| Needed | Why | Where you give it |
|---|---|---|
| Your statement that repeating the call is safe | Reqtide cannot know whether a callable has side effects, and it has no HTTP knowledge. | OperationSafety in newContext() |
| A retry budget | Retries must be paid for, so a failing dependency is not hammered. | retryBudget(), required whenever retry() allows more than one attempt |
| A retryable exception class | You decide which of your failures are transient. | ExceptionClassifier::retryOn() |
Without the statement, the context defaults to OperationSafety::unsafe(), and a pipeline that allows more than one attempt refuses to run. An exception you did not list is a plain failure. Reqtide returns it to you and does not retry it.
Configure the retry and its budget
// At most 3 attempts in total (the first call counts), exponential backoff from 10 ms up to 100 ms.
->retry(3, BackoffMode::Exponential, Duration::milliseconds(10), Duration::milliseconds(100), JitterMode::Full)
// Retries draw from a budget so a failing dependency is not hammered: 10 tokens, 10 more per minute.
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10))maxAttemptscounts the first attempt, so 3 means one call plus at most two retries. It must be between 1 and 64.- The delay grows from
baseDelay, constant or exponential, and never exceedsmaximumDelay. JitterMode::Fulldraws each delay at random from zero up to the computed delay.JitterMode::Nonewaits exactly the computed delay.- The optional last argument is the time that must still remain after a delay for another attempt to be worth starting. The default is none.
The retry budget is a token bucket. retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10)) holds at most 10 tokens and adds 10 per minute. It holds a reservation for at most 10 seconds while a retry is being dispatched. That last number must exceed the attempt timeout by more than 1 second.
The budget is an absolute pool of tokens, not a ratio of first attempts, and you size it. An oversized pool gives no protection. An undersized one stops retries early with retry_budget_exhausted.
Make the statement on every call
// One context per operation. Its deadline covers all attempts and waits together. forReadOnlyCallable() is YOUR
// statement that running this callable again is safe; Reqtide cannot check it, and refuses to retry without it.
$context = $pipeline->newContext(
Duration::seconds(30),
OperationSafety::replayableRead(ReplaySafetyContract::forReadOnlyCallable('get-payment', Duration::seconds(3600))),
);forReadOnlyCallable() is your assertion that running the callable again is safe. Reqtide cannot check it. The call also takes how long the assertion stays valid. Two other kinds exist: providerIdempotentMutation() for a provider that deduplicates repeated mutations, and unsafe(), the default.
You are likely to make two mistakes first. Reqtide raises both before it sends anything. The first comes while you build the pipeline:
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.The second comes when a retrying pipeline gets a context without the assertion:
maxAttempts greater than one requires an explicit replay-safety contract; the operation is Unsafe.The deadline still rules
A retry whose delay does not fit in the remaining deadline does not start. The reason is retry_delay_exceeds_deadline. The total deadline covers all attempts and all waits. Nothing is held during a delay: no circuit permit and no bulkhead slot.
Every retry also costs a rate-limit unit. See Rate limiting.
When the retry policy gives up
When retries stop because of the retry policy itself, Reqtide keeps your last exception as the result and records the reason as a retry-stage problem. The plain execute() call throws your own exception. Detailed mode tells you why it stopped:
// 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";disposition: failed
dispatched: 2
problem code: attempts_exhausted
last business failure: Reqtide\Examples\ErrorHandling\UpstreamTimeoutA refusal by another protection on a later attempt behaves differently. The protections are the circuit, the bulkhead, the rate limit and the cooldown. The refusal becomes the result, and your last exception is attached as getPrevious().
Retry-After: honored, or returned to you
A wait stated by the server, such as the Retry-After header of an HTTP 429, is a lower bound for the next attempt. Reqtide never sends sooner and never clamps the wait down. Reqtide has no HTTP awareness, so turning a 429 and its header into a Duration is the job of your classifier, with retryAfterFrom(). The class below stands in for the exception you would throw on a 429:
/** Stands in for an HTTP 429 with a Retry-After header. */
final class Throttled extends \RuntimeException
{
public function __construct(public readonly Duration $wait)
{
parent::__construct('throttled');
}
}When honoring the wait is impossible, you get a deferral and Reqtide does not sleep:
echo "-- 4. Retry-After: a wait too long for the policy is returned to you, not slept through\n";
$pipeline = $base()
->retry(3, BackoffMode::Constant, Duration::milliseconds(5), Duration::milliseconds(100), JitterMode::None)
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10))
->cooldown(Duration::seconds(10))
->compileInMemory(
ExceptionClassifier::create()
->retryOn(Throttled::class)
->retryAfterFrom(static fn(\Throwable $e): ?Duration => $e instanceof Throttled ? $e->wait : null),
);
$safety = static fn(): OperationSafety => OperationSafety::replayableRead(
ReplaySafetyContract::forReadOnlyCallable('list-products', Duration::seconds(3600)),
);
$calls = 0;
$throttled = static function () use (&$calls): string {
++$calls;
throw new Throttled(Duration::seconds(60)); // "retry after 60 seconds", but the longest retry delay is 100 ms
};
try {
$pipeline->execute($throttled, $pipeline->newContext(Duration::seconds(30), $safety()));
} catch (AdmissionDeferredException $e) {
echo "deferred with {$e->reason()->value}; wait hint present: ", $e->decision->retryAfter !== null ? 'yes' : 'no', "\n";
echo 'the throttling exception is kept as previous: ', $e->getPrevious() instanceof Throttled ? 'yes' : 'no', "\n";
}-- 4. Retry-After: a wait too long for the policy is returned to you, not slept through
deferred with retry_after_exceeds_maximum_delay; wait hint present: yes
the throttling exception is kept as previous: yesReqtide defers in two cases:
- The wait exceeds the
maximumDelayof the retry policy:retry_after_exceeds_maximum_delay. - The wait does not fit in the remaining deadline:
retry_after_exceeds_deadline.
The exception is an AdmissionDeferredException that carries the wait, with your throttling exception as getPrevious(). A positive Retry-After also extends a shared cooldown for the partition, so the next operation is deferred without calling the API. Bulkhead and cooldown covers that.
Testing retries
A test of exponential backoff should not sleep. With the virtual clock, a scripted callable and a recorded event list, you can assert the exact delays. See Testing with the virtual clock.
Was this page helpful?