caseyamcl/guzzle_retry_middleware
Guzzle Retry Middleware automatically retries Guzzle HTTP requests when servers return 429 or 503, or when requests time out. It honors Retry-After headers, backs off exponentially, supports configurable retry limits and callbacks, and drops into your handler stack with no setup.
This is the single most important install decision, and the package's own description is misleading:
| Guzzle version | Package version | Install |
|---|---|---|
| 8.x | v3.x (current, 2026-08-02) |
composer require caseyamcl/guzzle_retry_middleware |
| 6.x / 7.x | v2.x | composer require "caseyamcl/guzzle_retry_middleware:^2" |
The README title says "Guzzle v8" but the Packagist summary still says "v6/7+". Check composer show guzzlehttp/guzzle in your project before installing, or you'll get a confusing dependency resolution error (or worse, resolve to v2 without realising it and lose newer options).
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleRetry\GuzzleRetryMiddleware;
$stack = HandlerStack::create();
$stack->push(GuzzleRetryMiddleware::factory());
$client = new Client(['handler' => $stack]);
That's genuinely it — no config needed. You now retry on 429 and 503 up to 10 times, honouring Retry-After when present and falling back to exponential-ish backoff (1.5 × attempt number) when it isn't.
GuzzleRetryMiddleware::factory(array $defaultOptions = []) — the only entry point you'll normally touch.Retry-After formats, connection resets, null responses in callbacks).A flaky third-party API. Most teams reach for this the day a vendor starts returning 429 under load or 503 during deploys:
$stack = HandlerStack::create();
$stack->push(GuzzleRetryMiddleware::factory([
'max_retry_attempts' => 5,
'retry_on_timeout' => true,
'give_up_after_secs' => 30,
'on_retry_callback' => fn($attempt, $delay, $req, $opts, $res, $ex) =>
logger()->warning('Retrying upstream', [
'uri' => (string) $req->getUri(),
'attempt' => $attempt,
'delay' => $delay,
'status' => $res?->getStatusCode(),
'error' => $ex?->getMessage(),
]),
]));
$client = new Client(['handler' => $stack, 'timeout' => 10.0]);
That's a 10-minute integration that eliminates most "random 503 from upstream" support tickets.
Don't build HandlerStack inline at call sites — you'll end up with five stacks with five different retry policies. Bind one configured client as a singleton:
// AppServiceProvider::register()
$this->app->singleton('upstream.http', function () {
$stack = HandlerStack::create();
$stack->push(GuzzleRetryMiddleware::factory(config('services.upstream.retry')));
return new Client([
'handler' => $stack,
'base_uri' => config('services.upstream.base_uri'),
'timeout' => 10.0,
'connect_timeout' => 5.0,
'headers' => ['Accept' => 'application/json'],
]);
});
Keep the retry block in config/services.php so ops can tighten limits without a deploy.
Options merge in this order (later wins):
GuzzleRetryMiddleware::factory([...]) — team-wide defaultsnew Client([...]) — per-integration defaultsThe practical pattern: put conservative defaults in the factory, and let each call site relax or harden them.
// Bulk import endpoint: be patient
$client->post('/import', ['max_retry_attempts' => 10, 'give_up_after_secs' => 120]);
// User-facing read: fail fast, don't make the user wait
$client->get('/profile', ['max_retry_attempts' => 2, 'give_up_after_secs' => 5]);
// Webhook replay: never retry, upstream is the source of truth
$client->post('/webhook', ['retry_enabled' => false]);
max_retry_attempts => 0 also disables retries for a single call — useful when you've set a client-wide default of 5 but one endpoint is non-idempotent.
This is the pattern that prevents duplicate side effects. retry_on_methods defaults to '*' (all methods), which means a POST /payments will be retried on a 503 — and a 503 from a load balancer often means the request was processed.
$stack->push(GuzzleRetryMiddleware::factory([
'retry_on_methods' => ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'],
]));
Or with finer control via should_retry_callback, e.g. allow POST retries only when the server didn't acknowledge receipt:
'should_retry_callback' => function ($attempt, $request, $options, $response) {
if ($request->getMethod() === 'POST' && $response?->hasHeader('X-Request-Accepted')) {
return false; // server saw it; do not duplicate
}
return true;
},
The default delay is multiplier × attemptNumber — deterministic. If 500 workers all hit a 503 simultaneously, they all retry in lockstep. Replace the multiplier with a callable:
'default_retry_multiplier' => fn(int $numRequests, ?ResponseInterface $response): float
=> min(30.0, (1.5 * $numRequests) * (0.5 + mt_rand() / mt_getrandmax())),
'max_allowable_timeout_secs' => 30, // cap a hostile Retry-After
Note the ?ResponseInterface — it's null on connect timeouts, so don't call getStatusCode() on it.
retry_only_if_retry_after_header => true is a good default for expensive upstreams: only retry when the server actually sent guidance. Pair it with retry_on_status => [429] so you're rate-limit-aware but not blindly hammering a dead service on 503.
expose_retry_header => true puts the final attempt count onto the response as X-Retry-Counter. Forward that to your own callers and error trackers — it turns "the vendor is slow" into a measurable metric:
$response = $client->get('/reports');
$attempts = (int) ($response->getHeaderLine('X-Retry-Counter') ?: 0);
if ($attempts > 0) {
metrics()->increment('upstream.retries', ['endpoint' => '/reports', 'count' => $attempts]);
}
Laravel's Http::retry() retries on thrown exceptions, not on 429/503 status codes, and knows nothing about Retry-After. They solve different problems, so wire the middleware in and keep retry() for connection-level flakiness if you want it:
// Laravel 10.15+
Http::globalOptions(['handler' => app('upstream.stack')]);
$response = Http::get('https://api.example.org/things');
// Or per-client, when you need a distinct pool of sockets
$client = app('upstream.http'); // Guzzle client
Http::buildClient(fn() => $client); // or Http::setClient()
Avoid Http::withOptions(['handler' => $stack]) scattered across ten classes — each call rebuilds the stack.
In tests, kill the sleep by returning zero delay, and drive responses with MockHandler:
$mock = new MockHandler([
new Response(503, ['Retry-After' => '1']),
new Response(429),
new Response(200, [], '{"ok":true}'),
]);
$stack = HandlerStack::create($mock);
$stack->push(GuzzleRetryMiddleware::factory([
'default_retry_multiplier' => fn() => 0.0,
]));
$client = new Client(['handler' => $stack]);
You can then assert the callback fired twice, and inspect $request->getHeaderLine('X-Retry-Counter')-style counters on the response.
The middleware sleeps synchronously. usleep() blocks the PHP process. In a queue worker that's fine (it's one job's slot), but in PHP-FPM a Retry-After: 120 response will hold a worker for two minutes and can cascade into a 502 storm on your own app. Always set max_allowable_timeout_secs and give_up_after_secs when serving traffic directly, or push retryable calls onto a queue.
Retry-After is honoured without negotiation. A misbehaving upstream returning Retry-After: 86400 will make your client wait a day. max_allowable_timeout_secs caps the per-attempt wait, so set it. give_up_after_secs caps the total elapsed retry window — you generally want both.
Unbounded retries are the default. 10 attempts with a 1.5 multiplier is ~82 seconds of cumulative sleep before the first exception surfaces. That's a long time for a synchronous web request. Override per-request, not just globally.
retry_on_timeout is off by default and needs company. It only helps if you've also set connect_timeout and/or timeout on the client — otherwise there's nothing to time out. And it retries ConnectExceptions including "connection refused", which for a genuinely-down host means 10 futile attempts.
Non-idempotent methods are retried by default. Covered above, but worth repeating because it's the most likely production incident this package causes. retry_on_methods is a one-line fix — set it on day one.
Middleware ordering matters. $stack->push() puts the retry middleware at the top (outermost) of the stack, so it sees the final response. If you unshift() it below a logging middleware, retries still work but your logging sees only one request. Also: don't stack this on top of Guzzle's built-in RetryMiddleware — you'll get nested, multiplied retry counts.
Three-tier precedence can surprise you. A client-level max_retry_attempts silently overrides the factory value, and per-request beats both. When retries behave unexpectedly, grep for max_retry_attempts and retry_enabled across the codebase before reading the library source.
Streamed and non-rewindable bodies can break retries. With 'stream' => true or a body resource that has already been consumed, the re-sent request may be empty. The on_retry_callback receives $request by reference — use that to rewind or replace the body:
'on_retry_callback' => function ($attempt, $delay, RequestInterface &$request, array &$options) {
if ($request->getBody()->isSeekable()) {
$request->getBody()->rewind();
}
},
Guard the callback against nulls. On connect timeouts $response is null and $exception is set; on 429/503 retries it's the opposite. The README's sample callback calls $response->getStatusCode() unconditionally and will fatal on a timeout.
3xx responses in retry_on_status are explicitly untested and will produce odd behaviour — redirects are resolved below the middleware, so retrying them is meaningless.
Servers with non-spec Retry-After dates (e.g. Fri, 08 Aug 2026 12:00:00 GMT+2 or missing weekday) will fail to parse and silently fall back to the multiplier algorithm. Fix with retry_after_date_format and a retry_after_header override if the vendor uses a custom header like X-RateLimit-Reset (note: numeric values are interpreted as seconds, so epoch timestamps need a custom multiplier callable).
Extension point: should_retry_callback is where non-standard logic belongs — checking response bodies for error codes, honouring a budget, or short-circuiting during maintenance windows. It runs after status matching, so it can only veto, not add.
Low ecosystem coupling cuts both ways. 222 stars and zero dependents means no third party will break you, but it also means fewer people hitting the edge cases before you do. Pin the minor version ("^3.0"), keep the retry config in one config file, and add a test that asserts your idempotency policy — that's your real safety net.
How can I help you explore Laravel packages today?