skip to content

How do you add retries to a Guzzle client with Middleware::retry, and what does the decider see when pushed onto HandlerStack::create()?

level: seniorimportance: should knowfreq 38%

answer

  1. middleware wraps the handler
  2. decider gets retries, request, response, exception
  3. retries count starts at 0
  4. default delay 1 s, 2 s, 4 s
  5. pushed inside http_errors: raw 5xx

basics

~20 s

Push Middleware::retry($decider, $delay) onto a HandlerStack. The decider receives the retry count (starting at 0), the request, and a response or exception, and returns true to retry. Pushed after HandlerStack::create(), it sits inside http_errors, so a 5xx arrives as a response.

solid answer

~40 s

`Middleware::retry(callable $decider, ?callable $delay = null)` returns a middleware that I push onto the client's `HandlerStack`. After each attempt it calls the decider with `$retries` (0 on the first failure), the request, the response or `null`, and the exception or `null`. If it returns `true`, the middleware increments the count, asks `$delay` for milliseconds and sends again; the default delay is 1 s, 2 s, 4 s. Placement matters. `push()` appends toward the handler, so a retry pushed after `HandlerStack::create()` sits **inside** `http_errors`. A 503 then reaches the decider as a response, not a `ServerException`, and a timeout arrives as a `ConnectException`. I cap the count in the decider, retry only idempotent requests on 5xx or `ConnectException`, and keep per-attempt timeouts so the total stays bounded.

code

php · 23 lines
php
<?php
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
    function (int $retries, RequestInterface $request, ?ResponseInterface $response, ?Throwable $e): bool {
        if ($retries >= 2 || $request->getMethod() !== 'GET') {
            return false; // at most 3 attempts, idempotent reads only
        }
        return $e instanceof ConnectException
            || in_array($response?->getStatusCode(), [502, 503, 504], true);
    },
    fn (int $retries): int => 200 * 2 ** ($retries - 1), // 200 ms, 400 ms
), 'retry');

$client = new Client(['handler' => $stack, 'base_uri' => 'https://fx-a.example.test/', 'timeout' => 3]);

go deeper

for a junior

Know that Guzzle retries through middleware on a HandlerStack and that you write the decider that says yes or no.

for a middle

Explain the decider's four arguments, the count starting at 0, the millisecond delay, and why nothing stops retries unless the decider does.

for a senior

Reason about stack order (push lands inside http_errors), retry only idempotent requests on transient failures, and bound total latency including blocking delays.

for a principal

Decide where retries live across services so layers do not multiply attempts, and pair them with timeouts and a failure budget per upstream.

## Middleware in one paragraph A Guzzle **handler** takes a request and options and returns a promise of a response. A **middleware** is a function that takes the next handler and returns a new handler wrapping it, so it can change the request on the way down, or the promise on the way back. A `HandlerStack` holds a base handler and an ordered list of middleware; `HandlerStack::create()` adds the defaults `http_errors`, `allow_redirects`, `cookies` and `prepare_body`. The client uses the stack through its `handler` option. ## The retry middleware's contract `Middleware::retry($decider, $delay = null)` builds a `RetryMiddleware`. After every attempt: 1. On success it calls `$decider($retries, $request, $response, null)`; on failure it calls `$decider($retries, $request, null, $exception)`. 2. If the decider returns `false`, the response or the failure passes through unchanged. 3. If it returns `true`, the middleware increments `$retries`, calls `$delay($retries, $response, $request)` for a delay **in milliseconds**, and sends the request again through the rest of the stack. Details from the source: - The count starts at `0`: the middleware sets the `retries` request option to 0 before the first attempt if it is not already set. - The default delay is `2 ** ($retries - 1) * 1000` ms: 1 s, then 2 s, then 4 s. `RetryMiddleware::exponentialDelay()` still exists but has been deprecated since Guzzle 7.11. - There is **no built-in cap**. A decider that returns `true` for every 503 retries forever against a dead upstream. ## Where the middleware sits changes what it sees `HandlerStack::resolve()` wraps from the end of the list inward, so the **first** entry is the outermost layer. `push()` appends to the end, which puts new middleware **closer to the handler** than everything already there. `unshift()` puts it outermost. | Placement | Order, outside to inside | A 503 reaches the decider as | |---|---|---| | `$stack->push(Middleware::retry(...))` after `create()` | http_errors, redirects, cookies, prepare_body, **retry**, handler | a response with status 503 | | `$stack->unshift(Middleware::retry(...))` | **retry**, http_errors, ... , handler | a rejection with `ServerException` | In both placements a timeout arrives as a `ConnectException` in the exception argument. With the usual `push()`, the decider checks `$response?->getStatusCode()`. Only after the last attempt does `http_errors`, further out, turn a final 503 into `ServerException` for the caller. ## Writing a safe decider For the exchange-rate scenario, three providers each called with retries: - **Cap attempts.** Return `false` once `$retries` reaches 2 or 3. - **Retry transient failures only.** `ConnectException`, 502, 503 and 504 are worth another try; a 400 or 401 will fail the same way again. - **Respect idempotency.** A rates `GET` is safe to repeat; a `POST` that creates something may not be. Check `$request->getMethod()`. - **Bound the total time.** Each attempt gets the full `timeout`, and delays add on top. Three attempts with a 3-second timeout and 1 s + 2 s delays can take 12 seconds. When and how much to back off, and when to stop calling a failing upstream altogether, is resilience policy. The mechanism here is only where Guzzle lets you plug that policy in. ## How the delay is applied The middleware passes the delay as the `delay` request option. The synchronous cURL handler honours it with `usleep()`, which **blocks** the PHP worker for that long. The multi-handle handler used for async requests schedules the delayed transfer without blocking other transfers. Long backoffs in a web request therefore hold a worker; keep them short, or move retry-heavy work to a background job. ## Observing retries in production Retries hide failures: the caller sees a success while the upstream failed twice. Make them visible. The decider is a natural place to log, because it sees every failed attempt with its count, request and reason. The current count also travels in the `retries` request option, so a logging middleware placed inside the retry can tag each outgoing attempt. Watching the ratio of retried to first-try successes per provider shows an upstream degrading long before it fails outright. ## Testing it Queue `new Response(503)`, `new Response(503)` and `new Response(200)` in a `MockHandler`, build the stack with `HandlerStack::create($mock)`, push the retry middleware, and assert that the call returns 200 and that the mock's `count()` is 0.

  • Why does a decider that checks `$e instanceof ServerException` never retry a 503 when pushed after HandlerStack::create()?
    `push()` places the retry middleware inside `http_errors`, closer to the handler. The 503 comes back as a fulfilled response, so the decider gets `$response` with status 503 and `$exception` null. `ServerException` is only created later by `http_errors`, outside the retry. Check the response status, or `unshift()` the middleware to see exceptions instead.
  • What stops the retry middleware from retrying forever?
    Nothing built in. `RetryMiddleware` retries whenever the decider returns true and has no maximum of its own. The decider must stop, typically by returning false once `$retries` reaches a small limit, and should also exclude failures that will not change, such as 4xx responses.
  • Does the retry delay block the PHP process?
    On the synchronous cURL handler, yes: the delay is passed as the `delay` option and applied with `usleep()`, so the worker sleeps. With async requests on the multi-handle handler, the delayed transfer is scheduled without blocking the other transfers in flight.

Like a letter carrier with instructions to try a closed shop again: the carrier sees the 'closed' sign directly and decides, while the office behind them only files a complaint if the last visit also fails. Pushed inside http_errors, the retry sees the raw 503 before anyone turns it into an exception.

saying these in an interview costs you the question

  • Guzzle's retry middleware stops after three attempts by default.
  • The decider's retry count starts at 1 on the first failure.
  • A 503 always reaches the retry decider as a ServerException.
  • The delay callback returns seconds.
  • Retrying every failed POST is safe because Guzzle deduplicates requests.