The Laravel HTTP client retry feature is easy to switch on and easy to misuse. Http::retry(3, 100) retries on almost any failure, including a 404 that will never succeed and a 422 validation error that will fail identically every time. Each wasted attempt adds latency and load to someone else's API. retry() has two lesser-known arguments that fix this: a callback that decides whether a failure is worth retrying, and a throw flag that controls what you get back when every attempt fails.
The default: retry everything
use Illuminate\Support\Facades\Http;
$response = Http::retry(3, 100)->post('https://api.example.com/orders', $payload);
The first argument is the maximum number of attempts and the second is the wait between them in milliseconds. If the request fails with a client or server error, Laravel tries again. That is fine for a flaky network and wrong for a request that is simply invalid.
Decide what to retry with the third argument
Per the Laravel documentation, the third argument is a callable that determines whether the retries should actually be attempted. It receives the exception and the pending request. Return true to retry and false to stop.
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
$response = Http::retry(3, 200, function (Throwable $exception, PendingRequest $request) {
// Network problems: worth another try.
if ($exception instanceof ConnectionException) {
return true;
}
// Server errors (5xx): the other side may recover.
if ($exception instanceof RequestException) {
return $exception->response->serverError();
}
return false;
})->post('https://api.example.com/orders', $payload);
With this callback, a connection failure or a 5xx is retried up to three attempts. A 404 or a 422 is not retried at all. The callback returns false and the client stops immediately.
| Failure | Exception passed to the callback | Retry? |
|---|---|---|
| DNS failure, timeout, connection refused | ConnectionException |
Yes |
| 500, 502, 503, 504 | RequestException |
Yes (serverError()) |
| 429 Too Many Requests | RequestException |
Your call; see below |
| 404, 422, other 4xx | RequestException |
No |
For 429, retrying immediately usually makes things worse. If you do retry it, make the delay meaningful. The second argument can be a closure that receives the attempt number and returns the milliseconds to wait, such as fn (int $attempt) => $attempt * 500, and you can also pass an array of delays as the first argument, as in Http::retry([100, 200, 400]).
Get the last response back with throw: false
By default, when all attempts fail, Laravel throws a RequestException. If you would rather inspect the final response yourself, pass throw: false:
$response = Http::retry(3, 200, $shouldRetry, throw: false)
->post('https://api.example.com/orders', $payload);
if ($response->failed()) {
// $response is the last response received, e.g. the 422 with its error body.
logger()->warning('Order sync failed', [
'status' => $response->status(),
'body' => $response->json(),
]);
}
This is especially useful with the callback above. A 422 is not retried, and instead of an exception you get the response and can read the validation errors from the body.
The catch: connection failures still throw
The documentation is explicit about one limit: if all of the requests fail because of a connection issue, a ConnectionException is still thrown even when throw is false. There is no response to return in that case, so throw: false only covers failures that came back as an HTTP response. If you want to handle both, wrap the call:
try {
$response = Http::retry(3, 200, $shouldRetry, throw: false)->post($url, $payload);
} catch (ConnectionException $e) {
// The service was unreachable on every attempt.
$response = null;
}
Check it with a fake
You can prove the callback works without calling a real API. This test feeds a 422 and expects exactly one request, because a 422 must not be retried:
use Illuminate\Support\Facades\Http;
Http::fake([
'api.example.com/*' => Http::sequence()
->push(['error' => 'invalid'], 422)
->push(['ok' => true], 200),
]);
$response = Http::retry(3, 0, $shouldRetry, throw: false)
->post('https://api.example.com/orders', ['sku' => 'A1']);
expect($response->status())->toBe(422);
Http::assertSentCount(1);
Swap the first pushed response for a 503 and the same test should report two requests and a 200. (The test above uses Pest's expect(); in PHPUnit use $this->assertSame(422, $response->status()).)
Production considerations
-
Be careful retrying POST. A request that timed out may still have been processed on the other side. Retrying a payment or order creation can duplicate it unless the API supports an idempotency key, which you would send as a header with
withHeaders(). - Retrying multiplies load. Three attempts across 1,000 queued jobs can mean 3,000 calls to a service that is already struggling. Keep attempts low and delays non-zero.
-
Inside queued jobs, think twice. The job's own
$triesandbackoffmay already retry the whole job, so layering HTTP retries on top multiplies attempts. -
Set a timeout. Retries on a request that hangs for the default timeout can stall a worker for a long time. Add
->timeout(5)to bound each attempt.
References: Laravel HTTP Client: Retries and Laravel HTTP Client: Testing.
Originally published on DEV Talk.
Top comments (0)