On this page
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.
Free resources for this topic
Code snippets and fixes for this topic
Get one practical tip every week
New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.
Free download: Laravel interview questions (PDF)
Have a question about this post?
Ask the community: other developers and the author can answer.
Get notified about new articles
One click, no email needed. Turn it off anytime with the bell at the top.
Get new articles on your iPhone
Add DEV Talk to your Home Screen, open it, then tap the bell.
Enjoying the content?
If this article helped you, you can support the author with a coffee. It keeps new posts coming.
Comments 0
No comments yet
Be the first to share your thoughts.