Skip to content
Rate limiting: la margine, în gateway sau în aplicație? Trei straturi, trei treburi
← ← Înapoi la Idei Cloud

Rate limiting: la margine, în gateway sau în aplicație? Trei straturi, trei treburi

„Adaugă rate limiting” e un tichet de un rând care ascunde trei probleme diferite. Cineva bate ruta de login de la un singur IP: ăla e abuz, și vrei să fie oprit înainte să te coste compute. Un partener de integrare apelează API-ul prea repede pentru planul lui: aia e echitate, și vrei să-l încetinești și să-i spui de ce. Un utilizator încearcă să plaseze o mie de comenzi pe minut: aia e o regulă de business, și vrei ca aplicația să spună nu într-un fel pe care produsul îl înțelege.

Fiecare problemă are un strat unde e ieftin și corect de rezolvat, și un strat unde e scump sau greșit. Așa am împărțit-o pentru un API public pe AWS, cu cifre, cod și singurul loc unde, intenționat, nu facem rate limiting.

Cele trei straturi

client margine · WAFregulă pe rată · per IP2.000 / 5 min · 100 pe /authblochează înainte de orice computecunoaște IP-uri, nu utilizatoritreaba: abuz gateway · API Gatewayusage plan per cheie de APIburst 50 · constant 10 / s429 cu header-e, fără codcunoaște clientul, nu business-ultreaba: echitate între clienți aplicațiatoken bucket în DynamoDBper utilizator · per tenant · per plan429 + Retry-After + un mesajcunoaște regula de businesstreaba: limite de business Fiecare strat oprește ce cel dinainte nu poate vedea. Niciunul n-ar trebui să facă treaba celorlalți.

Marginea vede IP-uri, căi și header-e, și nimic despre cine e utilizatorul. E cel mai ieftin loc în care să arunci un request, pentru că se întâmplă înainte de orice compute pe care îl plătești, și cel mai grosier, pentru că toți cei din spatele unui NAT corporativ împart un IP. Treaba ei e abuzul: credential stuffing, scrapere, un client configurat greșit într-o buclă de retry.

Gateway-ul vede o cheie de API, dacă emiți așa ceva, și poate impune o rată per cheie. Știe ce client apelează, nu ce utilizator, și nici ce înseamnă request-ul. Treaba lui e echitatea între integratori: scriptul scăpat de sub control al nimănui nu degradează API-ul pentru toți ceilalți.

Aplicația vede utilizatorul autentificat, planul lui, tenant-ul din care face parte și ce încearcă să facă request-ul. E singurul strat care poate spune „ai plasat 50 de comenzi în ora asta, planul tău permite 50, încearcă din nou la 15:00”. Treaba ei sunt limitele de business, și e singurul strat care poate întoarce o eroare pe care produsul o poate explica.

Stratul 1: regula WAF bazată pe rată

Am tratat configurația WAF separat; regula de rată e partea din ea care a oprit atacuri reale. Două reguli, pentru că o singură limită pentru tot site-ul e greșită:

// 2.000 de request-uri per 5 minute de la un IP, pe tot
{ name: 'rate-all', priority: 40,
  statement: { rateBasedStatement: { limit: 2000, evaluationWindowSec: 300, aggregateKeyType: 'IP' } },
  action: { block: { customResponse: { responseCode: 429 } } } },

// 100 per 5 minute pe rutele de autentificare, unde un „utilizator” face poate 5
{ name: 'rate-auth', priority: 41,
  statement: { rateBasedStatement: { limit: 100, evaluationWindowSec: 300, aggregateKeyType: 'IP',
    scopeDownStatement: { byteMatchStatement: { fieldToMatch: { uriPath: {} }, positionalConstraint: 'STARTS_WITH', searchString: '/api/auth/', textTransformations: [{ priority: 0, type: 'LOWERCASE' }] } } } },
  action: { block: { customResponse: { responseCode: 429 } } } },

Două detalii. Răspunsul custom face blocajul un 429 în loc de 403-ul implicit al WAF, ca clienții care înțeleg rate limiting-ul să se comporte corect. Iar fereastra e de cinci minute cu limită de 100 pe auth, de care un utilizator real nu se apropie niciodată și pe care un script de credential stuffing o atinge în primele zece secunde. În șase luni regula asta a blocat trei astfel de încercări și un scraper. Costă $1 pe lună.

Ce nu poate face: să distingă o sută de utilizatori din spatele unui IP de birou de un singur atacator. Când toată compania unui client a fost blocată de la login pentru că au sosit toți la 9:00, rezolvarea n-a fost ridicarea limitei; a fost adăugarea unui scope-down care exclude plaja lor cunoscută de egress, ceea ce e o schimbare de două linii și o conversație cu echipa lor de IT.

Stratul 2: usage plans în API Gateway, și de ce nu le folosim

Dacă API-ul tău stă în spatele API Gateway și emiți chei către integratori, usage plans sunt unealta potrivită: o rată de burst și una constantă per cheie, impuse de gateway, întorcând 429 fără cod scris. E ce am folosi pentru un produs de API public cu integratori plătitori pe niveluri.

Noi n-avem forma asta. API-ul nostru e apelat de propriile front-end-uri și de o mână de parteneri, și rulează pe App Runner, nu în spatele API Gateway. Să adăugăm un gateway doar pentru rate limiting ar adăuga un hop, un cost ($3,50 per milion de request-uri, mai mult decât WAF-ul) și un timeout de 29 de secunde. Așa că treaba „echitate între clienți” s-a mutat în stratul aplicației, indexată după identitatea partenerului, nu după o cheie de API. Dacă am avea cincizeci de integratori în loc de cinci, aritmetica s-ar inversa și am pune gateway-ul.

Stratul 3: aplicația

Limiter-ul din aplicație e indexat după ce e regula de business: utilizatorul, tenant-ul, partenerul, uneori resursa. E un token bucket în DynamoDB, pentru că e un store pe care îl avem deja, e atomic și e ieftin la rata noastră de request-uri. Un item per cheie, reumplut la citire:

// lib/rate-limit.ts
export async function take(key: string, plan: { capacity: number; refillPerSec: number }): Promise<Allow | Deny> {
  const now = Date.now() / 1000;
  const res = await ddb.update({
    TableName: 'rate-limits', Key: { pk: key },
    // reumple până la capacitate în funcție de timpul scurs, apoi ia un token
    UpdateExpression: 'SET tokens = :cap - :one, updatedAt = :now',
    ConditionExpression: 'attribute_not_exists(pk) OR (tokens + (:now - updatedAt) * :refill) >= :one',
    ExpressionAttributeValues: { ':cap': plan.capacity, ':one': 1, ':now': now, ':refill': plan.refillPerSec },
    ReturnValues: 'ALL_NEW',
  }).catch(e => e.name === 'ConditionalCheckFailedException' ? null : Promise.reject(e));
  if (!res) return { allowed: false, retryAfterSec: Math.ceil(1 / plan.refillPerSec) };
  return { allowed: true, remaining: res.Attributes.tokens };
}

Versiunea reală e cu câteva linii mai lungă, pentru că expresiile de update din DynamoDB nu pot face toată aritmetica de reumplere într-o singură instrucțiune fără un min(), deci e un citește-calculează-scrie-condiționat cu retry la conflict. Ideea e forma: o operație atomică per request, fără Redis de rulat, item-urile expiră cu TTL, deci cheile inactive nu costă nimic.

Răspunsul la refuz e ce face stratul ăsta să-și merite costul:

HTTP/1.1 429 Too Many Requests
Retry-After: 6
RateLimit-Limit: 50
RateLimit-Remaining: 0
RateLimit-Reset: 6
Content-Type: application/json

{ "error": "rate_limited", "message": "Planul tău permite 50 de comenzi pe oră. Încearcă din nou în 6 secunde sau fă upgrade ca să elimini limita.", "upgradeUrl": "/billing" }

Un 429 pe care produsul îl poate randa. WAF-ul nu poate scrie mesajul ăla, pentru că nu știe ce e o comandă.

Cine gestionează ce cazstratcheie credential stuffing pe /api/authWAFIP · 100 / 5 min scraper care trage fiecare pagină de produsWAFIP · 2.000 / 5 min script de partener blocat într-o buclă de retryaplicațiaid de partener · planul lui utilizator care plasează mai multe comenzi decât permite planulaplicațiaid de utilizator · 50 / oră un birou întreg în spatele unui NAT blocat la 9:00scope-down WAFplaja lor de egress, nu o limită mai mare health check-uri · webhook-uri semnateexceptate peste totrestrâns pe cale, în fiecare strat

Unde, intenționat, nu facem rate limiting

Receptoarele de webhook. Un procesator de plăți care reîncearcă o livrare nu e abuz, e protocolul funcționând, iar o rafală de o sută de webhook-uri după pana lor e exact momentul în care ai cea mai mare nevoie să le accepți pe toate. Rutele de webhook sunt autentificate prin semnătură, sunt exceptate de la regula de rată WAF după cale, iar aplicația nu le pune în bucket. Dacă sunt o problemă de încărcare, rezolvarea e o coadă în spatele receptorului, nu o limită în fața lui.

Health check-urile, din același motiv, și pentru că un health check cu rate limit e un health check care minte.

Cât costă și ce a prins

Strat Cost lunar Ce a oprit în șase luni
Reguli de rată WAF (2) ~$2 3 runde de credential stuffing, 1 scraper, 1 bot de monitorizare configurat greșit
Usage plans API Gateway $0 (nefolosit) n/a
Limiter în aplicație (DynamoDB) ~$1 în scrieri on-demand 2 bucle de retry ale partenerilor, ~40 de utilizatori pe zi care ating limitele de plan, adică produsul funcționând

Cinci dolari pe lună, o după-amiază pentru regulile WAF, o zi pentru limiter-ul din aplicație și body-ul lui de 429. Cea mai valoroasă linie e ultima: patruzeci de utilizatori pe zi care văd un mesaj ce spune care e limita și cum s-o ridice, în loc de o eroare generică, pentru că limita trăiește în stratul care știe ce înseamnă.

Dacă ai o singură limită de rată pentru tot și e fie prea largă ca să oprească abuzul, fie prea strânsă pentru utilizatorii reali, ăsta e semnul că e în stratul greșit. Te ajutăm s-o împarți.