Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· Backend  · 6 minuten Lesezeit

Zweischichtiges Rate Limiting mit Traefik und express-rate-limit mit Redis

Ohne Rate Limiting kann ein einzelnes Skript den AI-Provider-Key innerhalb von Minuten leerlaufen lassen. Die Lösung sind zwei unabhängige Schichten: Traefik begrenzt auf IP-Ebene, express-rate-limit mit Redis-Backing begrenzt pro authentifiziertem Nutzer. Warum beide Schichten nötig sind und wie der TypeScript-Tücke beim Redis-Adapter umgangen wird.

Ohne Rate Limiting kann ein einzelnes Skript den AI-Provider-Key innerhalb von Minuten leerlaufen lassen. Die Lösung sind zwei unabhängige Schichten: Traefik begrenzt auf IP-Ebene, express-rate-limit mit Redis-Backing begrenzt pro authentifiziertem Nutzer. Warum beide Schichten nötig sind und wie der TypeScript-Tücke beim Redis-Adapter umgangen wird.

Inhalt

Warum ein einzelner API-Key in Minuten leerläuft

Local Insight hat zwei teure Endpunkte: POST /ingest und POST /query. Jeder Aufruf triggert mindestens einen Embedding-API-Call. Bei OpenAI kostet das Geld, bei Ollama kostet es GPU-Zeit.

Vor dem Rate Limiting war die Situation einfach: Wer die Extension-API-URL kannte und ein gültiges Token hatte, konnte beliebig viele Anfragen senden. Ein Skript mit einer einfachen for-Schleife hätte das Ingest-Limit gesprengt oder den Query-Endpunkt in Dauerlast versetzt.

Für ein System, das in Phase 4 auf Quota-basiertes Billing umstellt, ist das ein unmittelbares Problem. Quota-Limits im Code sind wertlos, wenn ein Nutzer die zugrunde liegende API unkontrolliert aufrufen kann.

Warum zwei Schichten

Ein Rate Limiter allein reicht nicht, weil die beiden Schichten unterschiedliche Angreifer stoppen.

Schicht 1 auf Traefik-Ebene arbeitet auf IP-Adresse. Sie stoppt Bots und einfache Angriffe, bevor der Request Node.js überhaupt erreicht. Kein JavaScript-Code wird ausgeführt, kein Datenbankaufruf passiert. Das ist cheap und effektiv gegen Massenbeschuss.

Schicht 2 auf Express-Ebene arbeitet auf authentifizierter userId. Zwei Nutzer hinter demselben NAT-Gateway teilen eine IP-Adresse. Schicht 1 würde sie zusammen limitieren, was zu false positives führt. Schicht 2 kennt den JWT-Inhalt und kann präzise nach Account limitieren.

Der andere Fall: ein einzelner Nutzer mit mehreren IP-Adressen (VPN-Wechsel, Mobile + WiFi) würde Schicht 1 umgehen. Schicht 2 hält ihn trotzdem, weil der JWT immer dieselbe userId enthält.

Beide Schichten ergänzen sich. Die Kombination schließt die Lücken, die jede Schicht allein lässt.

Schicht 1 mit Traefik RateLimit-Middleware

Traefik bietet eine RateLimit-Middleware, die als Docker-Label auf dem Service konfiguriert wird:

# docker-compose.override.yml
backend:
  labels:
    - "traefik.http.middlewares.backend-ratelimit.ratelimit.average=60"
    - "traefik.http.middlewares.backend-ratelimit.ratelimit.burst=20"
    - "traefik.http.routers.backend.middlewares=backend-ratelimit"

average=60 bedeutet 60 Anfragen pro Minute. burst=20 erlaubt kurze Spitzen bis 20 Anfragen, bevor der Token-Bucket-Algorithmus greift. Traefik zählt pro IP-Adresse des Clients.

Das ist Layer 1. Es kostet keine Node.js-Laufzeit.

Schicht 2 mit express-rate-limit und Redis-Backing

Für die per-User-Limits kommt express-rate-limit mit rate-limit-redis als Store zum Einsatz.

Der In-Memory-Store von express-rate-limit ist der falsche Ansatz für diesen Stack. Wenn das Backend auf mehrere Instanzen skaliert, hat jede Instanz ihren eigenen Zähler. Nutzer könnten die Limits durch parallele Requests an verschiedene Instanzen umgehen. Redis macht die Zähler global.

import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";

const redisClient = createClient({
  url: process.env.REDIS_URL ?? "redis://redis:6379",
});
await redisClient.connect();

function makeRateLimiter(max: number, windowMs: number) {
  return rateLimit({
    windowMs,
    max,
    // Key by authenticated userId, fall back to IP before auth middleware
    keyGenerator: (req) => (req as AuthenticatedRequest).auth?.userId ?? req.ip ?? "unknown",
    standardHeaders: true,
    legacyHeaders: false,
    store: new RedisStore({
      // TypeScript requires explicit sendCommand signature
      sendCommand: (command: string, ...args: string[]) =>
        redisClient.sendCommand([command, ...args]) as Promise<string | number>,
    }),
  });
}

const ingestLimiter = makeRateLimiter(
  parseInt(process.env.RATE_LIMIT_INGEST ?? "30", 10),
  60_000
);
const queryLimiter = makeRateLimiter(
  parseInt(process.env.RATE_LIMIT_QUERY ?? "20", 10),
  60_000
);

Die Limits sind über Umgebungsvariablen konfigurierbar. Das erlaubt es, sie ohne Deployment anzupassen, sobald echte Nutzungsdaten vorliegen.

Die TypeScript-Tücke beim Redis-Adapter

rate-limit-redis erwartet einen sendCommand-Callback mit einer spezifischen Signatur. Das redis-Paket von Node.js liefert sendCommand mit einer anderen Typdefinition zurück.

Das erste, naheliegende Muster funktioniert nicht:

// Error: type spread mismatch between redis and rate-limit-redis
sendCommand: (...args: string[]) => redisClient.sendCommand(args),

Der Fehler kommt, weil redisClient.sendCommand Promise<unknown> zurückgibt, aber rate-limit-redis Promise<string | number> erwartet. Außerdem kollidiert der Spread-Parameter mit den Typdefinitionen.

Die funktionierende Signatur:

// Explicit first arg avoids spread type mismatch, cast resolves Promise<unknown>
sendCommand: (command: string, ...args: string[]) =>
  redisClient.sendCommand([command, ...args]) as Promise<string | number>,

command wird als eigenes string-Argument herausgezogen, dann mit ...args wieder zusammengesetzt. Der Cast auf Promise<string | number> ist sicher, weil Redis-Kommandos im Rate-Limiting-Kontext (INCRBY, GET, TTL) immer Zahlen oder Strings zurückgeben.

Reihenfolge der Middlewares

Die Rate-Limiter werden vor den Route-Handlern registriert:

// Apply rate limiters before route handlers
app.post("/ingest", ingestLimiter, authMiddleware, ingestHandler);
app.post("/query", queryLimiter, authMiddleware, queryHandler);

Das ist bewusst. Der Rate-Limiter läuft vor authMiddleware, weil authMiddleware einen JWT-Aufruf machen kann. Ohne Vorab-Limitierung wäre der Auth-Check selbst angreifbar.

Innerhalb des Rate-Limiters greift der keyGenerator auf req.auth?.userId zurück. Falls auth noch nicht gesetzt ist (weil authMiddleware noch nicht gelaufen ist), fällt er auf req.ip zurück. Das ist der korrekte Fallback: Vor der Authentifizierung ist IP das beste verfügbare Signal.

Was die standardHeaders bringen

standardHeaders: true bedeutet: Bei einer 429-Antwort sendet Express die Standard-RateLimit-Header:

RateLimit-Limit: 30
RateLimit-Remaining: 0
RateLimit-Reset: 1717430460

Das gibt Clients und der Extension die Möglichkeit, Retry-Logik korrekt zu implementieren: warte bis RateLimit-Reset, dann sende erneut. Ohne diese Header müssen Clients blind zurückwarten.

Vorbereitung für Phase 4

Die per-User-Zähler in Redis sind die Grundlage für das Quota-Enforcement in Phase 4. Wenn ein Free-Tier-Nutzer 50 Ingest-Calls pro Monat hat, brauche ich einen persistenten Zähler pro Account pro Abrechnungszeitraum.

Die Rate-Limiting-Schicht zeigt das Muster: Redis-Key nach userId, Zähler inkrementieren, gegen Limit prüfen. Phase 4 macht dasselbe mit anderen Fenstern (Monat statt Minute) und anderen Konsequenzen (Upgrade-Hinweis statt 429).

Die Infrastruktur ist bereit. Die Zählerlogik muss nur auf einen anderen Zeitraum umgestellt werden.

Zweischichtiges Rate Limiting: Traefik filtert nach IP auf Layer 1, Express filtert nach userId auf Layer 2, Redis speichert alle Zähler global

Das Diagramm zeigt den Request-Pfad durch beide Limitierungsschichten. Traefik prüft den IP-Token-Bucket bevor Node.js startet. Express prüft den Redis-Zähler nach Schlüssel userId. Beide Schichten können unabhängig voneinander 429 zurückgeben.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis (dieser Artikel)
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: Artikel lesen

Du planst Quota-Enforcement für dein SaaS-Backend und fragst dich, wie Rate Limiting und Billing zusammenspielen? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
DSGVO Art. 17 korrekt implementieren mit Promise.allSettled und Export-Batching

DSGVO Art. 17 korrekt implementieren mit Promise.allSettled und Export-Batching

Ein sequenzielles await-chain im DELETE-Account-Endpoint bedeutet: wenn Qdrant fehlschlägt, bleiben S3-Daten und die Zitadel-Identität erhalten. Das ist ein DSGVO-Art.-17-Risiko. Die Lösung ist Promise.allSettled mit strukturierter Fehlerrückgabe. Gleichzeitig: wie ein OOM-Crash im Export-Endpoint durch Batching verhindert wird.

Event-Driven Ingestion mit BullMQ und Redis

Event-Driven Ingestion mit BullMQ und Redis

POST /ingest blockierte die Extension, bis Embedding und Qdrant-Upsert fertig waren. Mit BullMQ und Redis wird der Ingest asynchron: 202 sofort, Verarbeitung im Hintergrund, Statusabfrage über GET /captures/:id/status.