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
- ext-ctype: *
- ext-json: *
- guzzlehttp/guzzle: ^7.15.2 || ^8.0.1
- psr/event-dispatcher: ^1.0
- psr/log: ^1.1 || ^2.0 || ^3.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- predis/predis: ^2.4 || ^3.0
- psr/clock: ^1.0
- symfony/lock: ^6.4 || ^7.0 || ^8.0
Suggests
- ext-redis: Redis via phpredis, for RedisStore (lock-free atomic reservations) and RedisLockFactory
- predis/predis: Redis via Predis, for RedisStore (lock-free atomic reservations) and RedisLockFactory
- psr/clock: Pass a PSR-20 clock (e.g. symfony/clock) as the middleware clock via Psr20Clock
- symfony/lock: Locks shared across hosts (Redis, PDO, ...) via SymfonyLockFactory
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.0
- dev-chore/release-1.3.0
- dev-fix/persist-under-lock
- dev-ci/github-actions
- dev-feature/redis-store
- dev-feature/async-handler
- dev-feature/client-side-limits
- dev-feature/events
- dev-chore/upgrade-phpstan
- dev-feature/testing-helpers
- dev-feature/lock-factories
- dev-feature/more-presets
- dev-feature/response-parsers
- dev-feature/psr-clock
- dev-refactor/decouple-discord-logic
- dev-feature/options-validation
- dev-feature/state-inspection
- dev-fix/github-resource-buckets
- dev-feature/per-request-options
- dev-fix/isolate-bucket-state-by-identity
- dev-feature/retry-strategy
- dev-fix/headerless-responses
- dev-fix/retry-after-http-date
- dev-fix/global-limit-detection
This package is auto-updated.
Last update: 2026-11-03 08:15:38 UTC
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 and parsers โ works with any API (Discord, GitHub, GitLab, Twitch, Reddit, Twitter, Shopify, the IETF
RateLimitdraft, or your own). - ๐พ Cross-process state โ share rate-limit state via Redis (phpredis or Predis), PSR-16 (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; pluggable strategies add backoff and GitHub's 403 rate limits. - ๐ฆ Client-side limits โ your own budgets (e.g. 10/s and 5,000/h) for APIs that send no headers.
- ๐ชฃ Bucket-hash discovery โ adapts to APIs (like Discord) that assign buckets dynamically.
- ๐ Cross-process locking โ
flock()on one host or symfony/lock (Redis, PDO, โฆ) across hosts, or plug in your own. - ๐ก Observable โ PSR-14 events and structured PSR-3 log context for every delay, retry and limit.
- ๐ Inspectable state โ ask how much of a bucket is left and when it resets, or clear it.
- ๐งช 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.15.2+ or 8.0.1+.
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, one bucket per X-RateLimit-Resource, GitHub's 403/429 retry rules RateLimitMiddleware::twitter(); // x-rate-limit-* headers RateLimitMiddleware::ietf(); // RateLimit / RateLimit-Policy (IETF draft), or legacy RateLimit-* RateLimitMiddleware::gitlab(); // RateLimit-* with an absolute reset RateLimitMiddleware::twitch(); // Ratelimit-* (Helix) RateLimitMiddleware::reddit(); // X-Ratelimit-* with a relative reset RateLimitMiddleware::shopify(); // X-Shopify-Shop-Api-Call-Limit leaky bucket RateLimitMiddleware::discord(); // Discord headers + bucket-hash discovery
The IETF preset understands the draft's structured fields โ the current
RateLimit: "default";r=50;t=30 with its RateLimit-Policy, the older
RateLimit: limit=100, remaining=50, reset=30 โ as well as the separate
RateLimit-Limit/-Remaining/-Reset headers. When a response lists several
policies, it tracks whichever runs out first.
The Shopify preset reads X-Shopify-Shop-Api-Call-Limit: 32/40, keeps one
bucket per shop (every REST endpoint drains the same one), and retries a 429
after its Retry-After. It assumes the standard 2 requests/second leak rate,
which only errs on the slow side for faster plans.
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, webhook and interaction tokens never appear in persisted
bucket or lock keys, log messages or exception messages. Those messages name
Discord's route template with its tokens replaced, and with any other resolver
only the method and host, since a token in a path looks like any other word. A
custom resolver opts in to being named with LoggableRouteInterface.
RateLimitExceededException::getRequest() still has the whole URI, so treat it
as sensitive. Interaction callback endpoints are excluded from Discord's bot-global
budget; the preset adds that exemption to any GlobalLimit you pass, so the
example above keeps it (GlobalLimit::discord() spells it out). Shared-scope
429 responses do not consume the invalid-request budget
(InvalidRequestLimit::$ignoredScopes, default ['shared']).
Supported APIs and runtimes
| API sends | Examples | Use |
|---|---|---|
| A preset's headers | Discord, GitHub, GitLab, Twitch, Reddit, Twitter/X, Shopify | That preset |
X-RateLimit-* |
Many REST APIs | new RateLimitMiddleware() |
RateLimit / RateLimit-Policy, or RateLimit-* |
The IETF draft, in any of its forms | RateLimitMiddleware::ietf() |
| Other names, one number per header | Sentry, Okta, Zendesk | Custom headers |
| One field holding several values | used/limit and the like |
Custom parsers |
Only 429 with Retry-After |
Slack, Microsoft Graph | The default: nothing to pre-empt, but 429s are retried for you |
| Nothing | Stripe | Client-side limits |
Reset values may be relative seconds, an epoch in seconds or milliseconds, an
HTTP-date or ISO-8601. Duration strings such as OpenAI's 6m0s aren't read, so
such an API is only retried on its 429s unless you add a parser.
CI runs PHP 8.2 to 8.5 on Linux, Windows and macOS, Guzzle 7 and 8, with lowest and highest dependencies.
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, โฆ |
RedisStore |
โ | phpredis or Predis. Reserves bucket capacity in one atomic Lua script, so buckets need no lock. |
Redis
RedisStore talks to Redis directly, through a phpredis \Redis connection or
any Predis client. Unlike the other stores it doesn't need a lock to be safe
across processes: reading a bucket's allowance and taking a slot happen in a
single Lua script on the server, so two workers are never handed the same
slot, and no lock is held while a request is in flight.
use Kyzegs\GuzzleRateLimitMiddleware\Lock\RedisLockFactory; use Kyzegs\GuzzleRateLimitMiddleware\Store\RedisStore; $redis = new \Redis(); $redis->connect('127.0.0.1', 6379); // or: $redis = new \Predis\Client('tcp://127.0.0.1:6379'); $middleware = RateLimitMiddleware::discord( store: new RedisStore($redis), lockFactory: new RedisLockFactory($redis), );
When a bucket's allowance is unknown (its first request, or the first after a
window resets) the script lets exactly one caller through to learn it and has
the others poll briefly until its response lands. That caller's claim expires
after 10 seconds (probeTtlSeconds), so a worker that dies mid-request can't
stall the bucket.
The global and invalid-request budgets (see the Discord preset) and client-side
limits still go through the lock factory. RedisLockFactory covers them: SET NX locks with
a random token, a TTL (30 seconds by default) for holders that die, an optional
acquire timeout that throws LockTimeoutException, and a release that never
frees a lock someone else has taken over since.
Values are JSON, stored under the prefix (grl: by default) plus a SHA-1 of
the key. On Redis Cluster, give the store a prefix with a hash tag, such as
{grl}:, so the keys a reservation touches share a slot. Redis 6.0 or newer
is required.
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', // relative seconds, epoch (s or ms), HTTP-date or ISO-8601 resetAfter: null, // relative seconds (preferred when present) retryAfter: 'Retry-After', // used for 429 retry delays (seconds or a date) 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 numbers below the year-2000 epoch are treated as relative seconds, larger ones as absolute UNIX timestamps, and from 1e11 up as timestamps in milliseconds. A value that can't be read counts as no reset.
Custom parsers
Headers feeds the default HeaderRateLimitParser, which fits APIs that send
one number per header. For anything else, swap the parser. The library also
ships IetfRateLimitParser (used by ietf()) and FractionHeaderParser for
"used/limit" leaky buckets:
use Kyzegs\GuzzleRateLimitMiddleware\Parser\FractionHeaderParser; $middleware = RateLimitMiddleware::create( parser: new FractionHeaderParser('X-Api-Call-Limit', leakPerSecond: 4.0), );
Or implement RateLimitParserInterface. Return null when a response carries
no rate-limit information; the stored state is then left as it is:
use Kyzegs\GuzzleRateLimitMiddleware\Contracts\ClockInterface; use Kyzegs\GuzzleRateLimitMiddleware\Contracts\RateLimitParserInterface; use Kyzegs\GuzzleRateLimitMiddleware\RateLimit; use Psr\Http\Message\ResponseInterface; // X-Quota: 12 left, resets in 30s final class QuotaParser implements RateLimitParserInterface { public function parse(ResponseInterface $response, ClockInterface $clock): ?RateLimit { if (preg_match('/^(\d+) left, resets in (\d+)s$/', $response->getHeaderLine('X-Quota'), $m) !== 1) { return null; } return new RateLimit( limit: 0, // unknown remaining: (int) $m[1], reset: $clock->now() + (int) $m[2], // absolute UNIX timestamp ); } } $middleware = RateLimitMiddleware::create(parser: new QuotaParser());
A parser only replaces how limit, remaining and reset are read. Headers
still names the Retry-After, bucket, global and scope headers.
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 rate limit (a 429 by default) maxStoreTtl: 604800, // upper bound for cached bucket state (seconds) retryStatusCodes: [429], // statuses that trigger a retry globalLimit: null, // cap on all requests per credential, see below invalidRequestLimit: null, // stop before an API bans you for too many errors maxDelaySeconds: null, // throw RateLimitDelayExceededException rather than wait longer limits: [], // client-side budgets, see "Client-side limits" ); $middleware = RateLimitMiddleware::create(options: $options); // Presets: Options::default(), Options::conservative(), Options::aggressive()
maxRetries and retryStatusCodes configure the default retry strategy; see
Retry strategies to replace it.
Out-of-range values (negative retries, jitter outside 0-100, a zero-length
window, โฆ) throw an InvalidArgumentException when the config is built.
When retries are exhausted on a rate limit (a 429, unless the retry strategy
says otherwise) and throwOnRateLimit is true, a
Kyzegs\GuzzleRateLimitMiddleware\Exception\RateLimitExceededException is thrown
(carrying the request, response, retry-after seconds and global flag); for an
async request, the promise rejects with it. Any other status is returned as is
once its retries run out.
Global request budget
globalLimit caps every request made with one credential, across all routes,
on top of the per-bucket limits. Routes the API leaves out of that budget are
exempted with regular expressions matched against the request path:
use Kyzegs\GuzzleRateLimitMiddleware\Config\GlobalLimit; $options = new Options( globalLimit: new GlobalLimit( maxRequests: 100, windowSeconds: 60, exemptPaths: ['#^/health$#'], ), );
Nothing is exempt by default, and a pattern that doesn't compile throws an
InvalidArgumentException when the limit is built.
Retry strategies
Whether a response is retried, how long to wait first, and whether it counts as
a rate limit is up to a RetryStrategyInterface. The default,
StatusCodeRetryStrategy, retries retryStatusCodes up to maxRetries times
and waits as long as Retry-After (or the bucket reset) says.
When a response says nothing, that wait is just the safety buffer. Wrap the
strategy in ExponentialBackoffRetryStrategy to back off instead (a wait the
response does give is left alone):
use Kyzegs\GuzzleRateLimitMiddleware\Retry\ExponentialBackoffRetryStrategy; use Kyzegs\GuzzleRateLimitMiddleware\Retry\StatusCodeRetryStrategy; $middleware = RateLimitMiddleware::create( retryStrategy: new ExponentialBackoffRetryStrategy( new StatusCodeRetryStrategy(maxRetries: 5, statusCodes: [429, 503]), baseSeconds: 1.0, // up to 1s, 2s, 4s, โฆ (full jitter) maxSeconds: 60.0, ), );
RateLimitMiddleware::github() uses GitHubRetryStrategy, since GitHub
answers a rate limit with a 403 as readily as a 429:
x-ratelimit-remaining: 0(primary limit) waits untilx-ratelimit-reset;retry-after(secondary limit) waits that long;- a secondary limit that gives neither waits a minute, then doubles;
- any other
403is taken as a permissions error and returned without a retry.
Exhausted 403 rate limits throw RateLimitExceededException like a 429. To
change the number of retries, build it yourself:
use Kyzegs\GuzzleRateLimitMiddleware\Config\Headers; use Kyzegs\GuzzleRateLimitMiddleware\Retry\GitHubRetryStrategy; $middleware = RateLimitMiddleware::create( headers: Headers::github(), retryStrategy: new GitHubRetryStrategy(maxRetries: 5), );
For anything else, implement RetryStrategyInterface: return the seconds to
wait (or null to stop) from retryDelay(), and say in isRateLimited()
which responses should throw once retries stop. The safety buffer, jitter and
maxDelaySeconds still apply on top.
Client-side limits
Some APIs document their limits but send no headers, many enforce several at once, and sometimes you just want to stay well under a provider's ceiling. Client-side limits are budgets you declare yourself, enforced before every request whether or not the API reports anything:
use Kyzegs\GuzzleRateLimitMiddleware\Config\Limit; use Kyzegs\GuzzleRateLimitMiddleware\Config\Options; use Kyzegs\GuzzleRateLimitMiddleware\RateLimitMiddleware; use Kyzegs\GuzzleRateLimitMiddleware\Store\Psr16Store; use Psr\Http\Message\RequestInterface; // 10 requests/second and 5,000/hour per API key, plus 30 searches/minute. $middleware = RateLimitMiddleware::create( store: new Psr16Store($psr16Cache), options: new Options( safetyBufferSeconds: 0.05, limits: [ Limit::perSecond(10), Limit::perHour(5000), Limit::perMinute( 30, appliesTo: fn (RequestInterface $request): bool => $request->getUri()->getPath() === '/v1/search', name: 'search', ), ], ), );
A request waits until every limit that applies to it has room, sleeping once for the longest wait, and takes nothing from any of them until it can take from all: a request waiting out the hourly budget never holds a slot of the per-second one.
| Algorithm | Behaviour |
|---|---|
SlidingWindow (default) |
Counts the previous window in proportion to how much of it still overlaps, smoothing out bursts at window edges. |
FixedWindow |
maxRequests per window, opened by the first request. Simplest, but allows up to twice the budget across a window edge. |
TokenBucket |
Bursts up to maxRequests, then refills maxRequests per window continuously. |
| Scope | One budget shared by |
|---|---|
Identity (default) |
requests with the same credentials |
Host |
requests with the same credentials to the same host |
Route |
requests with the same credentials to the same route, as the bucket resolver groups them |
Everything |
every request, whatever its credentials |
Build limits with new Limit($maxRequests, $windowSeconds, $algorithm, $scope, $appliesTo, $name),
or the Limit::perSecond(), perMinute(), perHour() and perDay() shorthands.
Waits get the same safety buffer and jitter as header-driven ones and respect
maxDelaySeconds, so keep safetyBufferSeconds small for per-second budgets.
State lives in the configured store under hashed keys, so processes sharing a
store share the budgets; add a lock factory if they run concurrently. Limits
that differ only in appliesTo need distinct names, or they share one budget.
Per-request options
Adjust or bypass the middleware for a single call with the rate_limit
request option:
$client->get('/search', ['rate_limit' => ['max_delay' => 0]]); // fail fast instead of sleeping $client->post('/bulk', ['rate_limit' => ['bucket' => 'bulk-writes']]); // share one bucket across routes $client->get('/health', ['rate_limit' => ['enabled' => false]]); // bypass the middleware entirely // per-tenant state; give up on the first 429 $client->get('/items', ['rate_limit' => ['identity' => 'tenant-42', 'max_retries' => 0]]);
| Key | Type | Effect |
|---|---|---|
enabled |
bool |
false sends the request straight through: no waiting, retries, budgets or stored state. |
bucket |
string |
Replaces the resolved bucket, so different routes can share one limit. Still scoped by identity. |
identity |
string |
Replaces the resolved identity (the Authorization fingerprint by default), e.g. with a tenant id. Fingerprinted the same way, never stored raw. |
max_delay |
number or null |
Overrides Options::$maxDelaySeconds. 0 throws RateLimitDelayExceededException instead of waiting; null lifts the cap. |
max_retries |
int |
Overrides Options::$maxRetries. With a custom retry strategy it can only stop retries sooner. |
Unknown keys, wrong types and negative numbers throw an InvalidArgumentException,
and the option is removed before the request reaches the next handler. Set it in
the client config (new Client(['handler' => $stack, 'rate_limit' => [...]])) to
apply it to every request; a per-request rate_limit then replaces it as a whole,
since Guzzle merges options shallowly.
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).
RateLimitMiddleware::github() uses the same mechanism with
X-RateLimit-Resource, so every endpoint in a resource (core, search,
graphql, โฆ) shares one allowance per token, as GitHub counts it.
Concurrency / locking
By default there is no locking, which is fine for a single process. Once several processes share a persistent store, give them a lock factory too, or two workers can read the same allowance and both spend it.
On one host, pair FilesystemStore with FlockLockFactory. It has no
dependencies, and the OS drops a lock the moment its holder exits, so a crashed
worker never leaves a bucket locked:
use Kyzegs\GuzzleRateLimitMiddleware\Lock\FlockLockFactory; use Kyzegs\GuzzleRateLimitMiddleware\Store\FilesystemStore; $middleware = RateLimitMiddleware::create( store: new FilesystemStore('/var/cache/rate-limits'), lockFactory: new FlockLockFactory('/var/cache/rate-limits/locks'), );
flock() only coordinates processes on the same machine, and is unreliable on
NFS. Across hosts, use SymfonyLockFactory over a shared
symfony/lock store such
as Redis or PDO (composer require symfony/lock):
use Kyzegs\GuzzleRateLimitMiddleware\Lock\SymfonyLockFactory; use Kyzegs\GuzzleRateLimitMiddleware\Store\Psr16Store; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\Store\RedisStore; $middleware = RateLimitMiddleware::create( store: new Psr16Store($psr16Cache), lockFactory: new SymfonyLockFactory( new LockFactory(new RedisStore($redis)), ttlSeconds: 30.0, // must outlast your slowest request ), );
RedisLockFactory does the same over a Redis connection you already have.
With RedisStore buckets need no lock across a request, since the store reserves
and records capacity atomically; the lock factory is then only held briefly, for
bookkeeping such as the global and invalid-request budgets and client-side limits.
For anything else, implement LockFactoryInterface/LockInterface.
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. It takes the lock again briefly to record the response. 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. A route whose successful responses carry no rate-limit headers is treated as unmetered for a minute, so its requests don't queue behind a probe that will never learn anything.
Both lock settings have to allow for that round trip:
- TTL (
SymfonyLockFactoryandRedisLockFactory). A lock in Redis or a database outlives a worker that crashes while holding it, so it expires afterttlSeconds(default 30). Keep it above your Guzzletimeout: if it lapses mid-request, a second worker may probe the same bucket and draw a 429.flock()needs no TTL, since the OS releases the lock with the process. - Timeout (all three factories).
acquire()waits indefinitely by default. PasstimeoutSecondsto throw aKyzegs\GuzzleRateLimitMiddleware\Exception\LockTimeoutExceptioninstead, and make it at least as long as your slowest request, because a waiter may queue behind a probe. A timeout then signals genuine contention on an unknown bucket, not ordinary throughput.
Async requests
The middleware never waits on the transfer itself, so getAsync(), Pool and
Promise\Utils::all() keep their requests in flight together:
use GuzzleHttp\Promise\Utils; $responses = Utils::all([ $client->getAsync('https://api.github.com/users/octocat'), $client->getAsync('https://api.github.com/users/torvalds'), ])->wait();
Transfers run concurrently; waiting for an exhausted bucket still blocks. The
wait for a spent bucket or global budget, and the delay before retrying a 429,
go through the (blocking) sleeper: each has to re-read the shared state once the
time is up, and without an event loop there is nothing to schedule that on.
Within one process, requests to a bucket whose allowance is still unknown wait
for the first response to reveal it, then go out together. Rate-limit errors
reject the returned promise instead of being thrown from getAsync().
A lock timeout or store failure while recording a response is logged as a warning and the response is still returned, so a request that went through isn't resent.
If several processes make async requests and share a blocking lock, set a lock
timeoutSeconds. A process waiting on a lock can't finish its own transfers, so
two processes that each hold one bucket's lock while waiting for the other's
would otherwise wait forever. RedisStore needs no bucket lock and so can't get
into this.
Events
Pass any PSR-14 event dispatcher to
count retries, export delay metrics or alert on limits. create() and every
preset take it as events::
use Kyzegs\GuzzleRateLimitMiddleware\Event\RetryScheduled; use Kyzegs\GuzzleRateLimitMiddleware\RateLimitMiddleware; use Symfony\Component\EventDispatcher\EventDispatcher; $dispatcher = new EventDispatcher(); $dispatcher->addListener(RetryScheduled::class, function (RetryScheduled $event) use ($metrics): void { $metrics->increment('rate_limit.retries', ['global' => $event->global]); }); $middleware = RateLimitMiddleware::github(events: $dispatcher);
| Event | Dispatched | Carries |
|---|---|---|
RequestDelayed |
before sleeping for a spent bucket, global budget or client-side limit | request, delay, reason (DelayReason::Bucket/GlobalLimit/ClientLimit), key |
RetryScheduled |
before sleeping to retry a 429 (or other retryStatusCodes) |
request, response, attempt, maxRetries (null with a custom strategy), delay, global |
RateLimitExceeded |
right before RateLimitExceededException is thrown |
request, response, retryAfter, global |
RateLimitUpdated |
after a response's bucket state is stored | request, rateLimit, key |
InvalidRequestLimitReached |
right before InvalidRequestLimitExceededException is thrown |
request, retryAfter |
Events are read-only notifications: a listener cannot change a delay. delay
is exactly what is slept, and key is the opaque, hashed storage key, so it
never exposes a route or a token.
The PSR-3 logger gets the same data as structured context (key, delay,
reason, method, status, attempt, โฆ), so log pipelines can aggregate
without parsing messages.
Inspecting state
Ask the middleware what it knows about the bucket a request would use โ to show "N requests left", decide whether to schedule work now, or clear state you know is stale:
use GuzzleHttp\Psr7\Request; $request = new Request('GET', 'https://api.github.com/user', ['Authorization' => "Bearer {$token}"]); $state = $middleware->state($request); // ?RateLimit if ($state !== null) { printf('%d/%d left, resets at %s', $state->remaining, $state->limit, $state->resetsAt()?->format('H:i:s')); } $middleware->forget($request); // the next request goes out without waiting
Build the request the way the client sends it โ absolute URI and the same
Authorization header โ since both decide the bucket. state() is null
until a response has revealed the window, and again once it has reset.
forget() drops the counters but keeps a bucket hash learned through
discovery. Both look at the identity and bucket the request resolves to, so they
don't see state kept under a rate_limit identity or bucket override.
The self-imposed budgets are exposed the same way. globalBudget() and
invalidRequestBudget() return a BudgetState for the request's identity
(count, max, remaining(), reset, and blockedUntil after a global
429), or null when that budget is not configured:
$budget = $middleware->globalBudget($request); if ($budget !== null && $budget->remaining() === 0) { // defer the batch until $budget->blockedUntil ?? $budget->reset }
A custom handler opts in by implementing InspectableHandlerInterface; with
one that doesn't, these methods throw a LogicException.
Testing your integration
The clock and sleeper are injectable, so you can assert delays without real waits. The package ships the two test doubles it uses itself:
use GuzzleHttp\Handler\MockHandler; use Kyzegs\GuzzleRateLimitMiddleware\Store\InMemoryStore; use Kyzegs\GuzzleRateLimitMiddleware\Testing\FakeClock; use Kyzegs\GuzzleRateLimitMiddleware\Testing\RecordingSleeper; $clock = new FakeClock(1_700_000_000.0); $sleeper = new RecordingSleeper($clock); // advances the clock instead of blocking $stack = HandlerStack::create(new MockHandler([/* responses */])); $stack->push(RateLimitMiddleware::create( store: new InMemoryStore($clock), clock: $clock, sleeper: $sleeper, )); // ... send requests, then: $sleeper->sleeps; // every delay requested, in seconds $sleeper->total();
The clock: argument also accepts any PSR-20 clock (e.g. symfony/clock), so
the middleware can share your application's clock. Wrap one in Psr20Clock
where a ClockInterface is expected, such as the stores:
use Kyzegs\GuzzleRateLimitMiddleware\Store\InMemoryStore; use Kyzegs\GuzzleRateLimitMiddleware\Support\Psr20Clock; $middleware = RateLimitMiddleware::create( store: new InMemoryStore(new Psr20Clock($psrClock)), clock: $psrClock, );
Development
composer test # PHPUnit composer analyse # PHPStan (level 8)
Redis-backed tests use GRL_REDIS_HOST and GRL_REDIS_PORT (127.0.0.1:6379
by default) and skip themselves when nothing is listening. Runnable examples are
in examples/.
License
MIT License. See LICENSE.