Skip to the guide
All guides

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.

app Reqtide PHP 0.1.0checked reading 6 minutes

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:

PHP
/** 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));
PHP
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";
    }
}
Text
-- 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
AlgorithmBehavior
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.
The three rate-limit algorithms.

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:

PHP
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";
Text
-- 1. A retry is charged like a first attempt
first operation: ok (3 dispatches)
second operation: rejected, dispatched 0, reason rate.exhausted

Size 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 getMeaning
AdmissionRejectedExceptionThe quota is spent. A wait may be known or unknown, depending on the algorithm.
AdmissionDeferredExceptionThe refusal carries a positive wait.
ImpossibleAdmissionExceptionThe request can never succeed, for example because its cost is above the capacity. Waiting does not help.
The three refusal exceptions.

The last case is a configuration problem, not a traffic problem:

PHP
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);
}
Text
-- 2. A cost above the capacity can never be admitted
Enaxon\Reqtide\Exception\ImpossibleAdmissionException: rate.cost_exceeds_capacity

Read $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.