Skip to content

Add Rate Limiting ​

This guide shows how to protect your NENE2 application with request rate limiting using ThrottleMiddleware and RateLimitStorageInterface.

Prerequisite: You have a working NENE2 application. If not, start with the Tutorial.


Production requirement — shared storage ​

Warning: InMemoryRateLimitStorage (shown below in quick start) is not suitable for production. PHP-FPM spawns multiple worker processes, and each process has its own in-memory store. A client that hits 10 workers simultaneously will be counted only once per worker — effectively bypassing the limit. Use a shared storage backend (Redis, Memcached, or a database) in any multi-process or multi-server deployment.

See Swap the storage backend for a Redis implementation.


Quick start ​

Add ThrottleMiddleware to RuntimeApplicationFactory. The built-in InMemoryRateLimitStorage is suitable for local development and single-process testing only.

php
use Nene2\Error\ProblemDetailsResponseFactory;
use Nene2\Http\RuntimeApplicationFactory;
use Nene2\Middleware\InMemoryRateLimitStorage;
use Nene2\Middleware\ThrottleMiddleware;
use Nyholm\Psr7\Factory\Psr17Factory;

$psr17    = new Psr17Factory();
$problems = new ProblemDetailsResponseFactory($psr17, $psr17);
$storage  = new InMemoryRateLimitStorage();

$throttle = new ThrottleMiddleware(
    problemDetails: $problems,
    storage:        $storage,
    limit:          60,   // requests allowed per window
    windowSeconds:  60,   // window length in seconds
);

$app = (new RuntimeApplicationFactory(
    $psr17,
    $psr17,
    throttleMiddleware: $throttle,
))->create();

ThrottleMiddleware sits at position 8 in the middleware stack — after authentication, so you can key limits per authenticated user if you choose (see Custom key extractor below).


How it works ​

For every request, the middleware:

  1. Computes a key for the client (default: REMOTE_ADDR).
  2. Increments the counter in the storage backend.
  3. If the counter is at or below the limit — passes the request through and adds rate limit headers.
  4. If the counter exceeds the limit — returns 429 Too Many Requests with Problem Details.

Response headers ​

Every response (including 429) carries these headers:

HeaderValue
X-RateLimit-LimitConfigured limit per window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds until the window resets (429 only)

429 response body ​

json
{
  "type": "https://nene2.dev/problems/too-many-requests",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit exceeded. Try again in 42 seconds.",
  "instance": "/examples/notes"
}

Custom key extractor ​

By default the key is the client IP address (REMOTE_ADDR). Pass a Closure to key limits per authenticated user, API key, or any other dimension.

php
$throttle = new ThrottleMiddleware(
    problemDetails: $problems,
    storage:        $storage,
    limit:          1000,
    windowSeconds:  3600,
    keyExtractor:   static function (ServerRequestInterface $request): string {
        // Use a header set by BearerTokenMiddleware after token verification.
        return $request->getAttribute('user_id', 'anonymous');
    },
);

Swap the storage backend ​

InMemoryRateLimitStorage stores counters in the PHP process memory. This is reset on every request in FPM deployments and is not shared between processes. For production you need a shared store such as Redis.

Implement RateLimitStorageInterface:

php
use Nene2\Middleware\RateLimitStorageInterface;

final class RedisRateLimitStorage implements RateLimitStorageInterface
{
    public function __construct(private \Redis $redis) {}

    /** @return array{count: int, reset_at: int} */
    public function hit(string $key, int $windowSeconds): array
    {
        $redisKey = "rate:{$key}";
        $count    = (int) $this->redis->incr($redisKey);

        if ($count === 1) {
            $this->redis->expire($redisKey, $windowSeconds);
        }

        $ttl     = max(0, (int) $this->redis->ttl($redisKey));
        $resetAt = time() + $ttl;

        return ['count' => $count, 'reset_at' => $resetAt];
    }
}

Then inject it instead of InMemoryRateLimitStorage:

php
$throttle = new ThrottleMiddleware(
    problemDetails: $problems,
    storage:        new RedisRateLimitStorage($redis),
    limit:          200,
    windowSeconds:  60,
);

Design decisions ​

See ADR 0010 for the rationale behind:

  • Fixed-window algorithm selection
  • IP-keyed default
  • Header conventions (X-RateLimit-*, Retry-After)
  • RateLimitStorageInterface abstraction boundary

Next step ​

See Problem Details types for the full 429 error shape, or Add a health check for the complementary observability feature.

Released under the MIT License.