Rate Limiting selbst implementieren: Token Bucket & Sliding Window in PHP
AI generated
<?php
8.4
PHP · Rate Limiting · API-Sicherheit · Redis
Rate Limiting selbst implementieren
Token Bucket, Sliding Window und die richtigen HTTP-Header

Ohne Rate Limiting genügt ein einziger fehlerhafter Client-Loop oder ein gezielter Angriff, um eine PHP-API in die Knie zu zwingen. Ein eigenes Rate Limiting lässt sich mit Redis und wenigen Zeilen PHP umsetzen, ganz ohne Drittanbieter-Gateway, und die Wahl zwischen Fixed Window, Sliding Window und Token Bucket entscheidet darüber, wie fair und wie burst-tolerant der Schutz tatsächlich ausfällt.

18 Min. Lesezeit Token Bucket · Sliding Window · Redis · 429 PHP 8.4 · framework-unabhängig

1. Warum Rate Limiting kein optionales Feature ist

Jede öffentlich erreichbare PHP-API braucht Rate Limiting, sobald sie mehr als einen vertrauenswürdigen internen Konsumenten hat. Ohne Begrenzung kann ein einzelner fehlerhaft konfigurierter Client, ein aggressiver Scraper oder ein gezielter Denial-of-Service-Versuch die Datenbank, den Cache und letztlich den gesamten Anwendungsserver überlasten. Rate Limiting setzt eine harte Obergrenze, wie viele Requests ein Client innerhalb eines Zeitfensters stellen darf, und schützt so die Infrastruktur vor exzessiver Last, egal ob böswillig oder versehentlich verursacht.

Über den reinen Schutz hinaus dient Rate Limiting auch der fairen Ressourcenverteilung zwischen Konsumenten: Ohne Begrenzung kann ein einzelner Großkunde mit hohem Traffic-Aufkommen die Antwortzeiten für alle anderen Nutzer der API verschlechtern. Kommerzielle APIs nutzen Rate Limiting zusätzlich, um Tarifstufen technisch durchzusetzen, etwa ein Free-Tier mit 100 Requests pro Stunde gegenüber einem Enterprise-Tier mit 10.000. Ein eigenes Rate Limiting in PHP zu bauen ist dabei weniger aufwendig, als es zunächst wirkt, besonders mit Redis als gemeinsamem Zähler-Backend.

2. Fixed Window: der einfachste Ansatz und seine Schwäche

Der einfachste Algorithmus für Rate Limiting ist Fixed Window: Für jeden Client wird ein Zähler geführt, der am Anfang jedes festen Zeitfensters, etwa jeder vollen Minute, auf null zurückgesetzt wird. Erreicht der Zähler das Limit vor Ablauf des Fensters, werden weitere Requests abgelehnt. Die Implementierung ist trivial und benötigt nur einen atomaren Increment-Befehl mit Ablaufzeit, was Fixed Window zur naheliegenden ersten Wahl für Rate Limiting macht.

Die Schwäche zeigt sich an den Fenstergrenzen: Ein Client kann kurz vor Ende eines Fensters das volle Limit ausschöpfen und direkt danach, im neuen Fenster, sofort erneut das volle Limit nutzen. In der Summe entstehen so doppelt so viele Requests wie eigentlich vorgesehen, konzentriert in einem sehr kurzen Zeitraum um die Fenstergrenze. Für viele interne APIs ist dieser Effekt tolerierbar, für Rate Limiting an kritischen, ressourcenintensiven Endpunkten ist er ein echtes Sicherheitsproblem.


<?php

declare(strict_types=1);

// Fixed window rate limiting with Redis — simple but has boundary bursts
final class FixedWindowLimiter
{
    public function __construct(
        private readonly Redis $redis,
        private readonly int $limit,
        private readonly int $windowSeconds,
    ) {}

    public function allow(string $key): bool
    {
        $windowKey = "rl:{$key}:" . intdiv(time(), $this->windowSeconds);

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

        return $count <= $this->limit;
    }
}

$limiter = new FixedWindowLimiter(new Redis(), limit: 100, windowSeconds: 60);
if (!$limiter->allow("api-key:{$apiKey}")) {
    http_response_code(429);
    exit;
}

3. Sliding Window: präziser gegen Grenzbursts

Sliding Window löst das Grenzburst-Problem, indem es nicht auf feste Kalenderfenster setzt, sondern jeden einzelnen Request-Zeitstempel innerhalb der letzten N Sekunden vor dem aktuellen Zeitpunkt betrachtet. Das genaueste Rate Limiting nach diesem Prinzip speichert jeden Zeitstempel in einer sortierten Menge, entfernt abgelaufene Einträge und zählt die verbleibenden. Die Genauigkeit hat ihren Preis: mehr Speicherbedarf pro Client und mehr Redis-Operationen pro Request im Vergleich zu Fixed Window.

Eine effizientere Annäherung ist Sliding Window Counter, das zwei Fixed-Window-Zähler kombiniert und den Anteil des vorherigen Fensters proportional zur verstrichenen Zeit gewichtet. Dieser Ansatz braucht nur zwei Zähler statt einer vollständigen Zeitstempel-Liste und liefert in der Praxis eine gute Näherung an echtes Sliding-Window-Verhalten. Für die meisten Rate-Limiting-Anwendungsfälle in PHP-APIs ist diese gewichtete Variante der beste Kompromiss zwischen Präzision und Ressourcenverbrauch.


<?php

declare(strict_types=1);

// Sliding window counter — weights the previous window by elapsed time
final class SlidingWindowLimiter
{
    public function __construct(
        private readonly Redis $redis,
        private readonly int $limit,
        private readonly int $windowSeconds,
    ) {}

    public function allow(string $key): bool
    {
        $now = time();
        $currentWindow = intdiv($now, $this->windowSeconds);
        $elapsedFraction = ($now % $this->windowSeconds) / $this->windowSeconds;

        $currentKey = "rl:{$key}:{$currentWindow}";
        $previousKey = "rl:{$key}:" . ($currentWindow - 1);

        $currentCount = (int) $this->redis->get($currentKey);
        $previousCount = (int) $this->redis->get($previousKey);

        // Weighted estimate: previous window's share still "counts" as it slides out
        $weightedCount = $previousCount * (1 - $elapsedFraction) + $currentCount;

        if ($weightedCount >= $this->limit) {
            return false;
        }

        $this->redis->incr($currentKey);
        $this->redis->expire($currentKey, $this->windowSeconds * 2);

        return true;
    }
}

4. Token Bucket: kontrollierte Bursts erlauben

Token Bucket verfolgt eine andere Philosophie: Statt Requests strikt gleichmäßig zu verteilen, erlaubt es kurze, kontrollierte Bursts, solange die durchschnittliche Rate über die Zeit eingehalten wird. Ein Eimer wird mit einer festen Rate an Tokens aufgefüllt, bis zu einer maximalen Kapazität. Jeder Request verbraucht ein Token, ist der Eimer leer, wird der Request abgelehnt. Das macht dieses Rate Limiting besonders geeignet für APIs, bei denen Clients gelegentlich in Schüben statt gleichmäßig anfragen, etwa beim initialen Laden vieler Ressourcen einer Anwendung.

Der entscheidende Unterschied zu Sliding Window ist die Fähigkeit, ungenutzte Kapazität anzusparen: Ein Client, der eine Weile inaktiv war, kann kurzzeitig mehr Requests stellen als der Durchschnittswert erlauben würde, solange der Eimer voll genug ist. Dieses Verhalten spiegelt reale Nutzungsmuster oft besser wider als starre Fenster und macht Token Bucket zur bevorzugten Wahl für Rate Limiting bei APIs mit natürlich schwankendem Traffic.


<?php

declare(strict_types=1);

// Token bucket rate limiting: allows controlled bursts up to bucket capacity
final class TokenBucketLimiter
{
    public function __construct(
        private readonly Redis $redis,
        private readonly int $capacity,
        private readonly float $refillRatePerSecond,
    ) {}

    public function allow(string $key): bool
    {
        $bucketKey = "rl:bucket:{$key}";
        $data = $this->redis->hGetAll($bucketKey);

        $tokens = isset($data['tokens']) ? (float) $data['tokens'] : (float) $this->capacity;
        $lastRefill = isset($data['last_refill']) ? (float) $data['last_refill'] : microtime(true);

        $now = microtime(true);
        $elapsed = $now - $lastRefill;
        $tokens = min($this->capacity, $tokens + $elapsed * $this->refillRatePerSecond);

        if ($tokens < 1.0) {
            $this->redis->hMSet($bucketKey, ['tokens' => $tokens, 'last_refill' => $now]);
            return false;
        }

        $tokens -= 1.0;
        $this->redis->hMSet($bucketKey, ['tokens' => $tokens, 'last_refill' => $now]);
        $this->redis->expire($bucketKey, 3600);

        return true;
    }
}

// 60 tokens capacity, refilled at 1 per second — allows short bursts up to 60
$limiter = new TokenBucketLimiter(new Redis(), capacity: 60, refillRatePerSecond: 1.0);

5. Redis als Backend für verteiltes Rate Limiting

Sobald eine PHP-Anwendung auf mehreren Servern oder Containern läuft, reicht ein In-Memory-Zähler pro Prozess nicht mehr aus, weil jeder Server nur seinen eigenen Traffic-Anteil sähe. Rate Limiting braucht dann einen zentralen, gemeinsam genutzten Zustand, und Redis eignet sich dafür ideal: geringe Latenz, atomare Increment-Operationen und eingebaute Time-to-Live-Unterstützung für automatisches Aufräumen abgelaufener Zähler.

Wichtig ist, dass die Redis-Operationen für Rate Limiting atomar bleiben, besonders bei Token Bucket mit seiner Lese-Berechnen-Schreiben-Sequenz. Ohne atomare Ausführung über eine Lua-Skript-Transaktion können zwei gleichzeitige Requests denselben Tokenstand lesen und beide den Verbrauch fälschlich für erlaubt halten, was das Limit effektiv verdoppelt. In produktiven Umgebungen lohnt sich daher, die Bucket-Logik als EVAL-Lua-Skript auszuführen, statt mehrere einzelne Redis-Befehle aus PHP heraus abzusetzen.

Rate Limiting ohne die passenden HTTP-Header lässt Clients im Unklaren darüber, wann sie es erneut versuchen können. Der Statuscode 429 Too Many Requests signalisiert die Ablehnung, der Retry-After-Header gibt an, nach wie vielen Sekunden ein erneuter Versuch sinnvoll ist. Zusätzlich haben sich die inoffiziellen, aber weit verbreiteten Header X-RateLimit-Limit, X-RateLimit-Remaining und X-RateLimit-Reset etabliert, die Clients erlauben, proaktiv unter dem Limit zu bleiben, statt erst nach einer Ablehnung zu reagieren.

Diese Header sollten bei jedem Request mitgeschickt werden, nicht nur bei Ablehnungen, damit gut implementierte Clients ihr eigenes Verhalten anpassen können, bevor sie überhaupt an das Limit stoßen. Ein Rate Limiting, das diese Transparenz bietet, reduziert die Zahl abgelehnter Requests in der Praxis deutlich, weil kooperative Clients ihre Anfragerate selbstständig drosseln, sobald X-RateLimit-Remaining gegen null geht.


<?php

declare(strict_types=1);

// Standard rate-limit response headers, sent on every request
final class RateLimitHeaders
{
    public function apply(int $limit, int $remaining, int $resetTimestamp): void
    {
        header("X-RateLimit-Limit: {$limit}");
        header('X-RateLimit-Remaining: ' . max(0, $remaining));
        header("X-RateLimit-Reset: {$resetTimestamp}");
    }

    public function reject(int $retryAfterSeconds): void
    {
        http_response_code(429);
        header("Retry-After: {$retryAfterSeconds}");
        header('Content-Type: application/problem+json');
        echo json_encode([
            'type' => 'https://mironsoft.de/problems/rate-limit-exceeded',
            'title' => 'Too Many Requests',
            'status' => 429,
            'detail' => "Rate limit exceeded, retry after {$retryAfterSeconds} seconds",
        ]);
    }
}

7. Rate-Limit-Schlüssel: IP, API-Key oder Nutzer-ID

Die Wahl des Schlüssels, unter dem Rate Limiting gezählt wird, entscheidet über dessen tatsächliche Wirksamkeit. IP-basiertes Rate Limiting ist einfach umzusetzen, versagt aber bei Clients hinter gemeinsam genutzten NAT-Gateways oder Firmenproxys, wo mehrere legitime Nutzer dieselbe IP-Adresse teilen. API-Key-basiertes Rate Limiting ist für authentifizierte APIs die genauere Wahl, weil jeder Konsument unabhängig von seiner Netzwerkumgebung sein eigenes Kontingent erhält.

Für öffentliche, nicht authentifizierte Endpunkte bleibt IP-basiertes Rate Limiting oft die einzig praktikable Option, sollte dann aber mit großzügigeren Limits kalkuliert werden, um NAT-Effekte abzufedern. Eine bewährte Strategie kombiniert beide Ebenen: ein grobes IP-Limit als erste Verteidigungslinie gegen automatisierten Missbrauch, und ein feineres API-Key- oder Nutzer-Limit für die eigentliche Kontingentsteuerung pro Kunde.

8. Gestaffelte Limits nach Endpunkt und Tarif

Nicht jeder Endpunkt verdient dasselbe Rate Limiting. Ein einfacher GET-Endpunkt, der aus einem Cache bedient wird, verträgt ein deutlich höheres Limit als ein rechenintensiver Such- oder Export-Endpunkt, der bei jedem Aufruf die Datenbank stark belastet. Ein durchdachtes Rate Limiting definiert daher Limits pro Endpunkt-Kategorie statt eines pauschalen globalen Werts für die gesamte API.

Zusätzlich lässt sich Rate Limiting an Tarifstufen koppeln, was bei kommerziellen APIs Standard ist: Ein Free-Tier-Kunde erhält niedrigere Limits als ein Enterprise-Kunde, konfigurierbar über denselben Redis-basierten Mechanismus, nur mit unterschiedlichen Limit- und Refill-Parametern pro Kundenkonto. Diese Flexibilität lässt sich in PHP elegant über eine Konfigurationsklasse abbilden, die anhand der Nutzer- oder API-Key-Metadaten das passende Limit zur Laufzeit auswählt, statt Limits im Code hart zu verdrahten.

9. Rate-Limiting-Algorithmen im direkten Vergleich

Die folgende Tabelle stellt die drei behandelten Algorithmen gegenüber und hilft bei der Entscheidung, welches Rate Limiting für welchen Anwendungsfall passt.

Algorithmus Genauigkeit Burst-Verhalten Empfehlung
Fixed Window Gering Doppel-Burst an Fenstergrenzen Einfache interne APIs ohne kritische Last
Sliding Window Counter Hoch Kein Grenzburst-Effekt Öffentliche APIs mit gleichmäßigem Traffic
Token Bucket Hoch Kontrollierte Bursts erlaubt APIs mit natürlich schwankendem Traffic
Kein Rate Limiting Unbegrenzt Nur für rein interne, vertrauenswürdige Aufrufer

Für die meisten öffentlichen PHP-APIs ist Token Bucket der beste Ausgangspunkt, weil es reale Nutzungsmuster mit gelegentlichen Lastspitzen abbildet, ohne die durchschnittliche Rate zu gefährden. Fixed Window bleibt wegen seiner Einfachheit für interne, unkritische Rate-Limiting-Anwendungsfälle attraktiv.

Mironsoft

PHP-Backend-Entwicklung und API-Härtung

Rate Limiting, das eure API wirklich schützt?

Wir bauen ein Redis-basiertes Rate Limiting nach eurem Traffic-Profil, mit korrekten HTTP-Headern, gestaffelten Limits pro Tarif und Schutz gegen Burst-Angriffe.

Algorithmus-Wahl

Token Bucket, Sliding Window oder Fixed Window passend zum Traffic-Profil

Redis-Integration

Atomare Lua-Skripte für verteiltes Rate Limiting über mehrere Server

Tarif-Kopplung

Gestaffelte Limits pro Kundenkonto ohne hart verdrahtete Werte im Code

10. Zusammenfassung

Rate Limiting in PHP selbst zu implementieren ist keine akademische Übung, sondern eine praktische Notwendigkeit für jede API mit externen Konsumenten. Fixed Window ist der einfachste Einstieg, hat aber Schwächen an Fenstergrenzen. Sliding Window Counter korrigiert diese Schwäche mit moderatem Zusatzaufwand. Token Bucket erlaubt kontrollierte Bursts und passt gut zu natürlich schwankendem Traffic. Redis liefert dabei das nötige gemeinsame Zustandsmanagement über mehrere Server hinweg.

Ebenso wichtig wie der gewählte Algorithmus ist die konsequente Kommunikation über HTTP-Header: 429, Retry-After und die X-RateLimit-Header geben Clients die Information, die sie brauchen, um sich selbstständig innerhalb des Limits zu bewegen. Wer Rate Limiting zusätzlich nach Endpunkt-Last und Tarifstufe staffelt, erreicht ein System, das Infrastruktur schützt, ohne legitime Nutzer unnötig auszubremsen.

Rate Limiting in PHP — Das Wichtigste auf einen Blick

Algorithmus

Token Bucket für Bursts, Sliding Window Counter für Präzision, Fixed Window für einfache interne Fälle.

Redis-Backend

Atomare Increment- und Lua-Skript-Operationen für konsistentes Rate Limiting über mehrere Server.

HTTP-Header

429, Retry-After, X-RateLimit-Limit/Remaining/Reset auf jeder Antwort mitschicken.

Schlüsselwahl

API-Key oder Nutzer-ID statt reiner IP, um NAT-Effekte und geteilte Adressen zu vermeiden.

11. FAQ: Rate Limiting selbst implementieren

1Bester Algorithmus für öffentliche APIs?
Token Bucket, weil es kontrollierte Bursts erlaubt und reale Nutzungsmuster besser abbildet als starre Fenster.
2Warum ist Fixed Window problematisch?
Ein Client kann das Limit an der Fenstergrenze doppelt ausschöpfen, was zu kurzzeitig doppelter Last führt.
3Brauche ich Redis?
Bei mehreren Servern ja, für einen einzelnen Prozess reicht theoretisch auch In-Memory-Zählung.
4Welcher Statuscode bei Ablehnung?
429 Too Many Requests mit Retry-After-Header, der die Wartezeit in Sekunden angibt.
5IP oder API-Key als Schlüssel?
API-Key oder Nutzer-ID ist genauer, weil geteilte NAT-Adressen bei IP-Limitierung mehrere Nutzer gemeinsam treffen.
6Race Conditions bei Token Bucket vermeiden?
Mit einem atomaren Lua-Skript per EVAL, das Lesen, Berechnen und Schreiben in einer Transaktion bündelt.
7Sollten alle Endpunkte gleiche Limits haben?
Nein, rechenintensive Endpunkte brauchen niedrigere Limits als einfache, cachefähige GET-Endpunkte.
8Was bedeutet X-RateLimit-Remaining?
Zeigt verbleibende Requests im aktuellen Fenster, damit Clients proaktiv drosseln können.
9Kann man Limits an Tarife koppeln?
Ja, Standard bei kommerziellen APIs, Limits werden dabei zur Laufzeit anhand des Kundenkontos ausgewählt.
10Reicht ein lokaler Zähler ohne Redis?
Nur für einen einzelnen Prozess, bei mehreren Workern oder Servern führt das zu inkonsistenten, umgangenen Limits.