skip to content

In Laravel's HTTP client, how does Http::retry(3, 100) count attempts, which failures does it retry, and what does its when-callback do?

level: middleimportance: must knowfreq 50%

answer

  1. total attempts, not extra retries
  2. 4xx retried too by default
  3. sleep argument defaults to 0 ms
  4. callback gets Throwable and PendingRequest
  5. throw: false returns last response

basics

~20 s

Http::retry(3, 100) makes at most three attempts, sleeping 100 ms between them. Without a when-callback it retries every failed status and connection error; the callback decides per failure. Exhausted attempts throw RequestException unless throw: false is passed.

solid answer

~40 s

The first argument is the **total** number of attempts, so `retry(3, 100)` is one try plus two retries with 100 ms sleeps; an array such as `retry([100, 500, 2000])` means four attempts with those sleeps. The sleep can be a closure receiving the attempt number and the exception, and it defaults to 0, so `retry(3)` fires back to back. With no third argument every failure is retried: `ConnectionException`, 5xx, and also 4xx answers that will never succeed. The when-callback, `fn (Throwable $e, PendingRequest $request) => bool`, gets a `ConnectionException` or a `RequestException` and may mutate the request, for example refreshing a token after a 401. When attempts run out Laravel throws `RequestException` even without `throw()`; `throw: false` returns the last response, but a final connection failure still throws.

code

php · 24 lines
php
<?php

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

$response = Http::withToken($tokens->current())
    ->timeout(5)
    ->retry(3, fn (int $attempt) => $attempt * 200,
        function (\Throwable $e, PendingRequest $request) use ($tokens) {
            if ($e instanceof ConnectionException) {
                return true;
            }
            if (! $e instanceof RequestException) {
                return false;
            }
            if ($e->response->status() === 401) {
                $request->withToken($tokens->refresh());
                return true;
            }
            return $e->response->serverError() || $e->response->tooManyRequests();
        })
    ->get('https://carrier.example/api/tracking/1Z999');

go deeper

for a junior

Remember that retry(3, 100) means three attempts in total with 100 ms pauses, and that it is chained before get() or post().

for a middle

Explain that 4xx answers are retried unless a when-callback filters them, what the callback receives, and how throw: false changes the final outcome.

for a senior

Show production judgment: retry only transient failures, never blindly retry non-idempotent POSTs, keep attempts times timeout inside the caller's budget, and refresh tokens in the callback.

for a principal

Discuss where retry policy should live — per call, in a per-API macro or client class, or in the queue — and how retries at several layers multiply load on a struggling upstream.

## What the arguments mean `retry()` lives on `Illuminate\Http\Client\PendingRequest` with the signature `retry(array|int $times, Closure|int $sleepMilliseconds = 0, ?callable $when = null, bool $throw = true)`. | Argument | Example | Meaning | |---|---|---| | `$times` as int | `3` | **total attempts**, including the first | | `$times` as array | `[100, 500, 2000]` | one sleep per retry; attempts = count + 1, so 4 here | | `$sleepMilliseconds` | `100` or a closure | pause before the next attempt; **defaults to 0** | | `$when` | `fn (Throwable $e, PendingRequest $r) => ...` | whether this failure deserves another attempt | | `$throw` | `throw: false` | whether running out of attempts throws | So `retry(3, 100)` is one attempt plus at most two retries, each preceded by a 100 ms sleep. The sleep is a blocking pause inside the current PHP process: during it, the web request or queue worker does nothing else. A closure in the second position receives the attempt number and the exception, so `fn (int $attempt) => $attempt * 200` grows the pause. ## What gets retried by default Without a when-callback, **every** failure is retried: - a `ConnectionException` — timeout, refused connection, DNS failure; - any **server error** (5xx); - any **client error** (4xx), including a 422 validation error or a 404 that will never change. The last point surprises people. Laravel treats every non-2xx response as a failed attempt; only the when-callback separates a retryable failure from a permanent one. ## The when-callback The third argument receives the failure as a `Throwable`: - a `ConnectionException` when no response arrived; - a `RequestException` when a failed response arrived, with the response on `$e->response`. It also receives the `PendingRequest` itself, so it can **change the request before the next attempt**. The documented example refreshes an expired bearer token: if the exception is a `RequestException` with a 401 status, call `$request->withToken($newToken)` and return `true`. Returning `false` stops retrying at once, and an exception thrown inside the callback also stops the loop and propagates. A sensible callback for a flaky shipping-carrier API retries connection failures, 5xx and 429 (too many requests), and nothing else: ```php fn (Throwable $e) => $e instanceof ConnectionException || ($e instanceof RequestException && ($e->response->serverError() || $e->response->tooManyRequests())) ``` ## What happens when attempts run out 1. If any attempt succeeds (2xx), its `Response` is returned and no further attempt is made. 2. If the last attempt returns a failed status and `$throw` is `true` (the default), Laravel throws `RequestException` — **even if you never called `throw()`**. Adding `retry()` therefore changes the error-handling contract of a call. 3. If the when-callback returns `false` for a failed response, that response is treated the same way: thrown as `RequestException` by default. 4. With `throw: false`, the **last response** is returned instead, and you check `failed()` yourself. 5. If the last attempt fails at the connection level, `ConnectionException` is thrown **even with `throw: false`**, because there is no response to return. ## Handling the outcome in the caller Because `retry()` changes whether a call throws, the calling code should be written for it explicitly. A typical carrier lookup wraps the retried call in `try`/`catch`: - catch `RequestException` to handle "the carrier kept answering with an error" — the last failed response is on `$e->response`; - catch `ConnectionException` to handle "the carrier never answered"; - or pass `throw: false` and branch on `$response->failed()`, remembering that a connection failure on the final attempt still throws. Logging inside the when-callback is a cheap way to see how often retries happen at all; a retry that fires on most calls is hiding an outage rather than smoothing over a blip. ## Backoff and Retry-After Laravel does not read a `Retry-After` header on its own. If the carrier sends one, the sleep closure can read `$e->response->header('Retry-After')` when `$e` is a `RequestException` and return a capped number of milliseconds. How to choose those delays — exponential growth, jitter, retry budgets — is general resilience theory; the Laravel part is only where the numbers go. ## Pitfalls - `retry(3)` without a sleep hits a struggling upstream three times in quick succession. - Retrying a non-idempotent `POST`, such as buying a shipping label, can create duplicates when a timed-out attempt actually succeeded on the carrier's side. - Retries multiply the worst-case wait, because each attempt may use the full `timeout`. - Requests inside `Http::pool()` honour `retry()` as well; their delay is handed to Guzzle as its `delay` option, and a request that exhausts its attempts lands in its result slot as an exception object.

  • Does adding retry() change what happens to a 500 even if you never call throw()?
    Yes. With more than one attempt and the `throw` argument left `true`, a final failed response is thrown as `RequestException`. Without `retry()`, the same 500 would have come back as a `Response`. Pass `throw: false` to keep the old behaviour and receive the last response.
  • How would you honour a carrier's Retry-After header with Laravel's retry()?
    Pass a closure as the sleep argument. It receives the attempt number and the exception; when the exception is a `RequestException`, read `$e->response->header('Retry-After')`, convert seconds to milliseconds, cap it, and fall back to your own delay when the header is absent. Laravel does not read the header by itself.
  • What does Http::retry([100, 500, 2000]) do?
    An array as the first argument sets both the count and the schedule: four attempts in total, sleeping 100 ms, then 500 ms, then 2000 ms before the second, third and fourth attempts.

saying these in an interview costs you the question

  • retry(3, 100) makes three retries after the first try, four requests in total.
  • Without a when-callback Laravel retries only 5xx responses and timeouts.
  • retry(3) waits 100 milliseconds between attempts when no sleep is given.
  • throw: false guarantees no exception, even when the carrier is unreachable.
  • The when-callback receives the Response object rather than an exception.
  • Laravel reads Retry-After headers and waits for them automatically.