Skip to content

RateLimiter

Ray Fung edited this page Feb 26, 2026 · 3 revisions

Rate Limiter

Razy provides a fixed-window rate limiting system with pluggable storage backends, named limiters, middleware support, and HTTP headers. The rate limiter is designed for API protection, brute-force prevention, and request throttling.


Table of Contents


Quick Start

use Razy\RateLimit\RateLimiter;
use Razy\RateLimit\Limit;
use Razy\RateLimit\Store\ArrayStore;

$limiter = new RateLimiter(new ArrayStore());

// Simple rate check
$key = 'login:' . $_SERVER['REMOTE_ADDR'];
if (!$limiter->attempt($key, maxAttempts: 5, decaySeconds: 60)) {
    http_response_code(429);
    echo "Too many attempts. Try again in {$limiter->availableIn($key)} seconds.";
    exit;
}

// Process the request...

Basic Rate Limiting

attempt()

The primary method ??checks if an attempt is allowed and records it in one call:

$key = 'api:' . $userId;

if ($limiter->attempt($key, maxAttempts: 100, decaySeconds: 3600)) {
    // Request is allowed ??proceed
    $remaining = $limiter->remaining($key, maxAttempts: 100);
    echo "You have {$remaining} requests remaining.";
} else {
    // Rate limit exceeded
    $retryIn = $limiter->availableIn($key);
    echo "Rate limit exceeded. Try again in {$retryIn} seconds.";
}

Manual Control

For finer control, use hit() and tooManyAttempts() separately:

// Check without recording (read-only)
if ($limiter->tooManyAttempts($key, maxAttempts: 10)) {
    // Already exceeded ??don't process
    return;
}

// Record a hit manually
$totalHits = $limiter->hit($key, decaySeconds: 60);

// Read current state
$currentAttempts = $limiter->attempts($key);
$remaining = $limiter->remaining($key, 10);
$retryIn = $limiter->availableIn($key);    // seconds until reset
$resetAt = $limiter->resetAt($key);         // Unix timestamp

// Clear all hits for a key
$limiter->clear($key);

Named Limiters

Define reusable rate limit configurations with named limiters:

use Razy\RateLimit\Limit;

// Register named limiters
$limiter->for('api', function (array $context) {
    $user = $context['user'] ?? null;
    if ($user && $user->isPremium()) {
        return Limit::perMinute(120)->by("api:{$user->id}");
    }
    return Limit::perMinute(30)->by('api:' . ($context['ip'] ?? 'unknown'));
});

$limiter->for('login', function (array $context) {
    return Limit::perMinute(5)->by('login:' . ($context['ip'] ?? ''));
});

$limiter->for('uploads', function (array $context) {
    return Limit::perHour(50)->by("upload:{$context['user']->id}");
});

// Check if a named limiter exists
$limiter->hasLimiter('api');     // true

// Resolve a named limiter to get its Limit config
$limit = $limiter->resolve('api', ['user' => $currentUser, 'ip' => $ip]);
if ($limit && !$limit->isUnlimited()) {
    $allowed = $limiter->attempt(
        $limit->getKey(),
        $limit->getMaxAttempts(),
        $limit->getDecaySeconds()
    );
}

Limit Configuration

The Limit class configures rate limiting parameters using static factory methods:

use Razy\RateLimit\Limit;

// Per-minute (60 seconds decay)
$limit = Limit::perMinute(30);

// Per-hour (3600 seconds)
$limit = Limit::perHour(1000);

// Per-day (86400 seconds)
$limit = Limit::perDay(10000);

// Custom interval
$limit = Limit::every(decaySeconds: 120, maxAttempts: 10);

// Unlimited (no rate limiting)
$limit = Limit::none();

// Set the bucket key
$limit = Limit::perMinute(30)->by('api:user-42');

// Read configuration
$limit->getMaxAttempts();    // 30
$limit->getDecaySeconds();   // 60
$limit->getKey();            // 'api:user-42'
$limit->isUnlimited();      // false

Middleware

The RateLimitMiddleware integrates rate limiting into request pipelines and automatically sets HTTP headers:

use Razy\RateLimit\RateLimitMiddleware;
use Razy\RateLimit\RateLimitExceededException;

$middleware = new RateLimitMiddleware(
    limiter: $limiter,
    name: 'api',                          // named limiter
    keyResolver: function (array $context) {
        return 'api:' . $context['user_id'];
    },
    onLimitExceeded: function (RateLimitExceededException $e) {
        http_response_code(429);
        echo json_encode([
            'error'       => 'Too many requests',
            'retry_after' => $e->getRetryAfter(),
        ]);
    },
    sendHeaders: true                      // send X-RateLimit-* headers
);

// In a pipeline context
$result = $middleware->handle($context, function ($context) {
    return processRequest($context);
});

HTTP Headers

When sendHeaders: true (default), the middleware sets:

Header Description Example
X-RateLimit-Limit Max attempts per window 100
X-RateLimit-Remaining Remaining attempts 87
X-RateLimit-Reset Unix timestamp when window resets 1700000060
Retry-After Seconds until retry (only on 429) 42

Storage Backends

ArrayStore (Testing)

In-memory store ??data is lost between requests:

use Razy\RateLimit\Store\ArrayStore;

$store = new ArrayStore();
$limiter = new RateLimiter($store);

// Testing helpers
$store->getRecords();        // all stored records
$store->count();             // number of active keys
$store->flush();             // clear all records

CacheStore (Production)

Uses Razy's PSR-16 Cache interface for persistent storage:

use Razy\RateLimit\Store\CacheStore;
use Razy\Cache\RedisAdapter;

$redis = new Redis();
$redis->connect('127.0.0.1');

$store = new CacheStore(
    cache: new RedisAdapter($redis),
    prefix: 'ratelimit_'
);
$limiter = new RateLimiter($store);

// Inspect
$store->getCache();     // the CacheInterface instance
$store->getPrefix();    // 'ratelimit_'

Custom Store

Implement RateLimitStoreInterface for custom backends:

use Razy\Contract\RateLimitStoreInterface;

class DatabaseStore implements RateLimitStoreInterface
{
    public function get(string $key): ?array
    {
        $row = DB::table('rate_limits')->where('key', $key)->first();
        if (!$row) return null;
        return ['hits' => $row->hits, 'resetAt' => $row->reset_at];
    }

    public function set(string $key, int $hits, int $resetAt): void
    {
        DB::table('rate_limits')->upsert(
            ['key' => $key, 'hits' => $hits, 'reset_at' => $resetAt],
            ['key'],
            ['hits', 'reset_at']
        );
    }

    public function delete(string $key): void
    {
        DB::table('rate_limits')->where('key', $key)->delete();
    }
}

Testing

Clock Override

Override the internal clock for deterministic testing:

$store = new ArrayStore();
$store->setCurrentTime(1700000000);

$limiter = new RateLimiter($store);
$limiter->setCurrentTime(1700000000);

// All time-based operations use the override
$limiter->attempt('test', 5, 60);

// Advance time
$store->setCurrentTime(1700000061);  // 61 seconds later
$limiter->setCurrentTime(1700000061);

// Window has expired ??attempts reset
$limiter->attempts('test'); // 0

RateLimitExceededException

use Razy\RateLimit\RateLimitExceededException;

try {
    if (!$limiter->attempt($key, 5, 60)) {
        throw new RateLimitExceededException(
            key: $key,
            maxAttempts: 5,
            retryAfter: $limiter->availableIn($key)
        );
    }
} catch (RateLimitExceededException $e) {
    $e->getKey();          // rate limit key
    $e->getMaxAttempts();  // 5
    $e->getRetryAfter();   // seconds to wait
    // HTTP code: 429
}

API Reference

RateLimiter

Method Signature Description
__construct (RateLimitStoreInterface $store) Create with store
for (string $name, Closure $callback): void Register named limiter
limiter (string $name): ?Closure Get limiter callback
hasLimiter (string $name): bool Check limiter exists
attempt (string $key, int $maxAttempts, int $decaySeconds): bool Check + record
tooManyAttempts (string $key, int $maxAttempts): bool Read-only check
hit (string $key, int $decaySeconds): int Record hit, return total
remaining (string $key, int $maxAttempts): int Remaining attempts
availableIn (string $key): int Seconds until reset
resetAt (string $key): int Reset Unix timestamp
attempts (string $key): int Current hit count
clear (string $key): void Clear all hits
resolve (string $name, array $context = []): ?Limit Resolve named limiter
getStore (): RateLimitStoreInterface Get store instance
setCurrentTime (?int $timestamp): void Testing clock override

Limit

Method Signature Description
perMinute (static) (int $maxAttempts): static 60s window
perHour (static) (int $maxAttempts): static 3600s window
perDay (static) (int $maxAttempts): static 86400s window
every (static) (int $decaySeconds, int $maxAttempts): static Custom window
none (static) (): static Unlimited
by (string $key): static Set bucket key
getMaxAttempts (): int Max attempts
getDecaySeconds (): int Window duration
getKey (): string Bucket key
isUnlimited (): bool Is unlimited?

RateLimitStoreInterface

Method Signature Description
get (string $key): ?array Returns {hits, resetAt} or null
set (string $key, int $hits, int $resetAt): void Store record
delete (string $key): void Remove record

??Previous: AuthManager Profiler

Clone this wiki locally