-
Notifications
You must be signed in to change notification settings - Fork 0
RateLimiter
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.
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...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.";
}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);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()
);
}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(); // falseThe 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);
});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 |
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 recordsUses 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_'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();
}
}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'); // 0use 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
}| 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 |
| 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? |
| 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 |