Zum Inhalt springen

Rate-Limiting-Muster für APIs, die skalieren

Token Buckets, Sliding Windows und verteiltes Rate Limiting — praktische Muster, um deine API zu schützen, ohne legitime Nutzer auszubremsen.

3 Min. Lesezeit
Visualisierung des Token-Bucket-Algorithmus, die den Request-Fluss durch den Rate Limiter zeigt

Jede öffentliche API braucht Rate Limiting. Ohne es kann ein einzelner sich falsch verhaltender Client deine Datenbank sättigen, dein Compute-Budget aufbrauchen oder den gesamten Dienst lahmlegen. Aber naives Rate Limiting — ein simpler Zähler, der jede Minute zurückgesetzt wird — erzeugt Klippeneffekte und bestraft legitimen Burst-Traffic.

Fixed Window: einfach, aber fehlerhaft

Der einfachste Ansatz: Requests pro Zeitfenster zählen. Überschreitet der Zähler das Limit, wird abgelehnt.

tstypescript
// ❌ Fixed window — boundary burst problem
async function fixedWindowLimit(
  key: string,
  limit: number,
  windowMs: number,
): Promise<boolean> {
  const window = Math.floor(Date.now() / windowMs);
  const counterKey = `ratelimit:${key}:${window}`;
 
  const count = await redis.incr(counterKey);
  if (count === 1) await redis.pexpire(counterKey, windowMs);
 
  return count <= limit;
}
 
// Problem: at 11:59:59, a client sends 100 requests (under the limit).
// At 12:00:01, they send another 100. Both pass — 200 requests in 2 seconds.

Das Grenzproblem bedeutet, dass ein Client sein effektives Rate Limit verdoppeln kann, indem er Requests über Fenstergrenzen hinweg timt.

Sliding-Window-Log

Zeichne einzelne Request-Zeitstempel auf und zähle, wie viele in das gleitende Fenster fallen. Genauer, verbraucht aber mehr Speicher.

tstypescript
async function slidingWindowLog(
  key: string,
  limit: number,
  windowMs: number,
): Promise<boolean> {
  const now = Date.now();
  const windowStart = now - windowMs;
  const sortedSetKey = `ratelimit:${key}`;
 
  // Remove expired entries and add current request atomically
  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(sortedSetKey, 0, windowStart);
  pipeline.zadd(sortedSetKey, now, `${now}:${Math.random()}`);
  pipeline.zcard(sortedSetKey);
  pipeline.pexpire(sortedSetKey, windowMs);
 
  const results = await pipeline.exec();
  const count = results![2][1] as number;
 
  return count <= limit;
}

Das eliminiert das Grenzproblem, speichert aber einen Eintrag pro Request. Bei Endpoints mit hohem Traffic ist das eine Menge Redis-Speicher.

Token Bucket: die beste Standardwahl

Token Buckets erlauben Bursts, während sie eine durchschnittliche Rate durchsetzen. Tokens werden mit einer konstanten Rate hinzugefügt; jeder Request verbraucht ein Token. Ist der Bucket leer, werden Requests abgelehnt.

tstypescript
// ✅ Token bucket — allows bursts, enforces average rate
async function tokenBucket(
  key: string,
  capacity: number,
  refillRate: number, // tokens per second
): Promise<{ allowed: boolean; remaining: number }> {
  const bucketKey = `bucket:${key}`;
  const now = Date.now();
 
  // Lua script for atomicity
  const script = `
    local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
    local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
    local lastRefill = tonumber(bucket[2]) or tonumber(ARGV[3])
    local capacity = tonumber(ARGV[1])
    local refillRate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
    
    local elapsed = (now - lastRefill) / 1000
    tokens = math.min(capacity, tokens + elapsed * refillRate)
    
    local allowed = 0
    if tokens >= 1 then
      tokens = tokens - 1
      allowed = 1
    end
    
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
    redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / refillRate) * 1000)
    
    return {allowed, math.floor(tokens)}
  `;
 
  const [allowed, remaining] = (await redis.eval(
    script,
    1,
    bucketKey,
    capacity,
    refillRate,
    now,
  )) as [number, number];
 
  return { allowed: allowed === 1, remaining };
}

Ein Bucket mit Kapazität 100 und einer Auffüllrate von 10/Sekunde erlaubt einen Burst von 100 Requests und hält danach 10/Sekunde. Das entspricht realen Nutzungsmustern besser als Fixed Windows.

Eine Strategie wählen

AlgorithmusBurst-HandlingSpeicherGenauigkeitKomplexität
Fixed WindowSchlecht (Grenzproblem)NiedrigNiedrigEinfach
Sliding-Window-LogGutHochHochMittel
Sliding-Window-CounterGutNiedrigMittelMittel
Token BucketAusgezeichnetNiedrigHochMittel

Token Bucket ist für die meisten APIs die richtige Standardwahl. Fixed Window ist für interne Services akzeptabel, bei denen Präzision keine Rolle spielt.

Response-Header und 429-Handling

Kommuniziere den Rate-Limit-Status immer über Header. Wohlerzogene Clients nutzen diese, um sich selbst zu drosseln.

tstypescript
function rateLimitResponse(
  res: Response,
  limit: number,
  remaining: number,
  resetAt: number,
) {
  res.setHeader("X-RateLimit-Limit", limit);
  res.setHeader("X-RateLimit-Remaining", Math.max(0, remaining));
  res.setHeader("X-RateLimit-Reset", Math.ceil(resetAt / 1000));
 
  if (remaining < 0) {
    res.setHeader("Retry-After", Math.ceil((resetAt - Date.now()) / 1000));
    return res.status(429).json({
      error: {
        code: "RATE_LIMITED",
        message:
          "Too many requests. Please retry after the Retry-After period.",
      },
    });
  }
}

Gestaffelte Rate Limits

Unterschiedliche Endpoints haben unterschiedliche Kostenprofile. Ein Such-Endpoint, der einen Volltextindex trifft, ist teurer als ein Status-Check.

tstypescript
const rateLimits = {
  "GET /api/status": { capacity: 1000, refillRate: 100 },
  "GET /api/search": { capacity: 20, refillRate: 5 },
  "POST /api/orders": { capacity: 10, refillRate: 2 },
  "POST /api/auth/login": { capacity: 5, refillRate: 1 },
} as const;
 
function getRateLimit(method: string, path: string) {
  const key = `${method} ${path}`;
  return rateLimits[key] ?? { capacity: 100, refillRate: 20 };
}

Authentifizierungs-Endpoints verdienen die strengsten Limits — sie sind das primäre Ziel für Brute-Force-Angriffe.

Die wichtigsten Punkte

  1. Token Bucket ist die beste Standardwahl — es handhabt Bursts auf natürliche Weise, während es durchschnittliche Raten durchsetzt
  2. Fixed Windows haben ein Grenzproblem — Clients können ihre effektive Rate an Fenstergrenzen verdoppeln
  3. Nutze Lua-Skripte in Redis für atomare mehrstufige Rate-Limiting-Operationen
  4. Gib immer Rate-Limit-Header zurück — X-RateLimit-Remaining und Retry-After helfen Clients, sich selbst zu drosseln
  5. Staffle Rate Limits nach Endpoint-Kosten — teure Operationen bekommen strengere Limits
  6. Authentifizierungs-Endpoints brauchen die strengsten Limits — sie sind das primäre Brute-Force-Ziel
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX