guzzlehttp/retry-subscriber
A Guzzle subscriber that automatically retries failed HTTP requests using configurable strategies, including exponential backoff. Set retry limits, delays, and the exceptions or status codes that trigger a retry to make your Guzzle 6/7 API calls more resilient.
guzzlehttp/retry-subscriber is a Guzzle event subscriber that re-sends a failed request according to a filter (should I retry?) and a delay strategy (how long to wait?). It is a Guzzle 4/5-era package and is archived, but the mental model transfers one-to-one to Guzzle 6/7's Middleware::retry() and Laravel's Http::retry().
Version reality check first. This package depends on Guzzle 5.x. Laravel 7+ ships Guzzle 6/7, so composer require guzzlehttp/retry-subscriber will fail resolution on a modern app. You can only use it as-is on a legacy Laravel 4/5 codebase (or one where you have deliberately pinned guzzlehttp/guzzle:^5.3).
composer require guzzlehttp/retry-subscriber
First use case — a flaky third-party API that returns 503s under load. Build one client, attach the subscriber once:
use GuzzleHttp\Client;
use GuzzleHttp\Subscriber\Retry\RetrySubscriber;
$client = new Client(['base_url' => 'https://api.partner.test']);
$client->getEmitter()->attach(new RetrySubscriber([
'filter' => RetrySubscriber::createStatusFilter([500, 502, 503, 504]),
'delay' => RetrySubscriber::exponentialDelay(500, 2), // 500ms, 1s, 2s, 4s, 8s
'max' => 5, // 5 *retries*, 6 attempts total
]));
Two things to internalise from the README: the filter table (createStatusFilter, createConnectFilter, createCurlFilter, createIdempotentFilter) and the delay table (constantDelay, linearDelay, exponentialDelay). Everything else is a closure you write yourself.
Register a single retrying client in the container. Don't attach subscribers in controllers — every attach() call stacks another subscriber and multiplies your retry count.
// app/Providers/ApiServiceProvider.php
$this->app->singleton('partner.http', function () {
$client = new \GuzzleHttp\Client([
'base_url' => config('services.partner.url'),
'defaults' => ['timeout' => 5, 'connect_timeout' => 2],
]);
$client->getEmitter()->attach(new \GuzzleHttp\Subscriber\Retry\RetrySubscriber([
'filter' => \GuzzleHttp\Subscriber\Retry\RetrySubscriber::createStatusFilter([429, 500, 502, 503, 504]),
'delay' => \GuzzleHttp\Subscriber\Retry\RetrySubscriber::exponentialDelay(250, 2),
'max' => 4,
]));
return $client;
});
Wrap it in a small PartnerApi class so callers never touch getEmitter().
Chain filters so you retry both connection failures and 5xx, but not 4xx:
'filter' => RetrySubscriber::createChainFilter([
RetrySubscriber::createConnectFilter(),
RetrySubscriber::createStatusFilter([429, 500, 502, 503, 504]),
]),
Or write the predicate directly — the filter receives the command, the response (nullable) and the exception (nullable):
'filter' => function ($command, $response = null, $e = null) {
if ($e instanceof \GuzzleHttp\Exception\ConnectException) {
return true;
}
return $response && in_array($response->getStatusCode(), [429, 500, 502, 503, 504], true);
},
Add jitter to the delay. The built-in exponential delay is deterministic, so a fleet of queue workers failing at the same instant retries in lockstep:
'delay' => function ($retry) {
$base = min(8000, 250 * (2 ** ($retry - 1)));
return (int) ($base * (0.5 + mt_rand() / mt_getrandmax()));
},
Keep the subscriber idempotent-only for mutating endpoints. RetrySubscriber::createIdempotentFilter() allows GET/HEAD/PUT/DELETE/OPTIONS/TRACE and rejects POST — use it as a base and layer your own status check on top.
Don't double-retry. If a job already has $tries = 3 and the HTTP client retries 5 times, a single failure becomes 18 outbound calls. Pick one layer: retry in the job for business-level failures, retry in the subscriber only for transient transport errors (and set $tries = 1 for that job).
Observe retries. Attach a second subscriber that listens for the same error event and logs, so you can see retry rates in your logs before a partner notices:
class RetryLogger implements \GuzzleHttp\Event\SubscriberInterface
{
public function getEvents()
{
return ['error' => ['onError', 0]];
}
public function onError(\GuzzleHttp\Event\ErrorEvent $event)
{
Log::warning('guzzle.retry', [
'url' => $event->getRequest()->getUrl(),
'error' => $event->getException()->getMessage(),
]);
}
}
$client->getEmitter() throws Call to undefined method GuzzleHttp\Client::getEmitter() the moment the box is on Guzzle 6/7. The equivalent modern code is HandlerStack::create() + $stack->push(Middleware::retry($decider, $delay)), or simply Http::retry(4, 250)->timeout(5)->get(...) in Laravel 8+.max is retries, not attempts. 'max' => 5 means one initial call plus five retries.Retry-After on a 429 impossible from the delay closure alone; you need a custom subscriber or to read it and sleep before re-throwing.createStatusFilter() defaults to [500, 503] only. 502 and 504 — the two most common gateway timeouts — are not retried unless you list them.timeout => 5, max => 5 and exponential backoff, a single call can occupy a worker for ~45 seconds. Make sure QUEUE_WORKER_TIMEOUT and any upstream gateway timeout exceed that, or you'll get killed mid-retry.error events. Instrument early or you'll debug "why is checkout slow" for a day.GuzzleHttp\Subscriber\Mock (Guzzle 5's mock, not Guzzle 6's MockHandler) queued with 503s followed by a 200, and assert attempt counts with GuzzleHttp\Subscriber\History. Attach the mock before the retry subscriber so the queue is consumed in order.Retry-After) belongs in a custom SubscriberInterface implementation rather than a cleverer closure — the closure has no access to the response or the client.How can I help you explore Laravel packages today?