How do you add retries to a Guzzle client with Middleware::retry, and what does the decider see when pushed onto HandlerStack::create()?
answer
- middleware wraps the handler
- decider gets retries, request, response, exception
- retries count starts at 0
- default delay 1 s, 2 s, 4 s
- pushed inside http_errors: raw 5xx
basics
~20 sPush 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
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
Know that Guzzle retries through middleware on a HandlerStack and that you write the decider that says yes or no.
Explain the decider's four arguments, the count starting at 0, the millisecond delay, and why nothing stops retries unless the decider does.
Reason about stack order (push lands inside http_errors), retry only idempotent requests on transient failures, and bound total latency including blocking delays.
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.