DEV Community

Cover image for Laravel HTTP Client Retry: Only Retry Failures That Matter
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel HTTP Client Retry: Only Retry Failures That Matter

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on DEV Talk.

Top comments (0)