Skip to content

Laravel HTTP Client Retry: Only Retry Failures That Matter

Laravel HTTP client retry: use the callback argument to retry connection errors and 5xx but not 404 or 422, and throw: false to keep the last response.

Dasun Kalupahana 3 min read 110 views 0 comments
On this page
  1. The default: retry everything
  2. Decide what to retry with the third argument
  3. Get the last response back with throw: false
  4. Check it with a fake
  5. Production considerations

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.

FailureException passed to the callbackRetry?
DNS failure, timeout, connection refusedConnectionExceptionYes
500, 502, 503, 504RequestExceptionYes (serverError())
429 Too Many RequestsRequestExceptionYour call; see below
404, 422, other 4xxRequestExceptionNo

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 $tries and backoff may 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.

Ask the community

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.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.