Repository navigation
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
AuthAttemptLimiter uses this same storage engine for login attempts, while ThrottleMiddleware uses it for API request limits.
Its public API is intentionally small, and attempt() is the main method you should understand first.
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.
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
$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);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.
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.
Consumes a hit and returns the remaining attempts.
Current implementation detail: hit() also emits the standard rate-limit headers for backward-compatible flows.
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
attempt() returns a Pair\Api\RateLimitResult with:
allowedlimitremainingresetAtretryAfterdriver
Emits:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset-
Retry-Afterwhen blocked
The driver field tells you which backend was used, usually redis or file.
$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,
]);
}$key = 'throttle:login:' . $request->ip();
if ($loginSuccess) {
// Clears the throttle bucket after a successful login.
$limiter->clear($key);
}// Public endpoints.
$publicLimiter = new \Pair\Api\RateLimiter(120, 60);
// Sensitive endpoints such as OTP or login checks.
$strictLimiter = new \Pair\Api\RateLimiter(10, 60);$result = $limiter->attempt('throttle:user:42');
if ($result->driver === 'redis') {
// The limit is shared across workers or nodes.
}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=1If Redis is not configured or becomes unavailable during the request, RateLimiter transparently falls back to file storage.
The class intentionally exposes only a few public methods:
-
attempt()for atomic enforce-and-consume -
tooManyAttempts()for read-only checks -
hit()for manual counting -
clear()for resetting one bucket
That small surface is deliberate and keeps most integrations straightforward.
- 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, AuthAttemptLimiter, Middleware, Request, API.
Home · Setup · Configuration
Build with Pair 4
- Generator
- Controllers and responses
- Read models
- ActiveRecord and queries
- Forms and validation
- PairUI.js and HTML fragments
APIs and authentication
- API guide and CRUD
- Middleware and rate limits
- Users, sessions, and ACL
- Passkeys and social login
- Native mobile setup
Integrate and operate
- Integrations
- Runtime extensions and adapter keys
- Cache and cache stores
- Logs and observability
- PWA and push
- Packages and distribution
- Upgrade to v4