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.
Architecture fit. This is a Guzzle HandlerStack middleware, not a Laravel package — no service provider, no facade, no config publishing. That is a feature, not a gap: it sits at the transport layer beneath Illuminate\Http\Client, so it applies uniformly to controllers, jobs, console commands, and third-party SDKs that build on Guzzle. For a Laravel shop it cleanly complements, rather than overlaps with, Http::retry(), which handles connection failures and a fixed-millisecond backoff but does not negotiate Retry-After, does not support per-request elapsed-time ceilings, and does not have a status-code allowlist with a callback hook. The middleware's real value is policy-as-configuration: retry statuses, backoff multiplier, attempt caps, wall-clock budgets, method allowlists, and a pre-retry callback that receives the request and options by reference. That maps directly onto decisions a TPM usually has to arbitrate (how hard do we retry, against whom, and what do we log) and expresses them in one place instead of scattered try/catch blocks.
Integration feasibility. Low. A single composer require plus one push() onto a stack, or a shared client factory bound in the container. Options cascade three levels (request → client constructor → middleware factory), which supports sensible defaults with per-integration overrides — exactly the governance shape you want. No PHP extensions, no native dependencies, no runtime services.
Technical risk. Moderate, concentrated in four areas:
| Risk | Detail | Severity |
|---|---|---|
| Version matrix | README states v3 targets Guzzle 8+; Guzzle 6/7 requires pinning ^2. Laravel 10–12 ship Guzzle 7.x, so the default composer require may resolve a package incompatible with the framework's locked Guzzle. The package description ("Guzzle v6/7+") contradicts the README. |
High if unpinned |
| Blocking backoff | Delays are implemented as in-process sleeps. Under PHP-FPM that is a worker held hostage; under Octane it is a stalled event-loop task. | High |
| Idempotency | retry_on_methods defaults to *. Retrying POST/PATCH against a non-idempotent endpoint can duplicate writes or double-charge. |
High |
| Adoption signal | 222 stars, MIT, single maintainer, effectively no downstream dependents in the graph. Small surface (a few hundred lines, PHPStan level 8, near-100% coverage) makes it forkable, but you are the de facto maintainer of your fork. | Medium |
A secondary note: the README describes the default backoff as exponential, but the documented formula is multiplier × attempt number — that is linear growth. Do not size latency budgets off the "exponential" label.
Key questions to resolve before approval.
^2 or ^3?Retry-After compliance and timeout-retry semantics, or does Http::retry() plus queue-level retries already satisfy the requirement? If both are used, they must never be stacked on the same call.max_retry_attempts, give_up_after_secs, max_allowable_timeout_secs), and who approves exceptions?on_retry_callback hook satisfy it without additional instrumentation?Stack fit. The middleware slots beneath Laravel's HTTP client without touching application code paths. Two viable shapes:
ClientInterface.Http::globalOptions() / per-request withOptions() to inject the handler or per-call retry options into Illuminate\Http\Client. Lower friction, but Guzzle options are unvalidated pass-throughs — a typo in retry_on_stauts fails silently and yields no retries, which is a nasty outage-time surprise. The factory approach lets you validate option keys in one place.Recommend the factory, with a thin RetryPolicy config object sourced from config/services.php so retry behaviour is reviewable in a PR rather than buried in a controller.
Migration path. Additive and reversible. Nothing else changes; removing the middleware restores prior behaviour exactly. That makes it safe to pilot on one integration and roll back without code churn.
Compatibility. Confirm the resolved Guzzle major in composer.lock before writing any code — this is the single blocking check. Verify PHP version support, and confirm the app does not already register Guzzle's stock RetryMiddleware on the same stack. Confirm no existing Http::retry() calls target the same endpoints. Note that retried request bodies must be rewindable; streamed or non-seekable bodies will fail on the second attempt, so upload endpoints need retry_enabled => false.
Sequencing.
Retry-After.^2 for Guzzle 7, ^3 for Guzzle 8) and lock it.max_retry_attempts than the shipped 10; set give_up_after_secs; restrict retry_on_methods).on_retry_callback to the Laravel logger and a metric counter.Maintenance. Low ongoing engineering cost, low but non-zero vendor cost. Single-maintainer project with a changelog, tagged releases, static analysis, and high coverage — solid hygiene for its size. The realistic exposure is abandonment rather than bugs: budget a fork contingency. The code is small enough that a mid-level engineer can read the whole thing in an afternoon, which materially de-risks that scenario. Pin the version; do not float.
Support. No Laravel-specific documentation, so your team owns the mapping from Laravel's HTTP client to this middleware's options, plus the semantic decisions (what counts as retryable, what the budgets are). The on_retry_callback signature includes a nullable response and nullable exception, which is the correct shape for timeout-triggered retries but is easy to misuse — write one adapter and reuse it rather than letting each team write its own.
Scaling. This is the section that should drive the decision. Retries multiply load precisely when a dependency is already degraded; ten attempts per request turns a 2× traffic spike into a 20× one. Mitigations: set max_retry_attempts to 2–3 for user-facing synchronous paths, keep higher values only for async jobs; always set give_up_after_secs and max_allowable_timeout_secs so a hostile or buggy Retry-After value cannot pin a worker indefinitely; restrict retry_on_methods. Separately, the in-process sleep model does not scale with concurrency — on Octane or a small FPM pool, long backoffs consume capacity and can convert a dependency blip into a self-inflicted outage. Prefer short delays in the middleware and push long-horizon retries to the queue.
Failure modes to instrument for.
Http::retry() plus queue retries — producing multiplicative attempt counts.Retry-After values far beyond acceptable user latency; worker occupancy grows unbounded.Ramp-up. Engineering ramp is roughly a day for a competent PHP developer — the API is small and the README is complete. The expensive part is organisational: agreeing an idempotency policy, per-integration budgets, and a telemetry standard. Plan one to two sprints for the policy work and treat the library integration itself as the cheap part. Net assessment: worth adopting for a controlled set of idempotent, non-latency-critical integrations where Retry-After compliance matters; not a blanket default across the whole application without the caps and allowlists above.
How can I help you explore Laravel packages today?