PHPackages                             kyzegs/guzzle-rate-limit-middleware - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. kyzegs/guzzle-rate-limit-middleware

ActiveLibrary[HTTP &amp; Networking](/categories/http)

kyzegs/guzzle-rate-limit-middleware
===================================

A configurable Guzzle middleware for rate limiting HTTP requests based on response headers

v1.1.0(1mo ago)05851MITPHPPHP ^8.2

Since Jun 23Pushed 1mo agoCompare

[ Source](https://github.com/Kyzegs/guzzle-rate-limit-middleware)[ Packagist](https://packagist.org/packages/kyzegs/guzzle-rate-limit-middleware)[ RSS](/packages/kyzegs-guzzle-rate-limit-middleware/feed)WikiDiscussions main Synced 2w ago

READMEChangelog (2)Dependencies (5)Versions (3)Used By (1)

[![Guzzle Rate Limit Middleware banner](banner.svg)](banner.svg)

Guzzle Rate Limit Middleware
============================

[](#guzzle-rate-limit-middleware)

A configurable Guzzle middleware that prevents your application from hitting `429 Too Many Requests` by reading rate-limit response headers and delaying requests *before* they exceed the limit.

State is persisted through a pluggable store, so rate limiting works **across separate requests and processes** — not just within a single operation.

Features
--------

[](#features)

- 🔧 **Configurable headers** — works with any API (Discord, GitHub, Twitter, the IETF `RateLimit-*` draft, or your own).
- 💾 **Cross-process state** — share rate-limit state via PSR-16 (Redis, Memcached, Laravel/Symfony cache), the filesystem, or in-memory.
- ⏳ **Pre-emptive delays** — sleeps until a bucket resets instead of failing.
- 🔁 **429 retries** — honours `Retry-After` and retries up to a configurable limit, then optionally throws.
- 🪣 **Bucket-hash discovery** — adapts to APIs (like Discord) that assign buckets dynamically.
- 🔒 **Optional locking** — plug in a distributed lock to serialise concurrent callers.
- 🧪 **Fully testable** — the clock and sleeper are injectable, so timing is deterministic in tests.

Installation
------------

[](#installation)

```
composer require kyzegs/guzzle-rate-limit-middleware
```

Requires PHP 8.2+ and Guzzle 7.10+.

Quick start
-----------

[](#quick-start)

```
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Kyzegs\GuzzleRateLimitMiddleware\RateLimitMiddleware;

$stack = HandlerStack::create();
$stack->push(new RateLimitMiddleware());

$client = new Client(['handler' => $stack]);
```

The default middleware reads the standard `X-RateLimit-*` headers and keeps state in memory.

### Per-API presets

[](#per-api-presets)

```
RateLimitMiddleware::github();   // X-RateLimit-* headers
RateLimitMiddleware::twitter();  // x-rate-limit-* headers
RateLimitMiddleware::ietf();     // RateLimit-* (IETF draft)
RateLimitMiddleware::discord();  // Discord headers + bucket-hash discovery
```

The Discord preset also enables a cross-process 50 request/second global budget, isolates all state by a one-way authorization fingerprint, accepts the JSON `retry_after` fallback, and stops at 9,000 invalid requests per 10 minutes before Discord's Cloudflare threshold. These values are configurable through `Options`:

```
use Kyzegs\GuzzleRateLimitMiddleware\Config\GlobalLimit;
use Kyzegs\GuzzleRateLimitMiddleware\Config\InvalidRequestLimit;
use Kyzegs\GuzzleRateLimitMiddleware\Config\Options;

$middleware = RateLimitMiddleware::discord(options: new Options(
    globalLimit: new GlobalLimit(maxRequests: 50, windowSeconds: 1),
    invalidRequestLimit: new InvalidRequestLimit(maxRequests: 9000, windowSeconds: 600),
    maxDelaySeconds: 120,
));
```

Raw authorization and webhook tokens never appear in persisted bucket or lock keys. Interaction callback endpoints are excluded from Discord's bot-global budget. Shared-scope 429 responses do not consume the invalid-request budget.

Cross-process rate limiting
---------------------------

[](#cross-process-rate-limiting)

To rate limit across separate requests/processes, give the middleware a persistent store. The recommended option is any PSR-16 cache:

```
use Kyzegs\GuzzleRateLimitMiddleware\RateLimitMiddleware;
use Kyzegs\GuzzleRateLimitMiddleware\Store\Psr16Store;

$middleware = RateLimitMiddleware::github(
    store: new Psr16Store($psr16Cache), // e.g. Redis, Laravel or Symfony cache
);
```

Or use the zero-dependency filesystem store:

```
use Kyzegs\GuzzleRateLimitMiddleware\Store\FilesystemStore;

$middleware = RateLimitMiddleware::github(
    store: new FilesystemStore('/var/cache/rate-limits'),
);
```

### Available stores

[](#available-stores)

StoreCross-processNotes`InMemoryStore` (default)❌Lives for the PHP process only. Good for one long-running worker and tests.`FilesystemStore`✅JSON files with atomic writes. No extra dependencies.`Psr16Store`✅Wraps any `Psr\SimpleCache\CacheInterface` — Redis, Memcached, Laravel, Symfony, …Custom headers
--------------

[](#custom-headers)

Header names live in the `Headers` config object:

```
use Kyzegs\GuzzleRateLimitMiddleware\Config\Headers;
use Kyzegs\GuzzleRateLimitMiddleware\RateLimitMiddleware;

$headers = new Headers(
    limit:      'X-API-Limit',
    remaining:  'X-API-Remaining',
    reset:      'X-API-Reset',        // absolute timestamp OR relative seconds
    resetAfter: null,                 // relative seconds (preferred when present)
    retryAfter: 'Retry-After',        // used for 429 retry delays
    bucket:     null,                 // enables bucket-hash discovery when set
    global:     null,                 // "true" indicates a global rate limit
    scope:      null,                 // "global" indicates a global rate limit
);

$middleware = RateLimitMiddleware::create(headers: $headers);
```

`reset` values below the year-2000 epoch are treated as relative seconds; larger values as absolute UNIX timestamps.

Behaviour options
-----------------

[](#behaviour-options)

```
use Kyzegs\GuzzleRateLimitMiddleware\Config\Options;
use Kyzegs\GuzzleRateLimitMiddleware\RateLimitMiddleware;

$options = new Options(
    maxRetries:          3,      // retries for a request that keeps getting 429
    safetyBufferSeconds: 1.0,    // added to every computed delay (clock skew/latency)
    jitterPercent:       0.0,    // random extra delay, 0-100% of the base delay
    throwOnRateLimit:    true,   // throw once retries are exhausted on a 429
    maxStoreTtl:         604800, // upper bound for cached bucket state (seconds)
    retryStatusCodes:    [429],  // statuses that trigger a retry
);

$middleware = RateLimitMiddleware::create(options: $options);

// Presets: Options::default(), Options::conservative(), Options::aggressive()
```

When retries are exhausted on a `429` and `throwOnRateLimit` is `true`, a `Kyzegs\GuzzleRateLimitMiddleware\Exception\RateLimitExceededException` is thrown (carrying the request, response, retry-after seconds and global flag).

Bucket resolution
-----------------

[](#bucket-resolution)

Requests are grouped into buckets that share a rate limit. The default `DefaultBucketResolver` keys by `METHOD host /path` and collapses identifier-like path segments — numeric ids/snowflakes, UUIDs, and long hex tokens — to `{id}` (so `/users/1` and `/users/2`, or two UUIDs, share a bucket). Human-readable slugs (e.g. `/repos/{owner}/{repo}`) are left literal because they're indistinguishable from route words; provide a custom resolver for APIs that bucket on such segments.

Provide your own by implementing `BucketResolverInterface`:

```
use Kyzegs\GuzzleRateLimitMiddleware\Contracts\BucketResolverInterface;
use Psr\Http\Message\RequestInterface;

final class MyResolver implements BucketResolverInterface
{
    public function resolve(RequestInterface $request): string
    {
        return $request->getMethod() . ' ' . $request->getUri()->getPath();
    }
}

$middleware = RateLimitMiddleware::create(resolver: new MyResolver());
```

### Bucket-hash discovery (Discord)

[](#bucket-hash-discovery-discord)

Some APIs assign a request to a bucket dynamically and report it via a header (Discord's `X-RateLimit-Bucket`). When `Headers::$bucket` is set, the middleware stores state under the discovered bucket and re-keys automatically if the API reassigns a route. `RateLimitMiddleware::discord()` enables this together with a `DiscordBucketResolver` that respects Discord's major parameters (`channel_id`, `guild_id`, `webhook_id` and `webhook_token`).

Concurrency / locking
---------------------

[](#concurrency--locking)

By default there is no locking. To serialise concurrent callers that share a bucket (e.g. multiple workers), implement `LockFactoryInterface`/`LockInterface`and pass the factory:

```
$middleware = RateLimitMiddleware::create(lockFactory: new MyLockFactory());
```

Testing your integration
------------------------

[](#testing-your-integration)

The clock and sleeper are injectable, so you can assert delays without real waits. See `tests/` — `FakeClock` and `RecordingSleeper` are good starting points.

Development
-----------

[](#development)

```
composer test      # PHPUnit
composer analyse   # PHPStan (level 6)
```

License
-------

[](#license)

MIT License. See [LICENSE](LICENSE).

###  Health Score

44

—

FairBetter than 90% of packages

Maintenance90

Actively maintained with recent releases

Popularity19

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity47

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

45d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/eb13f61de13b186245a047cd1d882784e4dacb5b9f2a8f930b20e5992159e61a?d=identicon)[Kyzegs](/maintainers/Kyzegs)

---

Top Contributors

[![Kyzegs](https://avatars.githubusercontent.com/u/45851377?v=4)](https://github.com/Kyzegs "Kyzegs (7 commits)")

---

Tags

guzzlehttplaravelmiddlewaresymfonyhttpmiddlewareapiGuzzlerate limit

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/kyzegs-guzzle-rate-limit-middleware/health.svg)

```
[![Health](https://phpackages.com/badges/kyzegs-guzzle-rate-limit-middleware/health.svg)](https://phpackages.com/packages/kyzegs-guzzle-rate-limit-middleware)
```

###  Alternatives

[laravel/framework

The Laravel Framework.

34.9k556.2M21.1k](/packages/laravel-framework)[civicrm/civicrm-core

Open source constituent relationship management for non-profits, NGOs and advocacy organizations.

762297.9k49](/packages/civicrm-civicrm-core)[shopware/platform

The Shopware e-commerce core

3.4k1.5M3](/packages/shopware-platform)[nutgram/nutgram

The Telegram bot library that doesn't drive you nuts

740315.8k8](/packages/nutgram-nutgram)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

595.8M650](/packages/shopware-core)[algolia/algoliasearch-client-php

API powering the features of Algolia.

69735.8M173](/packages/algolia-algoliasearch-client-php)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
