kyzegs / guzzle-rate-limit-middleware
A configurable Guzzle middleware for rate limiting HTTP requests based on response headers
Package info
github.com/Kyzegs/guzzle-rate-limit-middleware
pkg:composer/kyzegs/guzzle-rate-limit-middleware
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.10
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Requires (Dev)
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^10.0
Suggests
- psr/simple-cache: A PSR-16 cache (e.g. Redis, Memcached, Laravel or Symfony cache) for cross-process state via Psr16Store
README
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
- ๐ง 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-Afterand 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
composer require kyzegs/guzzle-rate-limit-middleware
Requires PHP 8.2+ and Guzzle 7.10+.
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
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
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
| Store | Cross-process | Notes |
|---|---|---|
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
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
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
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)
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
By default there is no locking. To coordinate concurrent callers that share a
bucket (e.g. multiple workers), implement LockFactoryInterface/LockInterface
and pass the factory:
$middleware = RateLimitMiddleware::create(lockFactory: new MyLockFactory());
The lock covers the bookkeeping, not the request. A caller takes a slot out of the bucket's remaining allowance and releases the lock before sending, so a bucket runs as many requests in parallel as it has capacity for. The lock is only held across a round trip when the allowance is still unknown โ the first request on a bucket, and the first after a window resets, goes alone because nothing else can reveal the new limit.
This means a lock wait timeout signals genuine contention on an unknown bucket, not ordinary throughput.
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
composer test # PHPUnit composer analyse # PHPStan (level 6)
License
MIT License. See LICENSE.