De Ce Să Îl Implementez Singur?
Rate limiting-ul este unul dintre acele lucruri pe care orice developer backend le folosește, dar puțini le implementează de la zero. Am vrut să înțeleg mecanica reală - nu API-ul librăriei, ci algoritmul, implicațiile de stocare, convențiile header-elor și modul subtil de eșec care face un algoritm mai corect decât celălalt.
Rezultatul este un proiect TypeScript + Express cu două strategii de rate limiting interschimbabile, două backend-uri de stocare pluggable, configurare per client/per endpoint și o suită de teste care demonstrează direct problema burst-ului la granița ferestrei.
Arhitectura
Pipeline-ul de middleware este simplu: Logger → Auth → Rate Limiter → Handler. Interesant este modul în care rate limiter-ul este conectat.
Două rute demonstrează diferența:
GET /foofolosește strategia Fixed WindowGET /barfolosește strategia Sliding Window Log
Ambele partajează aceeași interfață IRateLimitStrategy cu o singură metodă: isRateLimited(clientId, endpoint, maxRequests, windowMs). Asta înseamnă că schimbarea algoritmului este o modificare de un singur cuvânt în definiția rutei: rateLimit('fixed') vs rateLimit('sliding').
Fixed Window: Simplu, Rapid, Imperfect
FixedWindowRateLimiter împarte timpul în intervale discrete. Stochează două chei per pereche client/endpoint: un contor și un ID de fereastră.
Implementarea are abia 40 de linii:
- Calculează
currentWindow = Math.floor(now / windowMs) - Compară cu ID-ul ferestrei stocate
- Dacă este o fereastră nouă, resetează contorul
- Dacă
counter >= maxRequests, returneazătrue(rate limited) - Altfel, incrementează și returnează
false
Performanța este excelentă - timp O(1) și spațiu O(1) per client. Dar există o problemă.
Burst-ul la Granița Ferestrei
Acesta este bug-ul pe care am vrut să îl văd cu ochii mei. Am scris un test care setează timpul sistemului la 100ms înainte de granița unei ferestre, trimite maxRequests request-uri, apoi avansează timpul cu 200ms (trecând granița) și trimite încă unul.
Fixed window limiter-ul spune: "Fereastră nouă! Contor resetat! Request permis."
Asta înseamnă că un client poate trimite până la de 2x limita intenționată într-un interval de 2 secunde, sincronizând request-urile în jurul graniței. În testul meu cu maxRequests=10 și windowMs=60000, asta înseamnă 20 de request-uri în mai puțin de o secundă. Testul trece, confirmând vulnerabilitatea.
Sliding Window Log: Precis, dar mai Consumator
SlidingWindowRateLimiter stochează un array de timestamp-uri pentru fiecare pereche client/endpoint. La fiecare request:
- Preia timestamp-urile stocate
- Filtrează pe cele mai vechi de
now - windowMs - Dacă
timestamps.length >= maxRequests, respinge - Altfel, adaugă
nowși actualizează stocarea
Același test de granița ferestrei pe care l-am rulat împotriva fixed window? Sliding window blochează corect request-ul. Acele 10 timestamp-uri de acum 100ms sunt încă în cadrul ferestrei glisante de 60 de secunde. Niciun burst, nicio exploatare a sistemului.
Compromisul este memoria: în loc să stochezi un singur contor, stochezi până la maxRequests timestamp-uri per client per endpoint. Pentru 10.000 de clienți cu o limită de 100 de request-uri, asta înseamnă până la 1 milion de timestamp-uri în memorie. În practică, Redis gestionează asta cu ușurință, dar merită înțeles.
Testul de Comparare a Strategiilor
Testul meu preferat din suită este compararea strategiilor. Creează atât un FixedWindowRateLimiter, cât și un SlidingWindowRateLimiter, rulează aceeași secvență împotriva ambilor și confirmă comportamentele lor diferite:
Fixed window permite request-ul (a început o fereastră nouă), în timp ce sliding window îl blochează în continuare (toate request-urile sunt în cadrul ferestrei glisante). A vedea aceste două aserțiuni alăturat a făcut diferența algoritmică tangibilă pentru mine.
Stocare Pluggable
Ambele strategii acceptă un IRateLimitStorage în constructorul lor. Am implementat două backend-uri:
MemoryStorage - un Map<string, number | number[]> care stochează fie contoare (fixed window), fie array-uri de timestamp-uri (sliding window). Rapid, latență zero, dar pierdut la repornire și nu poate fi partajat între instanțe.
RedisStorage - împachetează clientul Redis pentru Node.js. Suportă aceeași interfață, dar persistă datele și partajează starea între procese. Am adăugat setWithExpiry pentru curățarea automată a cheilor și gestionarea corectă a ciclului de viață al conexiunii cu ensureConnection().
Interfața de stocare este deliberat minimală: get, set, increment, decrement, reset. Orice backend care poate face aceste cinci lucruri poate fi conectat - PostgreSQL, DynamoDB, SQLite, orice.
Configurare Per Client, Per Endpoint
Limitele de rate nu sunt universale. Fișierul de configurare clients.ts definește limite per client, per endpoint:
client-1: 10 req/min pe/foo, 5 req/min pe/barclient-2: 20 req/min pe/foo, 10 req/min pe/bar
Middleware-ul extrage ruta din req.baseUrl, caută configurația clientului și transmite limitele specifice whichever limiter-ului activ. Acesta reflectă modul în care platformele API reale oferă niveluri diferite.
Header-ele de Răspuns
Fiecare răspuns include patru header-e de rate limit: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Window-Ms și X-RateLimit-Strategy. Când este aplicat rate limiting-ul, răspunsul include și un header Retry-After. Acestea urmează draft-ul IETF și oferă developer-ilor client tot ce le trebuie pentru a implementa backoff.
Când Să Folosești Care
| Scenariu | Recomandare | De ce |
|---|---|---|
| API-uri publice cu trafic ridicat | Fixed Window | Simplitate și operații O(1) |
| API-uri financiare / de conformitate | Sliding Window | Precizia contează mai mult decât memoria |
| Platforme multi-tenant | Oricare + configurare per client | Lasă nivelul de business să determine algoritmul |
Codul sursă complet cu teste: github.com/ionutn0301/rate-limit-project