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 /foo folosește strategia Fixed Window
  • GET /bar foloseș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:

  1. Calculează currentWindow = Math.floor(now / windowMs)
  2. Compară cu ID-ul ferestrei stocate
  3. Dacă este o fereastră nouă, resetează contorul
  4. Dacă counter >= maxRequests, returnează true (rate limited)
  5. 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:

  1. Preia timestamp-urile stocate
  2. Filtrează pe cele mai vechi de now - windowMs
  3. Dacă timestamps.length >= maxRequests, respinge
  4. 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 /bar
  • client-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

ScenariuRecomandareDe ce
API-uri publice cu trafic ridicatFixed WindowSimplitate și operații O(1)
API-uri financiare / de conformitateSliding WindowPrecizia contează mai mult decât memoria
Platforme multi-tenantOricare + configurare per clientLasă nivelul de business să determine algoritmul

Codul sursă complet cu teste: github.com/ionutn0301/rate-limit-project

Înapoi la Articole