Zum Inhalt springen

API-Ratenbegrenzung: Algorithmen und Implementierung

Detaillierter Einblick in Token Bucket, Sliding Window und Leaky Bucket, mit verteilten Redis-Implementierungen und praxisnahen Middleware-Mustern.

4 Min. Lesezeit
Diagramm zum Vergleich der Ratenbegrenzungsalgorithmen Token Bucket, Sliding Window und Leaky Bucket

Warum Ratenbegrenzung nicht verhandelbar ist

Jede öffentliche API braucht Ratenbegrenzung. Ohne sie kann ein einzelner Client – ob böswillig oder schlicht fehlerhaft – sämtliche verfügbaren Serverressourcen aufbrauchen und damit die Erfahrung aller anderen Nutzer verschlechtern. Ratenbegrenzung schützt deine Infrastruktur, sorgt für fairen Zugang und gewährleistet ein vorhersehbares Verhalten unter Last.

Die eigentliche Herausforderung besteht darin, für den jeweiligen Anwendungsfall den richtigen Algorithmus zu wählen. Die verschiedenen Algorithmen unterscheiden sich in ihren Kompromissen bei der Burst-Behandlung, dem Speicherverbrauch und der Fairness.

Der Token-Bucket-Algorithmus

Der Token Bucket ist der gängigste Algorithmus zur Ratenbegrenzung. Tokens werden dem Bucket mit einer festen Rate hinzugefügt. Jede Anfrage verbraucht ein Token. Ist der Bucket leer, wird die Anfrage abgelehnt. Der Bucket hat eine maximale Kapazität, wodurch kontrollierte Bursts möglich sind.

tstypescript
class TokenBucket {
  private tokens: number;
  private lastRefill: number;
 
  constructor(
    private readonly capacity: number,
    private readonly refillRate: number // tokens per second
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }
 
  tryConsume(tokens: number = 1): boolean {
    this.refill();
 
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true;
    }
 
    return false;
  }
 
  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    const newTokens = elapsed * this.refillRate;
 
    this.tokens = Math.min(this.capacity, this.tokens + newTokens);
    this.lastRefill = now;
  }
 
  getState(): { tokens: number; capacity: number } {
    this.refill();
    return {
      tokens: Math.floor(this.tokens),
      capacity: this.capacity,
    };
  }
}
 
// Usage
const bucket = new TokenBucket(100, 10); // 100 capacity, 10 tokens/sec
if (bucket.tryConsume()) {
  // Process request
} else {
  // Return 429 Too Many Requests
}

Der Token Bucket erlaubt Bursts bis zur Kapazität des Buckets und hält gleichzeitig eine gleichmäßige Rate über einen längeren Zeitraum ein. Das eignet sich hervorragend für APIs, bei denen Nutzer naturgemäß in Bursts anfragen – etwa wenn das Laden eines Dashboards zehn API-Aufrufe gleichzeitig auslöst.

Der Sliding-Window-Zähler

Der Sliding-Window-Zähler sorgt für eine gleichmäßigere Ratenbegrenzung, allerdings ohne den Burst-Spielraum des Token Buckets. Er erfasst die Anzahl der Anfragen in kleinen Zeitfenstern und interpoliert zwischen ihnen.

tstypescript
// ❌ Fixed window — allows 2x burst at window boundaries
// At 11:59:59 user sends 100 requests (resets at 12:00:00)
// At 12:00:01 user sends another 100 requests
// Result: 200 requests in 2 seconds despite 100/minute limit
 
// ✅ Sliding window — smooth rate limiting across boundaries
class SlidingWindowCounter {
  private windows: Map<string, number> = new Map();
  private readonly windowSize: number; // in milliseconds
 
  constructor(
    private readonly maxRequests: number,
    private readonly windowMs: number
  ) {
    this.windowSize = windowMs;
  }
 
  tryConsume(clientId: string): { allowed: boolean; remaining: number; resetMs: number } {
    const now = Date.now();
    const currentWindow = Math.floor(now / this.windowSize);
    const previousWindow = currentWindow - 1;
 
    const currentKey = `${clientId}:${currentWindow}`;
    const previousKey = `${clientId}:${previousWindow}`;
 
    const currentCount = this.windows.get(currentKey) || 0;
    const previousCount = this.windows.get(previousKey) || 0;
 
    // Weight the previous window by how much of it overlaps
    const elapsedInWindow = (now % this.windowSize) / this.windowSize;
    const weightedCount =
      previousCount * (1 - elapsedInWindow) + currentCount;
 
    if (weightedCount >= this.maxRequests) {
      const resetMs = this.windowSize - (now % this.windowSize);
      return {
        allowed: false,
        remaining: 0,
        resetMs,
      };
    }
 
    this.windows.set(currentKey, currentCount + 1);
    this.cleanup(currentWindow);
 
    return {
      allowed: true,
      remaining: Math.floor(this.maxRequests - weightedCount - 1),
      resetMs: this.windowSize - (now % this.windowSize),
    };
  }
 
  private cleanup(currentWindow: number): void {
    for (const key of this.windows.keys()) {
      const windowNum = parseInt(key.split(":")[1], 10);
      if (windowNum < currentWindow - 1) {
        this.windows.delete(key);
      }
    }
  }
}

Verteilte Ratenbegrenzung mit Redis

Ratenbegrenzer im Arbeitsspeicher versagen in verteilten Systemen, weil jeder Server seinen eigenen Zähler führt. Redis bietet atomare Operationen für einen gemeinsamen Zustand über alle Instanzen hinweg.

tstypescript
import { Redis } from "ioredis";
 
class RedisRateLimiter {
  constructor(private readonly redis: Redis) {}
 
  async slidingWindowLimit(
    key: string,
    maxRequests: number,
    windowSeconds: number
  ): Promise<{ allowed: boolean; remaining: number; retryAfter: number }> {
    const now = Date.now();
    const windowMs = windowSeconds * 1000;
    const windowStart = now - windowMs;
 
    const pipeline = this.redis.pipeline();
 
    // Remove expired entries
    pipeline.zremrangebyscore(key, "-inf", windowStart);
    // Add current request
    pipeline.zadd(key, now, `${now}:${Math.random()}`);
    // Count requests in window
    pipeline.zcard(key);
    // Set TTL to auto-cleanup
    pipeline.expire(key, windowSeconds + 1);
 
    const results = await pipeline.exec();
    const requestCount = results?.[2]?.[1] as number;
 
    if (requestCount > maxRequests) {
      // Remove the request we just added
      await this.redis.zremrangebyscore(key, now, now);
 
      // Calculate retry-after from oldest request in window
      const oldest = await this.redis.zrange(key, 0, 0, "WITHSCORES");
      const oldestTime = oldest.length > 1 ? parseInt(oldest[1], 10) : now;
      const retryAfterMs = oldestTime + windowMs - now;
 
      return {
        allowed: false,
        remaining: 0,
        retryAfter: Math.ceil(retryAfterMs / 1000),
      };
    }
 
    return {
      allowed: true,
      remaining: maxRequests - requestCount,
      retryAfter: 0,
    };
  }
}

Middleware-Implementierung mit Express

Kapsle den Ratenbegrenzer in eine Middleware, die Antwort-Header, Fehlerantworten und die Identifizierung des Clients übernimmt.

tstypescript
import { Request, Response, NextFunction } from "express";
 
interface RateLimitConfig {
  maxRequests: number;
  windowSeconds: number;
  keyGenerator: (req: Request) => string;
  skip?: (req: Request) => boolean;
  onLimitReached?: (req: Request) => void;
}
 
function createRateLimitMiddleware(
  limiter: RedisRateLimiter,
  config: RateLimitConfig
) {
  return async (
    req: Request,
    res: Response,
    next: NextFunction
  ): Promise<void> => {
    if (config.skip?.(req)) {
      next();
      return;
    }
 
    const key = `ratelimit:${config.keyGenerator(req)}`;
    const result = await limiter.slidingWindowLimit(
      key,
      config.maxRequests,
      config.windowSeconds
    );
 
    // Always set rate limit headers
    res.setHeader("X-RateLimit-Limit", config.maxRequests);
    res.setHeader("X-RateLimit-Remaining", result.remaining);
    res.setHeader(
      "X-RateLimit-Reset",
      Math.ceil(Date.now() / 1000) + config.windowSeconds
    );
 
    if (!result.allowed) {
      res.setHeader("Retry-After", result.retryAfter);
      config.onLimitReached?.(req);
 
      res.status(429).json({
        error: "Too Many Requests",
        message: `Rate limit exceeded. Try again in ${result.retryAfter} seconds.`,
        retryAfter: result.retryAfter,
      });
      return;
    }
 
    next();
  };
}
 
// Usage with different tiers
const apiLimiter = createRateLimitMiddleware(limiter, {
  maxRequests: 100,
  windowSeconds: 60,
  keyGenerator: (req) => req.headers["x-api-key"] as string || req.ip || "unknown",
  skip: (req) => req.path === "/health",
  onLimitReached: (req) => {
    console.warn(`Rate limit hit: ${req.ip} on ${req.path}`);
  },
});
 
const authLimiter = createRateLimitMiddleware(limiter, {
  maxRequests: 5,
  windowSeconds: 300,
  keyGenerator: (req) => `auth:${req.ip}`,
});

Gestaffelte Ratenbegrenzung

Unterschiedliche API-Konsumenten verdienen unterschiedliche Limits. Nutzer der kostenlosen Stufe erhalten niedrigere Limits, Premium-Nutzer höhere, und interne Dienste benötigen noch höhere oder sogar unbegrenzte Limits.

tstypescript
interface RateLimitTier {
  name: string;
  requestsPerMinute: number;
  requestsPerDay: number;
  burstCapacity: number;
}
 
const tiers: Record<string, RateLimitTier> = {
  free: {
    name: "Free",
    requestsPerMinute: 30,
    requestsPerDay: 1000,
    burstCapacity: 10,
  },
  pro: {
    name: "Professional",
    requestsPerMinute: 300,
    requestsPerDay: 50000,
    burstCapacity: 50,
  },
  enterprise: {
    name: "Enterprise",
    requestsPerMinute: 3000,
    requestsPerDay: 500000,
    burstCapacity: 200,
  },
};
 
async function getTierForApiKey(apiKey: string): Promise<RateLimitTier> {
  const cached = await redis.get(`tier:${apiKey}`);
  if (cached) return JSON.parse(cached);
 
  const tier = await db.query(
    "SELECT tier FROM api_keys WHERE key_hash = $1",
    [hashApiKey(apiKey)]
  );
 
  const result = tiers[tier?.tier || "free"];
  await redis.set(`tier:${apiKey}`, JSON.stringify(result), "EX", 300);
  return result;
}

Wichtigste Erkenntnisse

Ratenbegrenzung schützt deine API vor Missbrauch und sorgt für fairen Zugang für alle Konsumenten. Wähle den Token Bucket für APIs, die von Burst-Kontingenten profitieren, und den Sliding-Window-Zähler für eine gleichmäßigere, besser vorhersehbare Begrenzung. Nutze Redis für verteilte Ratenbegrenzung, damit sich alle Anwendungsinstanzen dieselben Zähler teilen.

Gib stets die passenden HTTP-Header zurück (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After), damit sich Clients selbst regulieren können. Implementiere gestaffelte Ratenlimits passend zu deinem Preismodell – kostenlose Nutzer erhalten niedrigere Limits, zahlende Kunden höhere. Trenne die Ratenlimits für sensible Endpunkte wie die Authentifizierung, die deutlich strengere Kontrollen benötigen als allgemeine API-Endpunkte.

Der beste Ratenbegrenzer ist der, den deine API-Konsumenten nie bemerken, weil die Limits großzügig genug für die normale Nutzung und zugleich streng genug sind, um vor Missbrauch zu schützen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX