hamburgscleanest/guzzle-advanced-throttle
Advanced Throttle adds flexible rate-limiting middleware to Guzzle, letting PHP and Laravel apps enforce multiple request limits, use cache or Redis storage, and avoid API overages with configurable buckets, per-host rules, and smooth request spacing. Ideal for reliable API clients.
This is a Guzzle handler-stack middleware, not a full HTTP client. You keep using GuzzleHttp\Client; the package only adds request admission control and optional response caching.
Minimal setup:
composer require hamburgscleanest/guzzle-advanced-throttle
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Hamburgscleanest\GuzzleAdvancedThrottle\Middleware\ThrottleMiddleware;
use Hamburgscleanest\GuzzleAdvancedThrottle\RequestLimitRuleset;
use Hamburgscleanest\GuzzleAdvancedThrottle\Rule\Host;
$ruleset = new RequestLimitRuleset([
new Host('https://api.example.com', 60, 60), // 60 requests per 60 seconds
]);
$stack = HandlerStack::create();
$stack->push(ThrottleMiddleware::get($ruleset));
$client = new Client([
'handler' => $stack,
'base_uri' => 'https://api.example.com',
]);
First places to read in the repository:
src/RequestLimitRuleset.php — constructor arguments, response-header options, cache adapter/strategy wiring.src/Rule/ — the built-in Host and Url rules; this is where you learn how matching and bucket keys work.src/Cache/Adapters/ and src/Cache/Strategies/ — where the “serve cached response when throttled” behavior lives.src/Middleware/ThrottleMiddleware.php — the entry point you push onto the handler stack.Best first use case: an external API client that has a documented quota and where a 429 is expensive — GitHub, Shopify, a partner API, a paginated import job. Put the throttle on that client only, not on your entire application’s Guzzle usage.
Before adopting, check the repository’s last commit and its composer.json constraints against your PHP and Guzzle versions. With zero visible dependents, treat it as a small, possibly unmaintained utility and pin the version you validate.
One client per upstream API. Build a dedicated Guzzle client with its own HandlerStack and ruleset. Do not mix rules for unrelated APIs into one client; different services have different quotas, cache TTLs, and error semantics.
Centralize client construction. In Laravel, bind the configured client as a singleton so the ruleset and adapter are built once per container lifecycle:
$this->app->singleton(GitHubClient::class, function ($app) {
$ruleset = new RequestLimitRuleset(
[new Host('https://api.github.com', 5000, 3600)],
RequestLimitRuleset::RESPONSE_HEADERS,
new LaravelCacheAdapter($app['cache']->store('redis'))
);
$stack = HandlerStack::create();
$stack->push(ThrottleMiddleware::get($ruleset));
return new Client([
'handler' => $stack,
'base_uri' => 'https://api.github.com',
]);
});
Keep rules in config/ and convert them into rule objects in the provider. That makes quota changes deployment config rather than code changes.
Use multiple rules for layered protection. A broad host rule plus narrower URL rules works well:
new RequestLimitRuleset([
new Host('https://api.example.com', 100, 60),
new Url('https://api.example.com/search', 10, 60),
]);
Every applicable rule is checked; the request is throttled when any matching rule is exhausted. This lets you protect a general quota while separately limiting an expensive endpoint.
Custom rules for per-tenant limits. Built-in rules key off host or URL. For per-API-key, per-user, or per-tenant quotas, create a custom rule that inspects the PSR-7 request (for example an X-Api-Key header or a tenant path segment) and returns a stable bucket key. This is the main extension point for multi-tenant SaaS applications.
Choose a cache strategy deliberately.
The exact strategy constant names vary by version; inspect CachingStrategy constants in the installed release rather than copying constants from an old blog post.
Middleware ordering matters. Guzzle middleware run outside-in based on push order. If you also use RetryMiddleware, decide whether retries should consume a token. Pushing throttle before retry (so retry is outermost) makes each retry pass through the throttle, which is usually what you want for quota accounting. Verify with a small test rather than assuming.
Expose rate-limit state. Use the ruleset’s response-header option so your application logs or surfaces remaining quota. Pair this with structured logging in the client wrapper; when an import is slow, you want to know whether it is network latency or intentional throttling.
Test with tiny windows. Use a limit like 2 requests per 10 seconds and a loop of 5 requests in a feature test. Assert that the correct exception is thrown or that the cached response is returned. Use ArrayAdapter for isolated tests and a shared store for integration tests that exercise cross-process behavior.
In-memory cache is not real rate limiting under PHP-FPM. ArrayAdapter (or any in-process adapter) resets at the end of each request. It only throttles within a single request lifecycle — a pagination loop, a batch job in one process, or a queue worker’s current job. For limits across HTTP requests and workers you must use a shared adapter backed by Redis or Laravel’s cache.
Token-bucket updates are not automatically atomic across processes. The middleware reads and writes cache entries; concurrent workers can both observe “one token left” and both proceed. If strict compliance matters, use a cache backend and deployment model that tolerate this, or put a more strongly consistent limiter in front. Do not assume the package gives you distributed-systems-grade locking.
Cache TTLs must outlive the rate window. If your rate window is 60 seconds but the cache entry expires in 30, buckets reset early and you will exceed the upstream quota. Conversely, cached HTTP responses with long TTLs can return very stale data; set response caching policy per endpoint, not globally.
Do not reuse one cache store blindly across clients and rulesets. Bucket keys are derived from rule definitions; collisions or shared prefixes across environments can cause surprising throttling. Use a dedicated cache store, prefix, or namespace per upstream API and environment.
Caching strategies apply to all requests through that client. Do not put a cache-on-rate-limit strategy on a client that also performs POST, PUT, or DELETE. Split read-only and write clients, or restrict caching to safe/idempotent operations.
The package does not adapt to upstream 429 or Retry-After headers. Rules are static configuration. If the upstream changes its quota, your client will not notice. Monitor 429 responses and update rules manually. For Retry-After handling, catch the throttle exception and implement your own sleep/retry policy, or wrap with RetryMiddleware.
Know the failure mode. When throttled without a cache strategy, expect the package’s rate-limit exception rather than an automatic wait. Catch it explicitly in application code if you want graceful degradation; otherwise it bubbles out of the Guzzle call.
Configure the handler stack on the client. A common mistake is building the ruleset but passing only base_uri and options to new Client(...). Without 'handler' => $stack, the middleware is never invoked.
Debugging checklist: reduce to one rule with a tiny limit; confirm the client actually uses your stack; inspect the cache backend for bucket keys; temporarily disable the caching strategy to distinguish “throttled” from “stale cache returned”; log request URLs because Url rules are prefix/path sensitive and query strings can surprise you.
Maintenance risk. The package has very low adoption and may not track new Guzzle/PHP releases. Treat it as vendorable source: review the middleware and adapter code before production, keep a fork or a small internal replacement in mind, and avoid relying on undocumented behavior. The core idea — a Guzzle middleware that checks a token bucket and optionally serves cached responses — is straightforward enough to reproduce if the dependency becomes a liability.
How can I help you explore Laravel packages today?