All guides
No guide matches your search.
Rate limiting
Pick a rate-limit algorithm, see why retries are charged like first attempts, and tell a refusal that will pass from one that never will.
On this page
The limit protects one PHP process. Ten workers each get the full quota, and under PHP-FPM a pipeline built inside a request starts every request with a full quota. See Limits.
The code blocks come from examples/09-choosing-policies.php (opens in a new tab) and examples/11-rate-limit-charges.php (opens in a new tab). A script runs them and compares them with the recorded output. The numbers make behavior visible. They are not recommendations.
Three algorithms, one builder call
rateLimit(algorithm, cost) takes its algorithm from one of three factory methods. The snippet gives each algorithm room for exactly one call per hour and shows that the second call is refused. It starts from this shared setup:
/** Every pipeline below starts from the same two required options. */
$base = static fn(): PipelineBuilder => PipelineBuilder::profile('choosing', 'v1')
->partition('shop', 'prod', 'catalog-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative);
$ok = static fn(): string => 'ok';
$context = static fn(CompiledPipeline $pipeline): ExecutionContext => $pipeline->newContext(Duration::seconds(30));echo "-- 1. Rate limit: three algorithms, same builder call\n";
$algorithms = [
'token bucket' => PipelineBuilder::tokenBucket(1, 1, Duration::seconds(3600)),
'fixed window' => PipelineBuilder::fixedWindow(1, Duration::seconds(3600), FixedWindowAnchor::FirstHit),
'sliding window' => PipelineBuilder::slidingWindow(1, Duration::seconds(3600)),
];
foreach ($algorithms as $name => $algorithm) {
$pipeline = $base()->rateLimit($algorithm)->compileInMemory(ExceptionClassifier::create());
$pipeline->execute($ok, $context($pipeline));
try {
$pipeline->execute($ok, $context($pipeline));
} catch (AdmissionRejectedException | AdmissionDeferredException $e) {
echo "{$name}: second call refused with {$e->reason()->value}\n";
}
}-- 1. Rate limit: three algorithms, same builder call
token bucket: second call refused with rate.exhausted
fixed window: second call refused with rate.exhausted
sliding window: second call refused with rate.exhausted| Algorithm | Behavior |
|---|---|
tokenBucket(capacity, refillTokens, interval) | Admits while tokens remain. Refills continuously in integer fixed point. Discards the excess when full. |
fixedWindow(capacity, window, anchor) | Counts per window. Aligned windows start at multiples of the window length on the epoch. FirstHit windows start at the first call. A new window starts exactly at the window end. |
slidingWindow(capacity, window) | An approximate sliding counter. It never admits early, but it can admit later than an exact sliding log would. |
All three use integer arithmetic, and time is monotonic. A clock that steps backwards never refills a quota.
Which one to choose depends on what your provider enforces. If the provider documents a bucket, use a token bucket. If it resets a counter at fixed times, a fixed window with the matching anchor behaves the same way. The sliding counter is the cautious choice when you do not know the provider rule. It sometimes admits later than necessary.
What a charge means
Every dispatch is charged cost units, 1 by default. The first attempt and each retry are charged alike. A charge is never refunded, even for a call cancelled after admission and before dispatch. The quota used is therefore not the same as the requests sent.
This matters for sizing. A bucket of 3 units with a retry policy of up to 3 attempts is emptied by one operation that retries twice:
echo "-- 1. A retry is charged like a first attempt\n";
// A bucket of 3 units that does not refill within this script. The call may make up to 3 attempts.
$pipeline = PipelineBuilder::profile('rate-charges', 'v1')
->partition('shop', 'prod', 'catalog-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
->retry(3, BackoffMode::Constant, Duration::milliseconds(5), Duration::milliseconds(5), JitterMode::None)
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10))
->rateLimit(PipelineBuilder::tokenBucket(3, 1, Duration::seconds(3600)))
->compileInMemory(ExceptionClassifier::create()->retryOn(Hiccup::class));
$safety = static fn(): OperationSafety => OperationSafety::replayableRead(
ReplaySafetyContract::forReadOnlyCallable('list-products', Duration::seconds(3600)),
);
$flaky = static function (AttemptContext $attempt): string {
if ($attempt->dispatchedAttemptNumber < 3) {
throw new Hiccup('try again');
}
return 'ok';
};
echo 'first operation: ', $pipeline->execute($flaky, $pipeline->newContext(Duration::seconds(30), $safety())), " (3 dispatches)\n";
$completion = $pipeline->executeDetailed($flaky, $pipeline->newContext(Duration::seconds(30), $safety()));
echo 'second operation: ', $completion->disposition->value, ', dispatched ', $completion->summary->dispatched, ', reason ', $completion->problem()?->code->value, "\n";-- 1. A retry is charged like a first attempt
first operation: ok (3 dispatches)
second operation: rejected, dispatched 0, reason rate.exhaustedSize the quota for the expected traffic multiplied by the attempts you allow, not by the number of operations.
Refusals: rejected, deferred, impossible
A refusal reaches you as one of three typed exceptions. Your callable did not run, and the refusal does not count as a failure of your dependency.
| You get | Meaning |
|---|---|
AdmissionRejectedException | The quota is spent. A wait may be known or unknown, depending on the algorithm. |
AdmissionDeferredException | The refusal carries a positive wait. |
ImpossibleAdmissionException | The request can never succeed, for example because its cost is above the capacity. Waiting does not help. |
The last case is a configuration problem, not a traffic problem:
echo "-- 2. A cost above the capacity can never be admitted\n";
$tooCostly = PipelineBuilder::profile('rate-charges', 'v1')
->partition('shop', 'prod', 'catalog-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
->rateLimit(PipelineBuilder::tokenBucket(2, 2, Duration::seconds(1)), cost: 3)
->compileInMemory(ExceptionClassifier::create());
try {
$tooCostly->execute(static fn(): string => 'never runs', $tooCostly->newContext(Duration::seconds(30)));
} catch (ImpossibleAdmissionException $e) {
echo get_class($e), ': ', $e->reason()->value, "\n";
} catch (AdmissionRejectedException | AdmissionDeferredException $e) {
echo 'unexpected: ', get_class($e), "\n";
exit(1);
}-- 2. A cost above the capacity can never be admitted
Enaxon\Reqtide\Exception\ImpossibleAdmissionException: rate.cost_exceeds_capacityRead $e->reason() for the registry code, and $e->decision->retryAfter for the wait when there is one. Error handling explains what to do with each.
A fallback for refusals is a choice
By default a fallback replaces only what your callable did, so a rate-limit refusal passes through to you. You can let the fallback replace refusals too. A fallback that hides refusals also hides that you are being throttled. See Fallback.
Testing a limit
A test of a rate limit that refills over a minute should not wait a minute. Move a virtual clock instead. See Testing with the virtual clock.
Was this page helpful?