A transient failure (HTTP 503, a lock wait timeout) may succeed on a retry after a pause that doubles each time, plus random jitter so clients do not retry in lockstep. A fallback answers with something acceptable, such as a cached value. A bug should fail loudly: retry() catches only TransientException, so a DivisionByZeroError goes straight to the top-level handler.
<?php
final class TransientException extends RuntimeException {}
function retry(callable $op, int $attempts = 3, int $baseMs = 100): mixed {
for ($try = 1; ; $try++) {
try {
return $op($try);
} catch (TransientException $e) { // only failures worth repeating
if ($try === $attempts) throw $e;
$wait = $baseMs * 2 ** ($try - 1); // 100, 200, 400 ms ...
echo "try $try: {$e->getMessage()}, waiting $wait ms + jitter\n";
usleep(($wait + random_int(0, $baseMs)) * 1000);
}
}
}
$rate = retry(fn($try) => $try < 3 ? throw new TransientException('HTTP 503') : 4.12);
try { $quote = retry(fn() => throw new TransientException('timeout'), attempts: 2); }
catch (TransientException) { $quote = 4.99; } // fallback: yesterday's cached quote
echo "rate $rate, shipping $quote\n";Output
try 1: HTTP 503, waiting 100 ms + jitter try 2: HTTP 503, waiting 200 ms + jitter try 1: timeout, waiting 100 ms + jitter rate 4.12, shipping 4.99
Retry only what is safe to repeat, such as a read or a payment call with an idempotency key, and cap the wait so a slow service cannot tie up every PHP-FPM worker (Sizing pm.max_children). Never write catch (Throwable) {}: it turns a loud, fixable bug into silent wrong data.