Skip to content

RateLimiter

Viames Marino edited this page Mar 26, 2026 · 4 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

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) {
    // Stops the request with a normalized API error.
    \Pair\Api\ApiResponse::error('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) {
    \Pair\Api\ApiResponse::error('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, Middleware, Request, API.

Clone this wiki locally