Skip to content

How-to: Sliding-Window Rate Limiter

Overview

This guide covers building a per-user, per-endpoint sliding-window rate limiter with NENE2. Requests are counted within a rolling time window; once the limit is reached, further requests are rejected with 429 Too Many Requests.

Reference implementation: ../NENE2-FT/ratelog/


Schema Design

sql
CREATE TABLE IF NOT EXISTS rate_events (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    endpoint   TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_rate_events_user_endpoint
    ON rate_events (user_id, endpoint, created_at);

The index on (user_id, endpoint, created_at) makes the COUNT query fast at scale.


Route Table

MethodPathAuthDescription
POST/rate/checkUserRecord a request; returns 429 if over limit
GET/rate/statusUserCurrent usage for a user/endpoint
DELETE/rate/reset/{userId}AdminReset counters for a user

Core Algorithm

php
private const int LIMIT = 10;
private const int WINDOW_SECONDS = 60;

public function check(int $userId, string $endpoint): string
{
    $since = $this->windowStart();   // now() - 60s
    $count = $this->countInWindow($userId, $endpoint, $since);

    if ($count >= self::LIMIT) {
        return 'rate_limited';
    }

    $this->recordEvent($userId, $endpoint);
    return 'ok';
}

Sliding window: every check() looks back exactly WINDOW_SECONDS from the current moment, so old events naturally fall out of scope.


Admin Reset with Fail-Closed Pattern

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;     // fail-closed: unconfigured key blocks all admin access
    }
    return hash_equals($this->adminKey, $req->getHeaderLine('X-Admin-Key'));
}

Reset all counters for a user (all endpoints):

sql
DELETE FROM rate_events WHERE user_id = :uid

Reset for a specific endpoint:

sql
DELETE FROM rate_events WHERE user_id = :uid AND endpoint = :ep

Path Parameter Extraction (no Router::param())

When Router::param() is not available in the installed version, use the attribute directly:

php
/** @var array<string, string> $params */
$params = $req->getAttribute(Router::PARAMETERS_ATTRIBUTE, []);
$raw    = $params['userId'] ?? '';

Validation

  • endpoint: non-empty string, max 128 chars
  • X-User-Id: ctype_digit() + positive integer
  • Path userId: ctype_digit() + positive integer (fail → 404)
  • Admin key: hash_equals() comparison (fail → 403)

HTTP Status Codes

SituationStatus
Request allowed200
Status retrieved200
Counter reset200
No X-User-Id400
No body400
Empty / missing endpoint422
Endpoint too long422
No admin key403
Wrong admin key403
Invalid userId in path404
Rate limit exceeded429

ATK Attack Patterns Covered

ATKPatternDefence
ATK-01Missing X-User-Id400 with message
ATK-02Empty endpoint string422 validation
ATK-03129-char endpoint (DoS)Length limit 422
ATK-04SQL injection in endpointParameterized queries
ATK-05Non-admin reset attempt403 fail-closed
ATK-06Wrong admin key403 hash_equals()
ATK-07Negative userId in path404
ATK-08Zero userId404
ATK-09Non-digit userId (abc)404 ctype_digit
ATK-10Status without endpoint param422
ATK-11Check without body400
ATK-12Body missing endpoint key422

Notes

  • Concurrency: The sliding window has a small TOCTOU window. For high-concurrency production use, consider atomic counters (Redis INCR + EXPIRE) or database-level locking.
  • Clock skew: All timestamps should use UTC to avoid DST or timezone surprises.
  • Storage growth: Old events accumulate. Add a periodic cleanup job: DELETE FROM rate_events WHERE created_at < :cutoff.

Released under the MIT License.