Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Retry Subscriber Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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(),
        ]);
    }
}

Gotchas and Tips

  • It's archived and Guzzle 5-only. $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.
  • Delay is in milliseconds, and the delay callable only receives the retry count — not the response. That makes honouring 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.
  • Request bodies must be rewindable. A retried POST with a raw stream resource will go out with an empty body because the stream was already consumed. Pass a string (Guzzle wraps it in a seekable stream) or explicitly rewind. Combined with the idempotent filter, the safest rule is: no POST retries at all.
  • createStatusFilter() defaults to [500, 503] only. 502 and 504 — the two most common gateway timeouts — are not retried unless you list them.
  • Budget the wall-clock time. With 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.
  • Retries are invisible unless you log them. There's no counter, no event name specific to retries — you only see the underlying error events. Instrument early or you'll debug "why is checkout slow" for a day.
  • Testing: use 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.
  • Extension point: anything the built-ins can't express (per-endpoint limits, circuit breaking, Retry-After) belongs in a custom SubscriberInterface implementation rather than a cleverer closure — the closure has no access to the response or the client.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console