Skip to content

RateLimiter

Viames Marino edited this page Oct 19, 2026 · 7 revisions

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

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.

Storage strategy

Primary backend: Redis

Redis is used when:

  • REDIS_HOST is configured
  • the ext-redis extension 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_PATH is 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:

  • maxAttempts
  • decaySeconds

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 RateLimitResult with 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:

  • allowed
  • limit
  • remaining
  • resetAt
  • retryAfter
  • driver

applyHeaders(): void

Emits:

  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-RateLimit-Reset
  • Retry-After when 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-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.

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_PROXIES configuration.
  • Writing your own non-atomic tooManyAttempts() + hit() sequence when attempt() already solves that problem.

See also: ThrottleMiddleware, AuthAttemptLimiter, Middleware, Request, API.

Clone this wiki locally