RateLimiter - viames/pair GitHub Wiki
Pair framework: RateLimiter
Pair\Api\RateLimiter is the storage engine used by ThrottleMiddleware and any custom throttling logic.
It currently uses:
- Redis as the primary backend when available
- file-based storage as an automatic fallback
Its public API is intentionally small, and attempt() is the main method you should understand first.
Storage strategy
Primary backend: Redis
Redis is used when:
REDIS_HOSTis configured- the
ext-redisextension is available - the connection succeeds during the request
Redis mode uses Lua scripts for atomic sliding-window checks and updates.
Automatic fallback: file storage
When Redis is unavailable, the limiter stores data under:
TEMP_PATH/rate_limits/- or the system temp directory if
TEMP_PATHis not defined
Current behavior:
- one logical file per rate-limit key
flock()for atomic file access- sliding-window hit lists instead of the old fixed
{count, expiresAt}structure - expired entries are cleaned up opportunistically
Constructor
$limiter = new \Pair\Api\RateLimiter(60, 60);
Parameters:
maxAttemptsdecaySeconds
Example:
// Creates a limiter for 60 requests every 60 seconds.
$limiter = new \Pair\Api\RateLimiter(60, 60);
Main methods
attempt(string $key): RateLimitResult
This is the main method of the class.
It:
- checks the current sliding window
- consumes the hit only when the request is still allowed
- returns a
RateLimitResultwith the current state
Example:
$result = $limiter->attempt('throttle:user:15');
// Sends the standard rate-limit headers.
$result->applyHeaders();
if (!$result->allowed) {
// Returns a normalized API error.
return \Pair\Api\ApiResponse::errorResponse('TOO_MANY_REQUESTS', [
'retryAfter' => $result->retryAfter,
'resetAt' => $result->resetAt,
]);
}
Prefer attempt() over composing tooManyAttempts() and hit() manually, because attempt() is the atomic and safer primitive.
tooManyAttempts(string $key): bool
Read-only check for the current state. It does not consume a hit.
This is useful for diagnostics and read-only checks, but not ideal for enforcing limits under concurrency.
hit(string $key): int
Consumes a hit and returns the remaining attempts.
Current implementation detail: hit() also emits the standard rate-limit headers for backward-compatible flows.
clear(string $key): void
Deletes the current key from both Redis and file fallback storage.
Typical use cases:
- reset a login-throttle bucket after a successful challenge
- clear a temporary rate-limit bucket after a workflow is completed
RateLimitResult
attempt() returns a Pair\Api\RateLimitResult with:
allowedlimitremainingresetAtretryAfterdriver
applyHeaders(): void
Emits:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-Afterwhen blocked
The driver field tells you which backend was used, usually redis or file.
Typical integration
$limiter = new \Pair\Api\RateLimiter(60, 60);
// Builds one logical key for this traffic class.
$key = 'throttle:bearer:' . hash('sha256', $token);
// Atomically checks and consumes the hit.
$result = $limiter->attempt($key);
// Emits the response headers before the action continues.
$result->applyHeaders();
if (!$result->allowed) {
return \Pair\Api\ApiResponse::errorResponse('TOO_MANY_REQUESTS', [
'retryAfter' => $result->retryAfter,
'resetAt' => $result->resetAt,
]);
}
Frequent usage recipes
Reset a limiter after successful login
$key = 'throttle:login:' . $request->ip();
if ($loginSuccess) {
// Clears the throttle bucket after a successful login.
$limiter->clear($key);
}
Define different limit profiles
// Public endpoints.
$publicLimiter = new \Pair\Api\RateLimiter(120, 60);
// Sensitive endpoints such as OTP or login checks.
$strictLimiter = new \Pair\Api\RateLimiter(10, 60);
Inspect the backend driver
$result = $limiter->attempt('throttle:user:42');
if ($result->driver === 'redis') {
// The limit is shared across workers or nodes.
}
Example .env
PAIR_API_RATE_LIMIT_REDIS_PREFIX="pair:rate_limit:"
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_TIMEOUT=1
If Redis is not configured or becomes unavailable during the request, RateLimiter transparently falls back to file storage.
Secondary notes worth knowing
The class intentionally exposes only a few public methods:
attempt()for atomic enforce-and-consumetooManyAttempts()for read-only checkshit()for manual countingclear()for resetting one bucket
That small surface is deliberate and keeps most integrations straightforward.
Common pitfalls
- Reusing the same logical key for unrelated traffic classes.
- Forgetting that file fallback is local to the current node.
- Assuming client IP is trustworthy behind proxies without proper
PAIR_TRUSTED_PROXIESconfiguration. - Writing your own non-atomic
tooManyAttempts()+hit()sequence whenattempt()already solves that problem.
See also: ThrottleMiddleware, Middleware, Request, API.