Un dimarts al matí, l'API de la Botiga Aroma comença a respondre en quatre segons. No hi ha desplegament nou, no hi ha error als logs, la base de dades no està caiguda. Mirant el trànsit apareix el culpable: una única adreça IP fent 400 peticions per segon a GET /v1/cafes?limit=100, recorrent el catàleg sencer cada pocs segons. No és un atac sofisticat: és algú copiant el catàleg o —igual de probable— un client amb un useEffect mal escrit que reintenta en bucle sense esperar.
L'API està autenticada, autoritzada i endurida. I tot i així, qualsevol la pot tombar simplement cridant-la molt. Aquesta lliçó tanca l'última fila oberta del model d'amenaces de 04-02: API4, consum il·limitat de recursos. Veurem per què tota API pública necessita límits, els quatre algorismes clàssics amb els seus compromisos, què es fa servir com a clau d'agrupació, la resposta 429 amb les seves capçaleres, la implementació al projecte amb express-rate-limit i Redis, les altres defenses de disponibilitat, i què ha de fer un client ben educat quan li diuen que pari.
Advertiment. El rate limiting és una defensa de disponibilitat i, mal ajustat, pot negar el servei a usuaris legítims o deixar passar un atac. Els valors d'aquesta lliçó són un punt de partida raonable per a un cas fictici; un desplegament real els ha de dimensionar amb dades pròpies i revisar-los amb un professional de seguretat.
Contingut
- Per què tota API pública necessita límits
- Rate limiting, throttling i quotes
- Els quatre algorismes
- Implementació comentada del cubell de fitxes
- La clau: contra què es compta
- El problema de la IP: NAT, proxys i
trust proxy - Límits per endpoint
- Els nivells de la Botiga Aroma
- La resposta 429 i les seves capçaleres
- Implementació:
src/middleware/limit-peticions.js - Magatzem en memòria enfront de Redis
- Altres defenses de disponibilitat
- 503,
Retry-Afteriservei_no_disponible - Què fa un client ben educat
- Com es comuniquen els límits a la documentació
- On viu el rate limiting en producció
- Proves del límit amb
node:test
- Per què tota API pública necessita límits
Els motius són cinc i convé distingir-los, perquè cadascun demana un límit diferent:
| Motiu | Exemple a la Botiga Aroma | Quin límit ho ataca |
|---|---|---|
| Abús deliberat | Força bruta contra POST /v1/sessions |
Molt estricte per IP i per correu |
| Scraping | Copiar el catàleg sencer cada hora | Moderat per IP en lectura |
| Clients mal programats | Un bucle de reintents sense espera | Moderat, amb Retry-After clar |
| Cost | Cerques cares que disparen la factura | Límit específic a ?q= |
| Equitat | Un consumidor consumeix el 95 % de la capacitat | Límit per consumidor autenticat |
El tercer mereix un matís important: la majoria del trànsit abusiu no és maliciós. És un desenvolupador que no ha vist que el seu reintent no té espera, una app mòbil que refresca a cada scroll, un cron que se solapa amb ell mateix. Això canvia el disseny de la resposta: el 429 ha de ser pedagògic, dir quan cal tornar i ser fàcil de gestionar programàticament, perquè el seu destinatari habitual és algú que ho vol arreglar.
I hi ha un motiu transversal que els engloba: protegir la base de dades. La teva API pot escalar a més instàncies; l'SQLite de la Botiga Aroma, o el PostgreSQL que vingui després, no tant. El rate limiting és el que impedeix que un pic de trànsit es tradueixi en una allau de consultes que deixa la resta de consumidors sense servei.
- Rate limiting, throttling i quotes
Tres termes que es fan servir com a sinònims i no ho són:
| Concepte | Què fa | Horitzó | Resposta típica |
|---|---|---|---|
| Rate limiting | Rebutja el que excedeixi una taxa | Segons o minuts | 429 immediat |
| Throttling | Retarda en comptes de rebutjar | Segons | 200 més lent, o cua |
| Quota | Total permès en un període llarg | Dia o mes | 429 o 402 en esgotar-se |
- Rate limiting: «100 peticions per minut». La 101 es rebutja.
- Throttling: «com a màxim 10 peticions concurrents». La 11 espera a la cua. És més amable amb el client però consumeix recursos teus mentre espera, i pot degradar tot el sistema si la cua creix: per això sempre necessita un sostre de cua i un timeout.
- Quota: «50.000 peticions al mes en el pla gratuït». És un concepte comercial més que tècnic; es mostra a la factura, no a cada resposta.
La Botiga Aroma fa servir rate limiting com a mecanisme principal, amb una quota diària per al soci RàpidEnviaments. El throttling apareix de manera natural en un lloc: el pool de connexions a la base de dades, que ja limita la concurrència real.
- Els quatre algorismes
Finestra fixa
Es compta quantes peticions hi ha al minut actual; en canviar de minut, el comptador torna a zero.
És trivial d'implementar i molt barat: un enter per clau. El seu problema és l'efecte de vora:
10:00:59 → 100 peticions (permeses: la finestra de les 10:00 estava a 0) 10:01:00 → 100 peticions (permeses: finestra nova) ──────────────────────────── 200 peticions en 1 segon amb un límit de "100 per minut"
El doble del límit en un instant. Amb límits generosos és assumible; per protegir un login, no.
Finestra lliscant amb registre (sliding window log)
Es desa la marca de temps de cada petició i es compten les dels últims 60 segons, exactament. És precís al 100 % i no té efecte de vora. El cost és la memòria: amb un límit de 1.000 per minut i 50.000 claus actives, són 50 milions de marques de temps.
Finestra lliscant amb comptador (sliding window counter)
El compromís pràctic. Es desen dos comptadors —el de la finestra actual i el de l'anterior— i s'estima el valor lliscant per interpolació:
// Als 15 s de la finestra actual, se n'ha consumit el 25 %,
// així que encara pesa el 75 % de la finestra anterior.
const pesAnterior = 1 - transcorregutAFinestra / duracioFinestra; // 0,75
const estimat = comptadorAnterior * pesAnterior + comptadorActual;Amb dos números per clau s'elimina pràcticament l'efecte de vora. És el que fan servir la majoria de les implementacions serioses, inclosa Cloudflare.
Cubell de fitxes (token bucket)
Un cubell amb capacitat C que s'omple a R fitxes per segon. Cada petició consumeix una fitxa; si no n'hi ha, es rebutja.
La seva gràcia és que permet ràfegues de manera controlada: un client que ha estat callat acumula fitxes fins a C i les pot gastar de cop, però la seva taxa sostinguda mai no supera R. Això encaixa molt bé amb el trànsit real d'una API: una app que obre una pantalla fa vuit peticions seguides i després calla un minut, i no la volem castigar.
Cubell amb fuites (leaky bucket)
Les peticions entren en una cua que es buida a ritme constant. Suavitza el trànsit de sortida completament, però no permet ràfegues i afegeix latència: és throttling, no rate limiting. Es fa servir més per regular la sortida cap a un sistema aigües avall (per exemple, les crides a la passarel·la de pagament) que per a l'entrada.
Comparativa
| Algorisme | Precisió | Memòria per clau | Complexitat | Ràfegues | Quan fer-lo servir |
|---|---|---|---|---|---|
| Finestra fixa | Baixa (2× a la vora) | 1 enter | Molt baixa | Descontrolades a la vora | Límits generosos, prototips |
| Finestra lliscant amb log | Perfecta | N marques de temps | Mitjana | No | Endpoints crítics amb poques claus |
| Finestra lliscant amb comptador | Molt alta | 2 enters | Mitjana | Gairebé cap | Ús general |
| Cubell de fitxes | Alta | 2 números | Mitjana | Sí, acotades | APIs amb trànsit a ràfegues |
| Cubell amb fuites | Alta | Cua | Alta | No (afegeix latència) | Regular la sortida cap a tercers |
L'elecció de la Botiga Aroma: cubell de fitxes per al límit general —perquè el trànsit de la SPA i d'Aroma Mòbil és naturalment a ràfegues— i finestra lliscant estricta per a POST /v1/sessions, on no volem cap ràfega.
- Implementació comentada del cubell de fitxes
// src/middleware/cubell-fitxes.js (didàctic: en producció fem servir express-rate-limit)
/**
* Cubell de fitxes.
*
* @param {number} capacitat Fitxes màximes acumulables = mida de ràfega permesa.
* @param {number} taxa Fitxes que es reposen per segon = taxa sostinguda.
*/
export function crearCubell(capacitat, taxa) {
// Es desa per clau: { fitxes, ultimaRecarrega }.
// El truc central: NO hi ha temporitzador. Les fitxes es calculen quan es
// consulten, a partir del temps transcorregut. Això fa l'algorisme O(1)
// i evita mantenir milers d'intervals actius.
const cubells = new Map();
function consumir(clau, cost = 1) {
const ara = Date.now();
const cubell = cubells.get(clau) ?? { fitxes: capacitat, ultimaRecarrega: ara };
// 1. Recàrrega mandrosa: fitxes guanyades des de l'última consulta.
const segons = (ara - cubell.ultimaRecarrega) / 1000;
cubell.fitxes = Math.min(capacitat, cubell.fitxes + segons * taxa);
cubell.ultimaRecarrega = ara;
// 2. N'hi ha prou per a aquesta petició?
const permesa = cubell.fitxes >= cost;
if (permesa) cubell.fitxes -= cost;
cubells.set(clau, cubell);
// 3. Quan hi haurà prou fitxes, en segons (per a Retry-After).
const falten = Math.max(0, cost - cubell.fitxes);
const esperaSegons = permesa ? 0 : Math.ceil(falten / taxa);
return {
permesa,
restants: Math.floor(cubell.fitxes),
esperaSegons,
};
}
// 4. Neteja: sense això, la memòria creix sense límit amb cada IP nova.
// Un cubell ple és indistingible d'un que no existeix, així que es pot llençar.
function netejar() {
const ara = Date.now();
const segonsPerOmplir = capacitat / taxa;
for (const [clau, cubell] of cubells) {
if ((ara - cubell.ultimaRecarrega) / 1000 > segonsPerOmplir) cubells.delete(clau);
}
}
const temporitzador = setInterval(netejar, 60_000);
temporitzador.unref(); // no impedeix que el procés acabi
return { consumir, netejar };
}Quatre detalls que fan que això funcioni i que gairebé sempre es fan malament:
La recàrrega mandrosa (pas 1) és la idea clau. Un cubell per client amb un setInterval cadascun seria inviable amb milers de claus; calcular les fitxes en consultar-les dona el mateix resultat amb cost constant.
Math.min(capacitat, ...) impedeix que un client inactiu durant un dia acumuli 86.400 fitxes i es descarregui la base de dades sencera de cop. El màxim acumulable és la capacitat, i per això la capacitat és la mida de ràfega permesa.
El cost parametritzable (pas 2) obre la porta al cost per consulta de l'apartat 12: una cerca cara pot consumir cinc fitxes en comptes d'una.
La neteja (pas 4) és un requisit de disponibilitat, no una optimització: sense ella, un atacant que roti adreces IP fa créixer el Map fins a esgotar la memòria del procés. I unref() evita que el temporitzador mantingui viu el procés en apagar-lo, cosa que trencaria l'apagada ordenada de 03-07 i les proves de 03-08.
- La clau: contra què es compta
Un límit sempre s'aplica a un grup. Triar malament la clau és l'error més car:
| Clau | Avantatges | Inconvenients | Ús a la Botiga Aroma |
|---|---|---|---|
| IP | Funciona sense autenticació | El NAT agrupa milers; IPv6 permet rotar; proxys | Trànsit anònim i login |
Usuari autenticat (sub) |
Just i precís | Només després d'autenticar | Trànsit autenticat |
client_id d'OAuth |
Aïlla CataBox de la SPA | Només amb OAuth (04-03) | Tercers |
| Clau d'API | Igual, per a socis | Cal emetre-les i rotar-les | RàpidEnviaments |
| Combinació | El millor de cadascuna | Més estat | El que fem servir |
La regla de selecció de la Botiga Aroma, per ordre de preferència:
// src/middleware/limit-peticions.js (fragment)
export function clauDeLimit(req) {
// 1. Aplicació de tercers identificada: se li limita a ella, no a l'usuari.
if (req.usuari?.clientOauth) return `oauth:${req.usuari.clientOauth}:${req.usuari.id}`;
// 2. Usuari autenticat: la clau més justa.
if (req.usuari?.id) return `usr:${req.usuari.id}`;
// 3. Anònim: no queda més remei que la IP.
return `ip:${req.ip}`;
}El cas 1 mereix explicació: si CataBox té un bug i masega l'API en nom de 500 usuaris, limitar per usuari no atura res —són 500 claus diferents—. La clau composta permet aplicar a més un sostre agregat per client_id, que és el que realment protegeix.
- El problema de la IP: NAT, proxys i
trust proxy
trust proxyLimitar per IP té tres problemes seriosos que cal conèixer abans de fixar un número:
NAT. Una oficina, una universitat o un operador mòbil poden presentar centenars o milers d'usuaris darrere d'una única IP pública. Un límit de 60 per minut per IP deixa sense servei tota una empresa els empleats de la qual facin servir la botiga. Per això el límit anònim ha de ser generós, i l'estricte s'ha de reservar per a allò autenticat o per a operacions concretes.
IPv6. Un atacant amb un prefix /64 disposa de bilions d'adreces. Limitar per IPv6 individual és inútil: cal agrupar per prefix (habitualment /64).
Proxys. Aquest és el que trenca el codi. Si la teva API està darrere d'un balancejador o una CDN —i en producció hi estarà—, req.ip és la IP del proxy, no la del client. Tot el trànsit comparteix clau i el primer usuari esgota el límit de tots.
La IP real arriba a X-Forwarded-For, una capçalera amb la cadena de salts:
I aquí hi ha el parany: aquella capçalera la pot escriure qualsevol. Un atacant envia X-Forwarded-For: 1.2.3.4 i, si hi confies cegament, canvia d'identitat a cada petició i anul·la el límit.
// src/app.js
// ❌ PERILLÓS: confia en tota la cadena, inclosa la part que va escriure el client.
app.set('trust proxy', true);
// ✅ Confia exactament en 1 salt: el balancejador que TU controles.
// Express pren llavors la penúltima entrada de X-Forwarded-For, que és la que
// va escriure el teu propi proxy i que el client no pot falsificar.
app.set('trust proxy', 1);
// ✅ Alternativa explícita: la llista de proxys de confiança.
app.set('trust proxy', ['10.0.0.0/8', '172.16.0.0/12']);El número ha de coincidir exactament amb la quantitat de proxys que hi ha al davant. Si hi poses 1 i n'hi ha dos (CDN + balancejador), obtens la IP del primer proxy en comptes de la del client; si hi poses 2 i n'hi ha un, obtens la IP que el client vulgui. Comprova-ho a l'entorn real abans de fiar-te'n:
// Endpoint temporal de diagnòstic. Es retira després: exposa informació de xarxa.
app.get('/diagnostic/ip', (req, res) => {
res.json({ ip: req.ip, ips: req.ips, xff: req.get('X-Forwarded-For') });
});Recorda a més que aquest mateix ajust afecta req.protocol i, per tant, les galetes Secure i les redireccions HTTPS de 04-02: és una configuració amb conseqüències més enllà del rate limiting.
- Límits per endpoint
Un límit global uniforme és sempre un mal compromís: massa lax per al login, massa estricte per al catàleg. Els límits s'ajusten al cost i al risc de cada operació.
| Endpoint | Cost | Risc | Límit | Motiu |
|---|---|---|---|---|
GET /v1/cafes |
Baix (cacheable) | Scraping | Generós | És l'aparador |
GET /v1/cafes?q=... |
Alt (cerca) | Cost | Estricte | Consulta cara sense memòria cau |
POST /v1/sessions |
Baix | Força bruta | Molt estricte | Defensa de comptes |
POST /v1/clients (registre) |
Mitjà (bcrypt, correu) | Comptes brossa | Estricte | bcrypt costa CPU a propòsit |
POST /v1/comandes |
Alt (transacció) | Cost, estoc | Moderat | Protegeix la base de dades |
POST /v1/comandes/{id}/pagament |
Alt (tercer) | Frau, cost | Estricte | Cada intent costa diners |
POST /v1/cafes/{id}/imatge |
Molt alt | Emmagatzematge | Molt estricte | Pujades |
GET /v1/comandes |
Mitjà | Enumeració | Moderat | Autenticat |
El cas del login mereix una precisió important: cal limitar per dues claus alhora.
- Per IP: frena qui prova milers de contrasenyes contra molts comptes des d'un lloc.
- Per correu de destí: frena el credential stuffing distribuït, on cada intent contra
[email protected]ve d'una IP diferent (una botnet). El límit per IP no hi veu res; el límit per compte sí.
I un matís que s'oblida: només es compten els intents fallits. Si comptes també els correctes, un usuari legítim que entra i surt diverses vegades acaba bloquejat. express-rate-limit ho admet amb skipSuccessfulRequests: true.
Compte també amb l'efecte secundari del límit per compte: si bloqueges el compte durant 15 minuts després de cinc errades, un atacant pot negar el servei a un usuari concret fallant a propòsit. Mitigacions: retard progressiu en comptes de bloqueig dur, finestra curta, i no bloquejar si la petició ve d'una IP que ja ha entrat amb èxit en aquell compte abans.
- Els nivells de la Botiga Aroma
| Nivell | Qui | Lectura | Escriptura | Login | Quota diària |
|---|---|---|---|---|---|
| Anònim | Sense token (catàleg públic) | 60/min per IP | — | 5/15 min per IP i compte | — |
| Client autenticat | client |
300/min | 60/min | — | 50.000 |
| Aplicació de tercers | CataBox (client_id) |
600/min agregat | 60/min | — | 100.000 |
| Soci | RàpidEnviaments (soci) |
1.000/min | 300/min | — | 500.000 |
| Tauler intern | empleat, administrador |
2.000/min | 600/min | — | Sense quota |
Cerca ?q= |
Qualsevol | 20/min | — | — | — |
Notes sobre aquests números, que importen més que els números mateixos:
- Els valors són un punt de partida. El procediment correcte és mesurar el percentil 99 de l'ús legítim real i posar el límit bastant per damunt. Un límit que talla usuaris reals costa més que un de massa generós.
- Comença en mode observació.
express-rate-limitpermet comptar sense bloquejar (skipque només registra). Dues setmanes de dades diuen molt més que qualsevol estimació. - El nivell es deriva del token, així que el rate limiting específic per rol ha d'anar després d'autenticar. El límit global anònim, en canvi, va abans: ha de protegir fins i tot de qui envia tokens brossa.
- La resposta 429 i les seves capçaleres
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37
Aroma-RateLimit-Limit: 60
Aroma-RateLimit-Restants: 0
Aroma-RateLimit-Reinici: 1786000437
Aroma-Traca-Id: trz_9f3a2b7c
{
"error": {
"codi": "limit_peticions",
"missatge": "Has superat el límit de peticions. Torna-ho a provar d'aquí a 37 segons.",
"detalls": []
}
}| Capçalera | Valor | Significat |
|---|---|---|
Retry-After |
37 |
Segons que cal esperar. La més important: és estàndard i les biblioteques la llegeixen |
Aroma-RateLimit-Limit |
60 |
Peticions permeses a la finestra |
Aroma-RateLimit-Restants |
0 |
Quantes en queden |
Aroma-RateLimit-Reinici |
1786000437 |
Instant Unix en què es restableix |
Quatre decisions de disseny darrere d'aquella resposta:
Retry-After sempre. Sense ella, el client educat no sap quant ha d'esperar i el maleducat reintenta immediatament, empitjorant el problema. Admet segons o una data HTTP; els segons són més fàcils i no depenen del rellotge del client.
Les capçaleres informatives a totes les respostes, no només al 429. El seu valor és que el client pugui frenar abans de xocar: si veu Restants: 3, espaia les seves peticions. Enviar-les només en fallar malbarata el mecanisme.
El prefix Aroma-, coherent amb el contracte (mai X-, com vam decidir a 02-05). Existeix un esborrany de l'IETF que estandarditza RateLimit-Limit, RateLimit-Remaining i RateLimit-Reset —i una forma combinada RateLimit: limit=60, remaining=0, reset=37—. Quan es publiqui com a RFC convindrà emetre'n totes dues durant un temps i documentar la migració; mentrestant, el prefix propi evita col·lisions amb el que facin els intermediaris.
El codi limit_peticions del catàleg, amb el mateix format d'error que tota la resta. Un 429 que retorna text pla trenca els clients que analitzen errors.
I un advertiment de CORS que es paga car: la SPA no pot llegir cap d'aquestes capçaleres des de JavaScript llevat que es declarin a Access-Control-Expose-Headers. És exactament el tipus de detall que fa que un mecanisme ben construït no serveixi de res, i el resolem a 04-05.
- Implementació:
src/middleware/limit-peticions.js
src/middleware/limit-peticions.js// src/middleware/limit-peticions.js (fitxer NOU)
import rateLimit from 'express-rate-limit';
import { errors } from '../errors/error-api.js';
import { entorn } from '../config/entorn.js';
/**
* Clau d'agrupació: aplicació de tercers > usuari autenticat > IP.
*/
function clauDeLimit(req) {
if (req.usuari?.clientOauth) return `oauth:${req.usuari.clientOauth}`;
if (req.usuari?.id) return `usr:${req.usuari.id}`;
return `ip:${req.ip}`;
}
/**
* Gestor comú: fixa les capçaleres pròpies i delega en el middleware d'errors
* de 03-07, perquè el cos tingui EXACTAMENT el format del catàleg.
*/
function enSuperar(req, res, next, opcions) {
const reiniciMs = req.rateLimit.resetTime?.getTime() ?? Date.now() + opcions.windowMs;
const esperaSegons = Math.max(1, Math.ceil((reiniciMs - Date.now()) / 1000));
res.set('Retry-After', String(esperaSegons));
res.set('Aroma-RateLimit-Limit', String(req.rateLimit.limit));
res.set('Aroma-RateLimit-Restants', '0');
res.set('Aroma-RateLimit-Reinici', String(Math.ceil(reiniciMs / 1000)));
next(
errors.limitPeticions(
'limit_peticions',
`Has superat el límit de peticions. Torna-ho a provar d'aquí a ${esperaSegons} segons.`
)
);
}
/** Opcions compartides per tots els limitadors. */
const comunes = {
standardHeaders: false, // encara no emetem RateLimit-* estàndard (vegeu 04-04 §9)
legacyHeaders: false, // MAI X-RateLimit-*: el contracte fa servir el prefix Aroma-
keyGenerator: clauDeLimit,
handler: enSuperar,
// En proves es desactiva: si no, la suite de 03-08 comença a fallar sola.
skip: () => entorn.NODE_ENV === 'prova',
};
/** 1. Límit global. Xarxa de seguretat per a tot el trànsit. */
export const limitGlobal = rateLimit({
...comunes,
windowMs: 60_000,
limit: (req) => {
if (!req.usuari) return 60; // anònim
if (req.usuari.rol === 'empleat' || req.usuari.rol === 'administrador') return 2000;
if (req.usuari.rol === 'soci') return 1000;
return 300; // client
},
});
/** 2. Login: molt estricte i només sobre els intents FALLITS. */
export const limitLogin = rateLimit({
...comunes,
windowMs: 15 * 60_000,
limit: 5,
skipSuccessfulRequests: true, // un login correcte no gasta quota
keyGenerator: (req) => {
// Doble clau: IP + compte atacat. Frena també l'atac distribuït.
const correu = (req.body?.email ?? '').toLowerCase().trim();
return `login:${req.ip}:${correu}`;
},
});
/** 3. Cerca: consulta cara, sense memòria cau. */
export const limitCerca = rateLimit({ ...comunes, windowMs: 60_000, limit: 20 });
/** 4. Escriptures: protegeixen les transaccions. */
export const limitEscriptura = rateLimit({
...comunes,
windowMs: 60_000,
limit: (req) => (req.usuari?.rol === 'soci' ? 300 : 60),
});I la fàbrica d'errors que falta a src/errors/error-api.js (el codi limit_peticions ja hi era al catàleg des de 02-04; només n'afegim el constructor):
// src/errors/error-api.js (MODIFICAT)
export const errors = {
// ... noTrobat, conflicte, noAutenticat, permisDenegat, dadesInvalides
limitPeticions: (codi, missatge) => new ErrorApi(429, codi, missatge),
serveiNoDisponible: (codi, missatge) => new ErrorApi(503, codi, missatge),
};On es registra a src/app.js
// src/app.js (extracte després de 04-04)
app.disable('x-powered-by'); // 1
app.use(assignarTracaId); // 2
app.use(capcaleresSeguretat); // 3 helmet (04-02)
// (4) cors → 04-05
// (5) registre → 04-07
app.use(limitGlobal); // 6 ← NOU
app.use(express.json({ limit: '100kb', /* ... */ })); // 7
app.get('/salut', ...); // 8
app.use('/v1', rutesV1); // 9
app.use(gestorNoTrobat); // 10
app.use(gestorErrors); // 11Per què a la posició 6, i no abans ni després:
- Després de helmet i CORS, perquè el
429porti les capçaleres de seguretat i, sobretot, les de CORS: si no, la SPA rep un error de xarxa opac en comptes d'un429llegible. - Abans de l'analitzador de JSON. Si el limitador anés després, el teu servidor estaria analitzant i validant 100 kB de JSON de peticions que rebutjarà igualment. Rebutjar barat és mitja defensa de disponibilitat.
- Abans de les rutes, òbviament, perquè les protegeix totes.
- Després del registre (posició 5, 04-07), perquè els
429quedin registrats: si no, l'atac és invisible als logs justament quan més falta fa veure'l.
Hi ha una excepció deliberada a "abans de l'analitzador": limitLogin necessita req.body.email per a la seva clau, així que es registra dins de la ruta, després de l'analitzador:
// src/rutes/sessions.js (MODIFICAT)
router.post(
'/',
limitLogin, // ← ABANS d'autenticar: encara no hi ha usuari
validar(esquemaLogin, 'body'),
asincron(controladors.sessions.crear)
);
// src/rutes/cafes.js (MODIFICAT)
router.get(
'/',
autenticarOpcional,
(req, res, next) => (req.query.q ? limitCerca(req, res, next) : next()),
validar(esquemaLlistarCafes, 'query'),
asincron(controladors.cafes.llistar)
);
// src/rutes/comandes.js (MODIFICAT)
router.post(
'/',
autenticar,
exigirRol('client', 'empleat', 'administrador'),
limitEscriptura, // ← DESPRÉS d'autenticar: la clau és l'usuari
exigirClauIdempotencia,
validar(esquemaCrearComanda, 'body'),
asincron(controladors.comandes.crear)
);La regla general que resumeix l'ordre: el límit anònim va abans d'autenticar; el límit per usuari va després.
- Magatzem en memòria enfront de Redis
El magatzem per defecte d'express-rate-limit és un Map dins del procés. Amb una sola instància funciona. Amb tres, passa això:
graph TD C[Client: 60 peticions/min] --> B[Balancejador] B -->|20 peticions| A1[Instancia 1: compta 20 de 60 - permet] B -->|20 peticions| A2[Instancia 2: compta 20 de 60 - permet] B -->|20 peticions| A3[Instancia 3: compta 20 de 60 - permet] A1 --> R[Limit real aplicat: 180/min amb un limit de 60] A2 --> R A3 --> R
El límit efectiu es multiplica pel nombre d'instàncies, i a més és erràtic: depèn de com reparteixi el balancejador. Pitjor encara, un desplegament reinicia els processos i esborra tots els comptadors, així que un atacant només ha d'esperar el teu desplegament següent.
| Memòria | Redis | |
|---|---|---|
| Instàncies | 1 | N |
| Precisió amb N instàncies | Límit × N | Exacta |
| Supervivent a reinicis | No | Sí |
| Latència afegida | 0 | ~1 ms a la mateixa xarxa |
| Punt únic de fallada | No | Sí: cal decidir què passa si Redis cau |
| Complexitat | Nul·la | Un servei més per operar |
// src/config/redis.js (fitxer NOU)
import Redis from 'ioredis';
import { entorn } from './entorn.js';
export const redis = new Redis(entorn.REDIS_URL, {
maxRetriesPerRequest: 2,
enableOfflineQueue: false, // si Redis és caigut, falla ràpid en comptes d'encuar
});
redis.on('error', (e) => {
// No es llança: l'API ha de continuar servint encara que Redis sigui caigut.
console.error('Redis no disponible:', e.message);
});// src/middleware/limit-peticions.js (MODIFICAT)
import RedisStore from 'rate-limit-redis';
import { redis } from '../config/redis.js';
const magatzem = entorn.REDIS_URL
? new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'aroma:rl:', // prefix per no col·lisionar amb la memòria cau de 04-06
})
: undefined; // sense REDIS_URL, magatzem en memòria (desenvolupament)
const comunes = {
// ... la resta igual
store: magatzem,
};Què fer si Redis cau és una decisió de disseny explícita, no un detall:
- Fail-open (deixar passar): l'API continua funcionant sense límits. Prioritza la disponibilitat; és l'opció per defecte de la majoria i la que tria la Botiga Aroma per al límit general.
- Fail-closed (rebutjar): més segur però converteix una caiguda de Redis en una caiguda total.
Un compromís raonable: fail-open al límit general, fail-closed al login, on el risc de força bruta pesa més que la disponibilitat. I en tots dos casos, una alerta: quedar-te sense rate limiting i no assabentar-te'n és pitjor que qualsevol de les dues opcions.
- Altres defenses de disponibilitat
El rate limiting no arriba a tot. El quadre complet:
| Defensa | Què evita | Estat al projecte |
|---|---|---|
Límit de cos (limit: '100kb') |
POST de 500 MB |
Posat a 03-02 |
| Timeouts de servidor | Connexions obertes eternament (Slowloris) | A sota |
| Timeouts de sortida | Que un tercer lent bloquegi els teus processos | A cada fetch |
Límit d'expandir |
Consultes exponencials | Profunditat 2 + llista blanca (04-01) |
limit màxim 100 |
Pàgines gegants | 03-03 |
desplacament màxim |
OFFSET que escombra la taula |
03-03 |
| Cost per consulta | Que totes les peticions comptin igual | A sota |
| Circuit breaker | Masegar un servei caigut | A sota |
| Backpressure | Acceptar més càrrega de la que pots processar | Cua acotada |
Timeouts del servidor. Node accepta connexions que no envien res, i un atac Slowloris les fa servir per esgotar el pool:
// src/servidor.js (MODIFICAT)
const servidor = app.listen(entorn.port);
servidor.headersTimeout = 10_000; // 10 s per enviar les capçaleres completes
servidor.requestTimeout = 30_000; // 30 s per a tota la petició
servidor.keepAliveTimeout = 65_000; // més gran que el del balancejador (típic 60 s)El keepAliveTimeout és una font clàssica de 502 aleatoris: si el teu servidor tanca la connexió reutilitzada abans que el balancejador, el balancejador envia una petició per una connexió que s'està tancant. La regla és que el de Node sigui més gran que el del proxy.
Cost per consulta. No totes les peticions costen el mateix, i el cubell de fitxes de l'apartat 4 ja admet un cost:
| Operació | Cost en fitxes |
|---|---|
GET /v1/cafes/{id} |
1 |
GET /v1/cafes?limit=100 |
3 |
GET /v1/cafes?q=... |
5 |
GET /v1/comandes?expandir=linies.cafe |
5 |
POST /v1/comandes |
10 |
És més just que comptar peticions i és el que fan les APIs madures (GitHub en diu «punts»). Requereix documentar-ho bé, perquè un límit en unitats abstractes és més difícil d'entendre.
Circuit breaker. Si la passarel·la de pagament és caiguda, continuar cridant-la amb timeout de 30 segons consumeix els teus processos i retarda la recuperació de l'altre. El patró té tres estats: tancat (tot passa), obert (després de N fallades, es rebutja immediatament sense cridar) i semiobert (passat un temps es deixa passar una petició de prova). És el que converteix «el pagament és caigut» en un 503 ràpid en comptes d'una caiguda general.
Backpressure. Quan la feina entrant supera la que pots processar, la resposta correcta és rebutjar (503), no encuar indefinidament. Una cua sense sostre només canvia una caiguda ràpida per una de lenta i amb tota la memòria consumida.
- 503,
Retry-After i servei_no_disponible
Retry-After i servei_no_disponible429 i 503 es confonen i no són el mateix:
| 429 | 503 | |
|---|---|---|
| Significa | Tu has demanat massa | Nosaltres no podem ara |
| Culpa | Del client | Del servidor |
| Altres clients | Estan bé | També afectats |
| Codi | limit_peticions |
servei_no_disponible |
Retry-After |
Sí, calculat | Sí, estimat |
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/json
{
"error": {
"codi": "servei_no_disponible",
"missatge": "El servei no està disponible temporalment. Torna-ho a provar d'aquí a 2 minuts.",
"detalls": [],
"tracaId": "trz_9f3a2b7c"
}
}Fixa't en el tracaId: és un 5xx, així que el contracte de 03-07 l'exigeix. Al 429, que és 4xx, no hi apareix.
Quan es fa servir 503 a la Botiga Aroma: manteniment programat, base de dades no disponible, circuit breaker obert cap a la passarel·la de pagament, o sobrecàrrega detectada per backpressure. I sempre amb Retry-After, encara que sigui una estimació: sense ella, tots els clients reintenten alhora i l'efecte és el ramat atronador que impedeix que el servei es recuperi.
- Què fa un client ben educat
De l'altra banda del contracte, això és el que ha de fer qui consumeix:
/**
* Client HTTP amb reintents correctes per a l'API de la Botiga Aroma.
*
* Regles:
* - Només reintenta allò que és segur reintentar.
* - Respecta Retry-After quan el servidor l'indica.
* - Retrocés exponencial amb jitter quan no l'indica.
* - Sostre d'intents: reintentar per sempre és un atac.
*/
const REINTENTABLES = new Set([429, 502, 503, 504]);
function esperar(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function peticioAmbReintents(url, opcions = {}, maxIntents = 4) {
const metode = (opcions.method ?? 'GET').toUpperCase();
// Només es reintenten mètodes idempotents, o POST amb clau d'idempotència.
const segurReintentar =
['GET', 'HEAD', 'PUT', 'DELETE'].includes(metode) ||
Boolean(opcions.headers?.['Idempotency-Key']);
for (let intent = 1; intent <= maxIntents; intent++) {
const resposta = await fetch(url, opcions);
if (!REINTENTABLES.has(resposta.status) || !segurReintentar) return resposta;
if (intent === maxIntents) return resposta; // es retorna la fallada, no s'amaga
// 1. Si el servidor diu quant cal esperar, se li fa cas. Punt.
const retryAfter = Number(resposta.headers.get('Retry-After'));
let esperaMs;
if (Number.isFinite(retryAfter) && retryAfter > 0) {
esperaMs = retryAfter * 1000;
} else {
// 2. Si no, retrocés exponencial: 1 s, 2 s, 4 s, 8 s...
const base = 1000 * 2 ** (intent - 1);
// 3. Jitter: fins a ±50 % aleatori. IMPRESCINDIBLE.
esperaMs = base * (0.5 + Math.random());
}
// 4. Sostre absolut: mai esperar més de 60 s.
await esperar(Math.min(esperaMs, 60_000));
}
}Per què el jitter no és opcional. Si mil clients reben un 503 al mateix segon i tots esperen exactament 1, 2, 4 i 8 segons, els mil tornen alhora quatre vegades seguides. El servei, que s'estava recuperant, cau de nou amb cada onada. L'aleatorietat reparteix la tornada en el temps i és la diferència entre recuperar-se en un minut o no recuperar-se. És una fallada de sistemes distribuïts tan comuna que té nom propi: ramat atronador.
I les tres regles complementàries:
- Frenar abans de xocar: si
Aroma-RateLimit-Restantsbaixa d'un llindar, espaiar les peticions en comptes d'esperar el429. - No reintentar els
4xxde client. Un400o un422fallaran igual la segona vegada. Només429i5xx. - Desar a la memòria cau. El millor reintent és la petició que no es fa: si el catàleg no ha canviat, no el tornis a demanar. Aquest és exactament el tema de 04-06.
- Com es comuniquen els límits a la documentació
Un límit no documentat és una fallada intermitent des del punt de vista del consumidor. La documentació ha de respondre cinc preguntes, i convé que siguin en una sola pàgina:
- Quins són els límits, per nivell i per endpoint, en una taula com la de l'apartat 8.
- Com saber quant en queda: les capçaleres
Aroma-RateLimit-*, amb un exemple real. - Què passa en superar-los: el
429complet, amb el seu cos i el seuRetry-After. - Què ha de fer el client: el patró de retrocés, amb codi copiable com el de l'apartat 14.
- Com demanar-ne més: a qui escriure, amb quina justificació i en quin termini.
A openapi.yaml es declara la resposta 429 a les operacions afectades, amb les seves capçaleres:
components:
responses:
LimitPeticions:
description: S'ha superat el límit de peticions.
headers:
Retry-After:
description: Segons que cal esperar abans de reintentar.
schema: { type: integer, example: 37 }
Aroma-RateLimit-Limit:
description: Peticions permeses a la finestra actual.
schema: { type: integer, example: 60 }
Aroma-RateLimit-Restants:
description: Peticions que queden a la finestra actual.
schema: { type: integer, example: 0 }
Aroma-RateLimit-Reinici:
description: Instant Unix (segons) en què es restableix la finestra.
schema: { type: integer, example: 1786000437 }
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
codi: limit_peticions
missatge: Has superat el límit de peticions. Torna-ho a provar d'aquí a 37 segons.
detalls: []
- On viu el rate limiting en producció
El middleware d'Express funciona, però té una limitació estructural: per rebutjar la petició, ja l'has rebuda. El teu servidor ha acceptat la connexió TCP, ha negociat TLS i ha executat middleware. Sota un atac de debò, això ja és massa feina.
Per això, en producció, el rate limiting sol viure en capes:
| Capa | Què frena | Avantatge | Limitació |
|---|---|---|---|
| CDN / WAF (Cloudflare, CloudFront) | Volum brut, DDoS, bots | El trànsit ni arriba a la teva xarxa | No coneix la teva lògica de negoci |
| API gateway (Kong, Apigee, AWS API Gateway) | Límits per consumidor i pla | Centralitzat, sense tocar codi | Un component més per operar |
| Balancejador / nginx | Connexions i taxa per IP | Barat i molt ràpid | Només coneix IPs |
| Aplicació (el que hem fet) | Regles de negoci: per rol, per endpoint, cost | Coneix el context: qui és i què demana | Consumeix recursos del procés |
Les capes es complementen, no se substitueixen. La CDN frena el DDoS que el teu procés mai no aguantaria; només l'aplicació sap que un soci pot fer 1.000 per minut i un client 300. Mantenir el límit a l'aplicació té a més un avantatge pràctic: és la defensa que continua existint si algú desplega l'API sense la CDN al davant, o si un atacant troba la IP d'origen i la crida directament. Els gateways i els seus portals els veurem a 05-06.
- Proves del límit amb
node:test
node:test// proves/integracio/limit-peticions.prova.js
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import express from 'express';
import rateLimit from 'express-rate-limit';
import { gestorErrors } from '../../src/middleware/errors.js';
import { errors } from '../../src/errors/error-api.js';
/**
* Compte: el `skip` d'entorn 'prova' desactiva els limitadors reals perquè
* la suite de 03-08 no es trenqui. Per això aquí muntem una app mínima amb un
* limitador propi: provem el COMPORTAMENT, no la configuració global.
*/
function crearAppAmbLimit(limit = 3) {
const app = express();
app.use(
rateLimit({
windowMs: 60_000,
limit: limit,
standardHeaders: false,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
handler: (req, res, next) => {
const reinici = req.rateLimit.resetTime.getTime();
const espera = Math.max(1, Math.ceil((reinici - Date.now()) / 1000));
res.set('Retry-After', String(espera));
res.set('Aroma-RateLimit-Limit', String(req.rateLimit.limit));
res.set('Aroma-RateLimit-Restants', '0');
next(errors.limitPeticions('limit_peticions', `Espera ${espera} segons.`));
},
})
);
app.get('/v1/cafes', (req, res) => res.json({ dades: [], total: 0 }));
app.use(gestorErrors);
return app;
}
describe('rate limiting', () => {
it('permet fins al límit i rebutja la següent amb 429', async () => {
const app = crearAppAmbLimit(3);
for (let i = 1; i <= 3; i++) {
const r = await request(app).get('/v1/cafes');
assert.equal(r.status, 200, `la petició ${i} havia de passar`);
}
const r = await request(app).get('/v1/cafes');
assert.equal(r.status, 429);
assert.equal(r.body.error.codi, 'limit_peticions');
assert.deepEqual(r.body.error.detalls, []); // el contracte exigeix detalls sempre
assert.ok(!('tracaId' in r.body.error), 'tracaId només en 5xx');
});
it('el 429 inclou Retry-After i les capçaleres Aroma-RateLimit', async () => {
const app = crearAppAmbLimit(1);
await request(app).get('/v1/cafes');
const r = await request(app).get('/v1/cafes');
assert.equal(r.status, 429);
const espera = Number(r.headers['retry-after']);
assert.ok(Number.isInteger(espera) && espera > 0, 'Retry-After ha de ser enter positiu');
assert.equal(r.headers['aroma-ratelimit-limit'], '1');
assert.equal(r.headers['aroma-ratelimit-restants'], '0');
assert.ok(!r.headers['x-ratelimit-limit'], 'no s\'han d\'emetre capçaleres X-');
});
it('claus diferents no comparteixen quota', async () => {
const app = crearAppAmbLimit(1);
await request(app).get('/v1/cafes').set('X-Forwarded-For', '203.0.113.1');
// Sense trust proxy, Supertest fa servir sempre 127.0.0.1: aquesta prova verifica
// que la quota és per clau, així que el limitador les ha de distingir.
const r = await request(app).get('/v1/cafes').set('X-Forwarded-For', '203.0.113.2');
assert.ok([200, 429].includes(r.status)); // depèn de trust proxy: vegeu el comentari
});
});Tres coses que ensenya aquesta prova:
- El
skipen entorn de proves és necessari però perillós. Necessari perquè, sense ell, la suite de 03-08 començaria a fallar de manera aleatòria tan bon punt fes més de 60 peticions. Perillós perquè significa que els limitadors reals no es proven: per això muntem una app mínima amb el mateixhandler. - Es prova el contracte, no la implementació: codi, format del cos, capçaleres presents i capçaleres absents (
X-RateLimit-*no hi ha d'aparèixer). - La tercera prova està deliberadament fluixa i el seu comentari ho explica: sense
trust proxyconfigurat, Supertest sempre sembla venir de127.0.0.1. Provar la separació per IP requereix configurartrust proxya l'app de prova; és un recordatori que provar el rate limiting per IP obliga a replicar la topologia de xarxa.
Per provar el límit basat en temps sense esperar de debò, fes servir el rellotge fals de node:test:
import { mock } from 'node:test';
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
mock.timers.tick(61_000); // avança un minut: la finestra s'ha renovatErrors Comuns i Consells
Posar el limitador després de l'analitzador de JSON. Analitzes 100 kB de peticions que rebutjaràs. Rebutja com més aviat millor.
Posar el limitador abans de CORS. El navegador rep un error de xarxa opac en comptes d'un 429, i el desenvolupador de la SPA perd una tarda.
Fer servir el magatzem en memòria amb diverses instàncies. El límit es multiplica pel nombre de processos i s'esborra a cada desplegament.
Confiar en X-Forwarded-For sense configurar trust proxy correctament. O limites tothom junt, o l'atacant canvia d'identitat a voluntat.
Comptar els logins correctes. Un usuari legítim que entra i surt acaba bloquejat.
Bloquejar comptes per intents fallits sense més. Es converteix en una manera de negar el servei a un usuari concret. Retard progressiu millor que bloqueig dur.
No enviar Retry-After. El client educat no sap quant ha d'esperar i el maleducat no espera res.
Enviar les capçaleres de quota només al 429. El seu valor és que el client freni abans de xocar.
Oblidar Access-Control-Expose-Headers. La SPA no pot llegir cap capçalera pròpia i tot el mecanisme és invisible per a ella (04-05).
Consell: desplega en mode observació primer. Compta sense bloquejar durant dues setmanes, mira el percentil 99 real i fixa el límit bastant per damunt.
Consell: registra els 429 amb la seva clau. Saber que vénen tots d'oauth:catabox canvia el diagnòstic completament (04-07).
Consell: exclou el teu propi monitoratge. Res pitjor que la teva comprovació de salut gastant quota i disparant alertes falses.
Exercicis
Exercici 1: triar algorisme i clau
Per a cada situació, tria l'algorisme (finestra fixa, lliscant, cubell de fitxes) i la clau, i justifica-ho:
- Protegir
POST /v1/sessionsde la força bruta. - Permetre que la SPA carregui una pantalla que fa 8 peticions seguides, sense castigar-la.
- Limitar CataBox globalment, encara que actuï en nom de 500 usuaris diferents.
- Limitar
GET /v1/cafes?q=perquè cada cerca costa 200 ms de CPU.
Exercici 2: client amb retrocés
Escriu una funció descarregarCataleg(pagines) que recorri GET /v1/cafes?limit=100&desplacament=N durant pagines pàgines, respectant el rate limiting: ha de frenar preventivament quan Aroma-RateLimit-Restants sigui baix, respectar Retry-After en un 429 i no reintentar més de tres vegades per pàgina.
Exercici 3: diagnosticar un incident
Després de desplegar el rate limiting, suport rep aquestes queixes el mateix dia. Diagnostica cadascuna i proposa la correcció:
- (a) «Des de l'oficina de la meva empresa, el web deixa de funcionar al matí. Des de casa va bé.»
- (b) «La meva app mòbil rep 429 en obrir la pantalla d'inici, però només la primera vegada després d'estar una estona tancada.» (nota: l'app fa 8 peticions en arrencar)
- (c) «El nostre script d'integració rep 429 aleatòriament encara que fem 50 peticions per minut i el límit és 300.»
- (d) «La SPA mostra "error de xarxa" en comptes del missatge de límit.»
Solucions
Solució 1
| Cas | Algorisme | Clau | Justificació |
|---|---|---|---|
| 1. Login | Finestra lliscant estricta | ip + email (doble) |
No s'hi ha de permetre cap ràfega: 5 intents són 5, no 10 a la vora. La doble clau frena tant l'atacant concentrat (IP) com el distribuït per botnet (compte) |
| 2. Pantalla de la SPA | Cubell de fitxes | Usuari autenticat | És exactament el cas per al qual serveix: capacitat 20, taxa 5/s permet la ràfega de 8 i manté acotada la taxa sostinguda |
| 3. CataBox | Finestra lliscant amb comptador | client_id agregat |
Limitar per usuari no serviria: són 500 claus diferents. La clau ha de ser només oauth:catabox, ignorant el sub. Idealment, els dos límits alhora: per usuari i agregat per client |
| 4. Cerca | Cubell de fitxes amb cost | Usuari o IP | Cost 5 fitxes per cerca enfront d'1 per lectura normal: reflecteix el cost real i no obliga a un comptador a part |
Solució 2
const LLINDAR_FRENADA = 10; // per sota d'això, espaiem
function esperar(ms) {
return new Promise((r) => setTimeout(r, ms));
}
export async function descarregarCataleg(pagines, token) {
const cafes = [];
for (let pagina = 0; pagina < pagines; pagina++) {
const url = `https://api.botigaaroma.example/v1/cafes?limit=100&desplacament=${pagina * 100}`;
let resposta;
for (let intent = 1; intent <= 3; intent++) {
resposta = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (resposta.status !== 429) break;
if (intent === 3) throw new Error(`429 persistent a la pàgina ${pagina}`);
// El servidor diu quant cal esperar: se li fa cas, amb jitter per no
// sincronitzar-nos amb altres clients que hagin rebut el mateix 429.
const retryAfter = Number(resposta.headers.get('Retry-After')) || 2 ** intent;
await esperar(retryAfter * 1000 * (1 + Math.random() * 0.2));
}
if (!resposta.ok) throw new Error(`Error ${resposta.status} a la pàgina ${pagina}`);
const cos = await resposta.json();
cafes.push(...cos.dades);
if (cafes.length >= cos.total) break; // no demanar pàgines buides
// Frenada PREVENTIVA: millor anar a poc a poc que xocar contra el 429.
const restants = Number(resposta.headers.get('Aroma-RateLimit-Restants'));
const reinici = Number(resposta.headers.get('Aroma-RateLimit-Reinici'));
if (Number.isFinite(restants) && restants < LLINDAR_FRENADA) {
const segonsFinsReinici = Math.max(1, reinici - Math.floor(Date.now() / 1000));
// Es reparteix el que queda de finestra entre les peticions que encara podem fer.
await esperar((segonsFinsReinici / Math.max(1, restants)) * 1000);
}
}
return cafes;
}L'essencial: la frenada preventiva fa que el 429 gairebé mai no passi, el Retry-After es respecta quan passa, el jitter evita sincronitzar-se amb altres clients, hi ha sostre d'intents i se surt del bucle quan ja es tenen tots els elements segons total.
Solució 3
(a) NAT. Tota l'oficina surt per una IP pública. Amb 60/min anònims, vint empleats navegant l'esgoten. Correccions: pujar el límit anònim; autenticar com més aviat millor per passar al límit per usuari (300/min); i, si la SPA carrega el catàleg sense sessió, aprofitar la memòria cau HTTP (04-06) perquè la majoria d'aquelles peticions ni hi arribin.
(b) Efecte de vora amb finestra fixa. L'app fa 8 peticions de cop. Si el límit es va implementar amb finestra fixa i estricta, una ràfega després d'un període d'inactivitat pot caure just a la vora. Correcció: cubell de fitxes amb capacitat ≥ 20 i taxa 5/s, que és justament el cas d'ús per al qual serveix. Si el problema fos d'escala i no de ràfega, la solució alternativa és reduir les 8 crides a 1 amb expandir, com vam veure a 04-01.
(c) Magatzem en memòria amb diverses instàncies... a l'inrevés. Amb memòria i N instàncies el límit es multiplica, així que 50/min mai no donaria 429. Que sí que en doni apunta a una altra causa: la clau està mal triada i agrupa diversos consumidors. Molt probablement l'script no envia Authorization, cau a la branca ip: i comparteix IP amb altres processos del mateix client. Correcció: autenticar l'script (Client Credentials, 04-03) perquè la seva clau sigui el seu client_id. Una altra possibilitat: trust proxy mal configurat fa que tots els clients comparteixin la IP del balancejador.
(d) Falta Access-Control-Expose-Headers, o el limitador és abans que CORS. Si el 429 s'emet sense les capçaleres Access-Control-Allow-Origin, el navegador bloqueja la resposta i JavaScript només veu una fallada genèrica de xarxa. Correccions: registrar cors abans que limitGlobal a src/app.js (és la posició 4 enfront de la 6) i exposar Retry-After i Aroma-RateLimit-* a Access-Control-Expose-Headers (04-05).
Conclusió
La disponibilitat és l'única propietat d'una API que pot destruir qualsevol sense explotar cap vulnerabilitat: n'hi ha prou de cridar-la molt. Has vist per què tota API pública necessita límits —abús, scraping, clients amb bucles, cost, equitat i protecció de la base de dades—, la diferència entre rate limiting, throttling i quotes, i els quatre algorismes amb el seu compromís real entre precisió, memòria i tolerància a ràfegues, amb un cubell de fitxes implementat i comentat que inclou la recàrrega mandrosa i la neteja que gairebé ningú no escriu. Saps contra què es compta i per què la IP és una clau problemàtica entre NAT, IPv6 i proxys, amb la configuració exacta de trust proxy; tens els límits per endpoint i per nivell de la Botiga Aroma, i la resposta 429 completa amb Retry-After i les capçaleres Aroma-RateLimit-*. Al projecte hi ha src/middleware/limit-peticions.js amb express-rate-limit, registrat a la posició 6 de src/app.js —després de helmet i CORS, abans de l'analitzador de JSON—, amb magatzem a Redis per a diverses instàncies i una decisió explícita de què fer si Redis cau. I has afegit timeouts, cost per consulta, circuit breaker i el 503 amb servei_no_disponible, a més del client amb retrocés exponencial i jitter que evita el ramat atronador.
Hi ha un detall que ha aparegut tres vegades i que ja no es pot posposar: la SPA no pot llegir cap de les capçaleres que acabem de dissenyar. A 04-05, CORS i polítiques de seguretat, començarem pel perquè: la política del mateix origen del navegador, què és exactament un origen i per què curl i Aroma Mòbil no en resulten afectats. Veurem peticions simples enfront de preflight amb l'intercanvi OPTIONS complet en cru, totes les capçaleres del protocol —inclosa Access-Control-Expose-Headers, que és la que resol aquest problema—, la configuració real de la Botiga Aroma amb llista blanca per entorn, per què * i Allow-Credentials són incompatibles, la taula d'errors de consola amb la seva causa i la seva solució, el clàssic preflight que retorna 401 perquè l'autenticació es va executar abans que CORS, i per què fem servir Authorization: Bearer en comptes de galetes —cosa que ens fa immunes a CSRF—.
Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful
Mòdul 1: Introducció a les APIs RESTful
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
