Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/cache/src/RateLimiting/CacheRateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Tempest\Cache\RateLimiting;

use Tempest\Cache\Cache;
use Tempest\DateTime\DateTime;
use Tempest\DateTime\Duration;

/**
* A rate limiter implementation using the cache.
*/
final readonly class CacheRateLimiter implements RateLimiter
{
private const string PREFIX = 'tempest_rate_limit_';

public function __construct(
private Cache $cache,
) {}

public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult
{
$cacheKey = $this->getCacheKey($key);
$timerKey = $this->getTimerKey($key);

// Get or set the timer (when the window ends)
$resetAt = (int) $this->cache->get($timerKey);

if ($resetAt === 0) {
$resetAt = time() + $decaySeconds;
$this->cache->put($timerKey, $resetAt, Duration::seconds($decaySeconds));
}

// Increment the counter
$attempts = $this->cache->increment($cacheKey);

// Set expiration on first hit
if ($attempts === 1) {
$this->cache->put($cacheKey, 1, Duration::seconds($decaySeconds));
}

$remaining = max(0, $maxAttempts - $attempts);

if ($attempts > $maxAttempts) {
return RateLimitResult::deny($maxAttempts, (int) $resetAt);
}

return RateLimitResult::allow($maxAttempts, $remaining, $resetAt);
}

public function attempts(string $key): int
{
return (int) ($this->cache->get($this->getCacheKey($key)) ?? 0);
}

public function remaining(string $key, int $maxAttempts): int
{
return max(0, $maxAttempts - $this->attempts($key));
}

public function tooManyAttempts(string $key, int $maxAttempts): bool
{
return $this->attempts($key) >= $maxAttempts;
}

public function clear(string $key): void
{
$this->cache->remove($this->getCacheKey($key));
$this->cache->remove($this->getTimerKey($key));
}

public function availableAt(string $key): DateTime
{
$timestamp = (int) ($this->cache->get($this->getTimerKey($key)) ?? time());

return DateTime::fromTimestamp($timestamp);
}

private function getCacheKey(string $key): string
{
return self::PREFIX . $this->sanitizeKey($key);
}

private function getTimerKey(string $key): string
{
return self::PREFIX . $this->sanitizeKey($key) . '_timer';
}

/**
* Sanitize a cache key to be compatible with all cache adapters.
* Replaces reserved characters with underscores.
*/
private function sanitizeKey(string $key): string
{
return preg_replace('/[{}()\/@:\\\\]/', '_', $key);
}
}
52 changes: 52 additions & 0 deletions packages/cache/src/RateLimiting/RateLimitResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Tempest\Cache\RateLimiting;

/**
* The result of a rate limit check.
*/
final readonly class RateLimitResult
{
public function __construct(
/** Whether the request should be allowed. */
public bool $allowed,
/** The maximum number of attempts allowed. */
public int $limit,
/** The number of attempts remaining in the current window. */
public int $remaining,
/** The Unix timestamp when the rate limit resets. */
public int $resetAt,
/** The number of seconds until the rate limit resets. */
public int $retryAfter,
) {}

/**
* Create a successful result (request allowed).
*/
public static function allow(int $limit, int $remaining, int $resetAt): self
{
return new self(
allowed: true,
limit: $limit,
remaining: $remaining,
resetAt: $resetAt,
retryAfter: max(0, $resetAt - time()),
);
}

/**
* Create a failed result (request denied due to rate limit).
*/
public static function deny(int $limit, int $resetAt): self
{
return new self(
allowed: false,
limit: $limit,
remaining: 0,
resetAt: $resetAt,
retryAfter: max(0, $resetAt - time()),
);
}
}
47 changes: 47 additions & 0 deletions packages/cache/src/RateLimiting/RateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Tempest\Cache\RateLimiting;

use Tempest\DateTime\DateTime;

/**
* A rate limiter tracks and enforces request limits.
*/
interface RateLimiter
{
/**
* Attempt to hit the rate limiter for the given key.
*
* @param string $key The unique identifier for this rate limit bucket.
* @param int $maxAttempts The maximum number of attempts allowed.
* @param int $decaySeconds The time window in seconds.
*/
public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateLimitResult;

/**
* Get the number of attempts for the given key.
*/
public function attempts(string $key): int;

/**
* Get the number of remaining attempts for the given key.
*/
public function remaining(string $key, int $maxAttempts): int;

/**
* Determine if the given key has been hit too many times.
*/
public function tooManyAttempts(string $key, int $maxAttempts): bool;

/**
* Clear the rate limiter for the given key.
*/
public function clear(string $key): void;

/**
* Get the date-time when the rate limit resets for the given key.
*/
public function availableAt(string $key): DateTime;
}
115 changes: 115 additions & 0 deletions packages/cache/tests/RateLimiting/CacheRateLimiterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace Tempest\Cache\Tests\RateLimiting;

use PHPUnit\Framework\TestCase;
use Tempest\Cache\RateLimiting\CacheRateLimiter;
use Tempest\Cache\Testing\TestingCache;
use Tempest\Clock\GenericClock;

/**
* @internal
*/
final class CacheRateLimiterTest extends TestCase
{
private TestingCache $cache;

private CacheRateLimiter $rateLimiter;

protected function setUp(): void
{
$clock = new GenericClock();
$this->cache = new TestingCache(tag: 'rate-limit-test', clock: $clock->toPsrClock());
$this->rateLimiter = new CacheRateLimiter($this->cache);
}

public function test_attempt_allows_within_limit(): void
{
$result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60);

$this->assertTrue($result->allowed);
$this->assertSame(5, $result->limit);
$this->assertSame(4, $result->remaining);
}

public function test_attempt_denies_after_exceeding_limit(): void
{
// Make 5 attempts (the limit)
for ($i = 0; $i < 5; $i++) {
$this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60);
}

// 6th attempt should be denied
$result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60);

$this->assertFalse($result->allowed);
$this->assertSame(0, $result->remaining);
}

public function test_attempts_returns_count(): void
{
$this->assertSame(0, $this->rateLimiter->attempts('test-key'));

$this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60);
$this->assertSame(1, $this->rateLimiter->attempts('test-key'));

$this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60);
$this->assertSame(2, $this->rateLimiter->attempts('test-key'));
}

public function test_remaining_returns_available_attempts(): void
{
$this->assertSame(10, $this->rateLimiter->remaining('test-key', maxAttempts: 10));

$this->rateLimiter->attempt('test-key', maxAttempts: 10, decaySeconds: 60);
$this->assertSame(9, $this->rateLimiter->remaining('test-key', maxAttempts: 10));
}

public function test_too_many_attempts_returns_true_when_exceeded(): void
{
$this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2));

$this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60);
$this->assertFalse($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2));

$this->rateLimiter->attempt('test-key', maxAttempts: 2, decaySeconds: 60);
$this->assertTrue($this->rateLimiter->tooManyAttempts('test-key', maxAttempts: 2));
}

public function test_clear_resets_the_counter(): void
{
$this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60);
$this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60);

$this->assertSame(2, $this->rateLimiter->attempts('test-key'));

$this->rateLimiter->clear('test-key');

$this->assertSame(0, $this->rateLimiter->attempts('test-key'));
}

public function test_different_keys_are_independent(): void
{
$this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60);
$this->rateLimiter->attempt('key-a', maxAttempts: 2, decaySeconds: 60);

// key-a is at limit
$this->assertTrue($this->rateLimiter->tooManyAttempts('key-a', maxAttempts: 2));

// key-b should still be available
$this->assertFalse($this->rateLimiter->tooManyAttempts('key-b', maxAttempts: 2));
}

public function test_reset_time_is_set_correctly(): void
{
$beforeTime = time();
$result = $this->rateLimiter->attempt('test-key', maxAttempts: 5, decaySeconds: 60);
$afterTime = time();

// Reset time should be approximately 60 seconds from now
$this->assertGreaterThanOrEqual($beforeTime + 60, $result->resetAt);
$this->assertLessThanOrEqual($afterTime + 60, $result->resetAt);
}
}
46 changes: 46 additions & 0 deletions packages/cache/tests/RateLimiting/RateLimitResultTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace Tempest\Cache\Tests\RateLimiting;

use PHPUnit\Framework\TestCase;
use Tempest\Cache\RateLimiting\RateLimitResult;

/**
* @internal
*/
final class RateLimitResultTest extends TestCase
{
public function test_allow_creates_successful_result(): void
{
$resetAt = time() + 60;
$result = RateLimitResult::allow(limit: 100, remaining: 99, resetAt: $resetAt);

$this->assertTrue($result->allowed);
$this->assertSame(100, $result->limit);
$this->assertSame(99, $result->remaining);
$this->assertSame($resetAt, $result->resetAt);
$this->assertLessThanOrEqual(60, $result->retryAfter);
}

public function test_deny_creates_failed_result(): void
{
$resetAt = time() + 30;
$result = RateLimitResult::deny(limit: 100, resetAt: $resetAt);

$this->assertFalse($result->allowed);
$this->assertSame(100, $result->limit);
$this->assertSame(0, $result->remaining);
$this->assertSame($resetAt, $result->resetAt);
$this->assertLessThanOrEqual(30, $result->retryAfter);
}

public function test_retry_after_is_never_negative(): void
{
$pastTime = time() - 100;
$result = RateLimitResult::deny(limit: 100, resetAt: $pastTime);

$this->assertSame(0, $result->retryAfter);
}
}
24 changes: 24 additions & 0 deletions packages/http/src/IsRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ public function getCookie(string $name): ?Cookie
return $this->cookies[$name] ?? null;
}

public function getClientIp(): string
{
$forwardedFor = $this->headers->get('X-Forwarded-For');

if ($forwardedFor !== null) {
$ips = explode(',', $forwardedFor);

return trim($ips[0]);
}

$realIp = $this->headers->get('X-Real-IP');

if ($realIp !== null) {
return $realIp;
}

if (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] !== '') {
return (string) $_SERVER['REMOTE_ADDR'];
}

// Avoid a shared global fallback key when the client IP cannot be resolved.
return 'unknown:' . spl_object_id($this);
}

private function resolvePath(): string
{
$decodedUri = rawurldecode($this->uri);
Expand Down
Loading