Съдържание на курса
Урок 4 — Капацитет и повреди на сървъра: 429, 500, 502, 503 и 504
От Dorian Chávez · основател и главен изпълнителен директор на Hábil ·
Петък е, последният ден на месеца, 19:05 ч. Услугата за фактуриране на екипа a започна да отговаря странно преди двайсет минути и таблото, което се захранва от revisor, се обагри в червено. Някой от екипа по плащанията, нервен, отвори таблото и натиска „revisar ahora“ отново и отново; браузърът му повтаря заявката на всеки половин секунда, а всяко повторение кара revisor да излезе в мрежата и да пита услуга, за която вече знае, че е недостъпна. В 19:12 дежурният поисква отчета на услугата за каталога и получава 500, чието тяло е стек от извиквания с пълния път до папката на сървъра, името на всеки файл и версията на Node, която върви. Някой копира това тяло в публичния канал за поддръжка, за да поиска помощ. В 19:20 идва въпросът, на който никой не може да отговори: вината наша ли е, или на тях? revisor отговаря 500, когато собственият му код се счупи, 500, когато услугата за фактуриране върне боклук, и 500, когато тази за отчетите се бави твърде много. С един-единствен номер за три различни повреди никой не знае кого да събуди.
Този урок дава на revisor различен номер за всяка от тези ситуации и го учи да премълчава онова, което не бива да излиза навън.
Време: 2 × 45 мин (прекъсването е между раздели 4.4 и 4.5)
Какво изграждаш: пълната мрежа за непредвидената грешка (500 с препратка, а стекът — само в терминала на онзи, който експлоатира системата), заявка на живо, GET /servicios/{id}/salud, в която revisor играе ролята на шлюз и различава бавна зависимост (504) от такава, която не отговаря по HTTP (502), режим на поддръжка, който отговаря с 503 и Retry-After, и ограничение за всяко удостоверение върху онова, което кара revisor да излезе в мрежата (проверките и заявката на живо), което отговаря с 429
Какво научаваш: за всяка повреда да отговаряш кой е виновен и кой може да я поправи; Retry-After в двата му вида, секунди или дата; 500 срещу 502 срещу 504; 503 срещу 500; защо 429 е 4xx, макар да говори за капацитет; какво се казва и какво никога не се казва в грешка на сървъра (стекове, заявки, пътища, тайни); а в рамките за сигурност — какво никога не се казва в грешка, 429 като защита срещу изброяването и изчерпването на ресурси и защо груба сила върху удостоверенията се спира на друг слой
Откъде идваш. От Урок 3, с revisor, който вече различава грешките в състоянието от страна на клиента (не целия регистър на 4xx на IANA, а онези, с които работи курсът): конфликта, бизнес правилото и предусловието, изгубената актуализация, оттегления версия. Този revisor вече не се срива, когато някой манипулатор хвърли изключение: от Урок 1 atenderSinCaerse го хваща и отговаря 500 error interno. Но това е минимална мрежа: 500 не носи нищо, което да позволи да се намери грешката в терминала, и още никой не ти е обяснил какво означава този код. Няма и начин да го прехвърлиш в режим на поддръжка, нито да спреш онзи, който иска проверки без прекъсване, нито да го попиташ на живо как е една услуга.
Какво не прави този урок. Не променя формата на тялото на грешките: те продължават да бъдат един ред текст, а Урок 5 ги превръща в application/problem+json. Не повтаря нищо: revisor чете Retry-After и го отбелязва в резултата на една проверка, но експоненциалното отлагане (backoff) и решението дали да се повтори са на Урок 6, а таблицата, която решава какво да се прави с всеки код, е на Урок 7. Не преподава заглавията RateLimit-*, защото все още са проект на IETF, а не стандарт (раздел 4.6.5). И не симулира истински балансьор на натоварването: шлюзът в този урок е самият revisor, който пита една услуга и ти разказва какво е станало.
За изпълненията. Linux Mint 22 е референтната система на курса. Изходите на този урок са получени на 21 октомври 2026 г. под macOS с Node 24.14.1 и curl 8.7.1, на машина с часовата зона на централно Мексико (UTC−6); само един изход зависи от тази зона, този на упражнение 2, и решението обяснява защо. На машината имаше други програми на портовете на курса, затова лабораторията и revisor бяха стартирани на портове 18481 и 18480, а в изходите бяха пренаписани като 18081 и 18080; дължините не се променят, защото двете числа са петцифрени. При твоето изпълнение няколко неща ще се различават: датата в заглавието Date, препратката на всяко 500 (случаен идентификатор, различен при всяка грешка), измерените милисекунди, осемте последни цифри на всеки ETag и Retry-After на едно 429, който зависи от това колко време е минало от първата заявка на прозореца. Когато терминалът на revisor отпечата стек, папката на машината се записва <carpeta>: пътищата на една машина не се публикуват, а раздел 4.3.4 обяснява защо. revisor държи всичко в паметта: стартирай го отново преди всяка серия заявки, за да получиш същото като в урока. Програмите са в programas/04-capacidad-y-fallas-del-servidor/, в хранилището на курса: revisor във fig04_01/, фиксираният прозорец, разгледан отблизо, във fig04_02.ts, четецът на Retry-After във fig04_03.ts, счупените версии на „Грешката, която ще видиш“ в defectos/, решенията, които съдържат код, в ejercicios/, а всяко изпълнение — в своя файл .salida.txt. Всички преминават tsc в строг режим с командата от раздел 4.7.4.
След урока ще можеш да
- За дадена повреда да кажеш кой е виновен, кой може да я поправи и кой код ѝ съответства:
429,500,502,503или504. - Да предизвикаш
500вrevisorи да намериш стека му в терминала по препратката, която е получил клиентът. - Да различиш с изпълнение
502от504и да обясниш защо недостъпна услуга, която отговаря с503, дава200в заявката за състоянието. - Да разчетеш
Retry-Afterв двата му вида, секунди или HTTP дата, и да посочиш кои стойности не са валидни според RFC 9110. - Да обясниш защо фиксиран прозорец от 3 заявки в секунда може да пропусне 5 заявки за по-малко от 70 милисекунди, с изпълнението, което го показва.
- Да изброиш какво никога не се казва в грешка на сървъра и да демонстрираш с изпълнение какво разкрива един стек, който пътува в тялото.
- Да демонстрираш с изпълнение, че ограничение за всяко удостоверение, проверявано преди търсенето на ресурса, спира онзи, който изпробва идентификатори един след друг, а ограничение за всеки ресурс — не го спира.
Защо, преди как
В Урок 0 видя въпроса, който разделя двете семейства грешки: кой сгреши? Ако е сгрешил онзи, който е поискал, кодът е 4xx; ако е сгрешил онзи, който обслужва, е 5xx. Уроци 2 и 3 бяха посветени на първата половина. Този урок се занимава с втората и добавя въпрос, който е също толкова важен като първия: кой може да я поправи? Защото „вината е на сървъра“ не казва на никого какво да прави. Клиентът трябва да знае дали му е изгодно да изчака, колко, или да се откаже. Онзи, който експлоатира системата, трябва да знае дали проблемът е в неговия код, в неговата конфигурация или в услуга на друг екип. Всеки код в този урок отговаря на двата въпроса по различен начин:
| Код | Какво се случи | Кой е виновен | Кой може да го поправи | Какво прави клиентът |
|---|---|---|---|---|
429 |
поискал си повече, отколкото ти се полага за дадено време | клиентът, заради темпото си | клиентът, като намали темпото | да изчака толкова, колкото казва Retry-After |
500 |
нещо неочаквано се случи в кода на сървъра | сървърът | онзи, който експлоатира сървъра, като поправи кода си | да не настоява за същото; да съобщи препратката |
502 |
сървърът играе ролята на шлюз и онова зад него е отговорило нещо невалидно или не е могло да се свърже | онова, което е зад него | онзи, който експлоатира онова, което е зад него | да изчака или да опита по-късно |
503 |
сървърът не може да обслужва сега: поддръжка или претоварване | никой не е сгрешил: това е решение или граница | времето или онзи, който експлоатира системата | да изчака толкова, колкото казва Retry-After |
504 |
сървърът играе ролята на шлюз и онова зад него не е отговорило навреме | онова, което е зад него | онзи, който експлоатира онова, което е зад него | да изчака или да опита по-късно |
Сцената в началото имаше три проблема и трите се побират в тази таблица. Браузърът, който повтаряше заявката на всеки половин секунда, имаше нужда от 429, който да му каже „намали темпото“ и колко да изчака. Отчетът, който гръмна, имаше нужда от 500, който да не разкрива нищо отвътре, плюс препратка, за да намери дежурният стека в своя терминал. А недостъпното фактуриране имаше нужда от 502 или 504, които да кажат „проблемът е зад мен“, та никой да не търси в кода на revisor грешка, която не беше там. С един-единствен 500 за всичко всеки от тези хора взе грешното решение: браузърът продължи да настоява, тялото на грешката се озова в публичен канал, а дежурният търсеше на погрешното място.
Обърни внимание на първия ред: 429 е 4xx, макар да говори за капацитет. Това е единственото привидно изключение в таблицата и не е изключение: онзи, който може да го поправи, е клиентът, като намали темпото си, а точно това казва семейството 4xx. Сървърът не е сгрешил; клиентът е поискал повече от уговореното. Обратно, 503 е 5xx, макар сървърът да „работи“: сървърът не може да обслужва сега, по своя причина, и никаква промяна в заявката не го поправя.
Таблицата има още една колона, която не се вижда, но минава през целия урок: какво се казва. Грешката на сървъра е моментът, в който един API е най-изкушен да казва прекалено много, защото онзи, който е писал кода, иска да види какво е станало, а най-бързото е да изпрати цялото изключение в отговора. Онова, което е полезно на онзи, който експлоатира системата (стекът, заявката, пътят до файла), е точно онова, което не бива да вижда онзи, който е поискал. Третият принцип на курса го казва: онова, което излиза навън, се проектира.
Уроците имат пет части:
- Лабораторията и въпросът (4.1 и 4.2): трите начина, по които една зависимост се проваля, без да отговори, и въпросът, който решава кода.
500(4.3): препратката, стекът в терминала и рамката за сигурност за онова, което никога не се казва.revisorкато шлюз (4.4):502и504и защо чуждо503е собствено200.- Капацитетът (4.5 и 4.6):
503сRetry-Afterза поддръжката и429за ограничението на заявките, с рамката за сигурност за429като защита. - Пълният
revisor(4.7): файловете и изпълнението на всички случаи.
Концепциите
4.1 revisor в този урок
4.1.1 Какво се променя
revisor в този урок живее в ~/curso-http/fig04_01/. Ако имаш папката от Урок 3, копирай я с това име. Те са тринайсет файла: два нови, шест, които се променят, и пет, които остават както са били. Раздел 4.7 ги дава всичките цялостно; тук е картата, за да знаеш къде е всяко нещо, докато четеш:
| Файл | Какво се променя | Раздел |
|---|---|---|
limitador.ts |
нов: колко пъти може да се направи нещо в прозорец от време | 4.6 |
operacion.ts |
нов: режимът на поддръжка и PUT /mantenimiento |
4.5 |
servidor.ts |
минималната мрежа става пълна: препратка и подрязан стек | 4.3 |
cliente.ts |
consultar различава четири изхода плюс един и чете Retry-After |
4.4 и 4.5 |
servicios.ts |
маршрутите salud и informe и ограниченията на pedirRevision и salud |
4.3, 4.4 и 4.6 |
rutas.ts |
новите маршрути и спирането заради поддръжка | 4.5 |
laboratorio.ts |
три маршрута за провал: /lento/MS, /cortar и /basura |
4.1.2 |
almacen.ts |
още три начални услуги и revisionesDe |
4.1.2 и 4.3 |
autenticacion.ts, destinos.ts, etiquetas.ts, respuestas.ts и versiones.ts не се променят. Тялото на грешките продължава да излиза от fallar, в respuestas.ts, като един ред текст.
4.1.2 Три начина да се провалиш, без да отговориш
За да предизвикаш 502 и 504, ти трябва услуга, която се проваля без да отговори с код. Услуга, която отговаря с 503, не върши работа за това: тя е отговорила и отговорът ѝ е валиден HTTP. Лабораторията, която е тезгяхът от Урок 0 на порт 18081, получава три маршрута, които се провалят по трите начина, по които се проваля истинска услуга, когато не отговаря добре:
const lento = /^\/lento\/(\d{1,5})$/.exec(peticion.url ?? "");
if (lento !== null) {
setTimeout(() => enviar(respuesta, 200, `tardé ${lento[1]} ms`), Number(lento[1]));
return;
}
if (peticion.url === "/cortar") {
peticion.socket.destroy();
return;
}
if (peticion.url === "/basura") {
peticion.socket.end("esto no es una respuesta HTTP\r\n\r\n");
return;
}
/lento/MSотговаря200, но следMSмилисекунди. Това е услугата, която работи, но се бави: претоварена база данни, процес, който е останал да чака друг./cortarзатваря връзката, без да напише нищо. Това е процесът, който е паднал по средата на заявката, или балансьорът, който прекъсва./basuraпише ред, който не е HTTP отговор. Това е услугата, която не говори протокола, който очакваш: грешен порт, друга програма, която слуша там.
Преди да видиш какво прави revisor с тях, виж какво прави curl, който е клиент без посредници. Стартирай лабораторията в един терминал (node laboratorio.ts, от fig04_01/) и изпробвай трите в друг:
$ curl -i http://127.0.0.1:18081/basura
curl: (1) Received HTTP/0.9 when not allowed
$ curl -i -m 1 http://127.0.0.1:18081/lento/3000
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
$ curl -i http://127.0.0.1:18081/cortar
curl: (52) Empty reply from server
Нито един от трите няма статус ред. Нито един няма код. curl приключва с своя грешка и всяка казва нещо различно. Номер 1 е, според ръководството на curl, „неподдържан протокол“, а съобщението обяснява защо: онова, което е написал /basura, не започва с HTTP/1.1, затова curl го е взел за отговор на HTTP/0.9 (без статус ред и без заглавия), а тази версия е изключена, освен ако не я поискаш с --http0.9. На практика: пристигнало е нещо, но не е HTTP. Номер 28 казва, че времето е изтекло: с -m 1 му даде една секунда, а /lento/3000 щеше да се забави три. Номер 52 казва, че сървърът не е отговорил нищо: приел е връзката и я е затворил. В Урок 0 видя кода 7, „не можах да се свържа“: това е четвъртият начин и ще го видиш в упражнение 4.
Когато revisor изпрати заявка към една от тези услуги, той ще бъде на мястото на curl. Разликата е, че curl може да приключи с изходен код, а revisor не може: някой му е направил HTTP заявка и той трябва да отговори с HTTP код. Това е проблемът, който решават 502 и 504.
Началните услуги на almacen.ts растат, за да използват тези маршрути. Към трите от преди (1 catalogo, 2 pagos, 3 nomina) се добавят още три, всички на екипа a:
| id | име | url | граница |
|---|---|---|---|
| 4 | inventario | http://127.0.0.1:18081/lento/1500 |
2000 ms |
| 5 | reportes | http://127.0.0.1:18081/lento/3000 |
1000 ms |
| 6 | facturacion | http://127.0.0.1:18081/basura |
2000 ms |
inventario се бави 1,5 s при граница 2 s: стига навреме, но бавно. reportes се бави 3 s при граница 1 s: не стига. facturacion отговаря с боклук. Тъй като вече има шест начални услуги, първото добавяне във всяко изпълнение получава id 7, а не 4.
4.1.3 Стартиране на двата
Както в предишните уроци, два терминала в ~/curso-http/fig04_01/:
$ node laboratorio.ts
laboratorio en http://127.0.0.1:18081/codigo/NNN
$ node servidor.ts
revisor en http://127.0.0.1:18080
Остави двата да работят. Всички заявки от раздели 4.3 до 4.6 са срещу тази двойка, в реда, в който се появяват, а раздел 4.7.3 ги събира.
4.2 Кой е виновен: въпросът на 5xx
RFC 9110 определя семейството 5xx в раздел 15.6 с една фраза, която си струва да се прочете бавно: сървърът знае, че е сгрешил, или не е в състояние да изпълни поискания метод. Това са две различни неща. „Сгрешил е“ е грешка: нещо в сървъра не е направило това, което е трябвало. „Не е в състояние“ може да не е грешка на никого: сървърът е в поддръжка, или е претоварен, или зависи от друг, който не отговаря. Същият раздел добавя силна препоръка (един SHOULD, не MUST), която вече видя в Урок 0: освен при отговор на HEAD, сървърът трябва да изпрати обяснение на грешката и да каже дали е временна, или постоянна. Тази втора половина е онази, която почти всички забравят, и тя е най-полезната за клиента, защото от нея зависи дали си струва да опита отново.
За да избереш между петте кода на този урок, задавай си тези въпроси по ред:
- Клиентът поискал ли е повече, отколкото му се полага за дадено време? Тогава сървърът не е сгрешил: клиентът трябва да намали темпото. Това е
429, от семейството4xx, сRetry-After, ако знаеш колко трябва да изчака. - Сървърът решил ли е да не обслужва сега, заради поддръжка или защото е претоварен? Това е
503. Никой не е сгрешил; той е временен по определение, аRetry-Afterказва колко се очаква да продължи. - Сървърът играел ли е ролята на шлюз и онова зад него се е провалило ли? Ако онова отзад не е отговорило навреме, това е
504. Ако е отговорило нещо, което не е валиден отговор, или не е могло да се говори с него, това е502. - Нищо от горното и нещо неочаквано е станало в твой собствен код? Това е
500.
500 е на последно място нарочно. Това е кодът, който се използва, когато никой друг не описва случилото се, и затова е най-лесният за прекомерна употреба: всичко, което се обърка, може да се нарече „вътрешна грешка“. Но 500 казва на онзи, който експлоатира системата, „търси в кода си“, и ако повредата е била в зависимост или е била планирана поддръжка, го праща да търси там, където я няма. 500 е признание: „това не го предвидихме“. Всеки път, когато revisor отговаря 500 за нещо, което е могъл да предвиди, той казва нещо невярно за себе си.
Има два въпроса, които дървото не задава и на които е добре да отговориш, преди да продължиш. Първият: ами ако клиентът е изпратил нещо невъзможно? Тогава не е 5xx. Нечетимо тяло е 400, такова над 16 KiB е 413, стойност извън обхвата е 422, и това го видя в Уроци 2 и 3. 5xx за грешка на клиента е също толкова подвеждащ, колкото 200 за грешка: клиентът заключава, че проблемът не е негов, и повтаря същата заявка, която ще се провали по същия начин. Вторият: какво да правим с 5xx, които курсът не използва? 501 беше споменат в Урок 2: той е за метод, който сървърът не реализира в нито един ресурс. 505 е за версия на HTTP, която сървърът не поддържа. Нито един не е грешка на капацитета или на зависимост и revisor няма нужда от тях.
4.3 500: онова, което никой не очакваше
4.3.1 Въпросът, който решава
RFC 9110 определя 500 Internal Server Error в раздел 15.6.1 с едно-единствено изречение: сървърът е срещнал неочаквано условие, което му е попречило да изпълни заявката. Думата, която има значение, е „неочаквано“. Въпросът, който решава 500, е: това предвидихме ли го? Ако отговорът е да, има друг код, който го описва по-добре. Ако е не, това е 500 и честното е да се отговори така, вместо да се прикрива като нещо друго.
На практика 500 е почти винаги изключение, което никой не е хванал: undefined там, където се е очаквал обект, деление на нула, връзка с базата данни, която се е скъсала по средата на заявка. Онова, което сървърът може да направи добре с 500, не е да го избегне (ако можеше, нямаше да е неочаквано), а да го отговори добре: с правилния код, без да казва прекалено много и оставяйки някъде информацията, от която онзи, който експлоатира системата, има нужда, за да го поправи.
4.3.2 Предизвикване
revisor в този урок носи нарочно едно 500, за да можеш да го предизвикаш, когато поискаш. Маршрутът GET /servicios/{id}/informe обобщава проверките на една услуга и има дефект, който убягва на всеки:
// GET /servicios/{id}/informe: el resumen de sus revisiones.
// DEFECTO A PROPÓSITO (lección 4): no contempla un servicio sin revisiones terminadas, y revienta.
// Se deja así en el resto del curso para tener un 500 que provocar.
export function informe({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const revisiones = revisionesDe(servicio.id);
const ultima = revisiones[revisiones.length - 1] as Revision;
enviarJson(respuesta, 200, {
servicio: servicio.id,
revisiones: revisiones.length,
ultimo_estado: (ultima.resultado as NonNullable<Revision["resultado"]>).estado,
});
}
Търси последната проверка на услугата и чете нейния резултат. Работи винаги, когато услугата има поне една завършена проверка. Ако няма нито една, revisiones[revisiones.length - 1] е undefined, а четенето на .resultado върху undefined хвърля TypeError. Двете as в кода казват на TypeScript „довери ми се, тук винаги има проверка“ и TypeScript се доверява: дефектът минава tsc в строг режим без нито едно оплакване. Това е видът грешка, който произвежда 500 в истински API: случаят, за който никой не е помислил, скрит зад едно допускане. Коментарът казва, че дефектът остава такъв до края на курса, за да има винаги едно 500, което да се предизвиква.
С току-що стартирани лаборатория и revisor услуга 1 няма проверки. Поискай нейния отчет два пъти:
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia affc3b8a-5ab9-42f5-8b37-2face1f8eafd
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia bf0e3b0a-790f-4625-80e7-017b0bcfa403
Прочети двата отговора с таблицата на „защо“ в ръка. Кодът е 500: сървърът признава, че нещо неочаквано се е случило, и клиентът знае, че проблемът не е негов. Тялото не казва какво се е случило, нито къде, нито в кой файл. Казва само две неща: че е вътрешна грешка и една препратка. И препратката е различна във всеки отговор: всяка грешка е отделно събитие, дори причината да е същата.
Сега погледни терминала на revisor:
GET /servicios/1/informe
error affc3b8a-5ab9-42f5-8b37-2face1f8eafd en GET /servicios/1/informe: TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:304:28)
at atender (file://<carpeta>/rutas.ts:104:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:23:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:35:44)
Там е всичко, което клиентът не видя: съобщението за грешката (TypeError: Cannot read properties of undefined (reading 'resultado')) и кадрите на стека, от най-скорошния към най-стария. Първият казва къде е родена грешката: функцията informe, в servicios.ts, ред 304, колона 28, което е точно .resultado върху ultima, проверката, която не съществува. Другите три казват как се е стигнало дотам: atender, в rutas.ts, е извикал манипулатора, atenderSinFiltrar, в servidor.ts, е извикал atender, а сървърът на Node е извикал atenderSinFiltrar. А в началото на реда е същата препратка, която е получил клиентът. Ако някой съобщи „излезе ми грешка с препратка affc3b8a-…“, онзи, който експлоатира системата, търси този текст в дневника си и намира точния стек на тази заявка сред хиляди.
4.3.3 servidor.ts: препратката и стекът
В Уроци 1 до 3 servidor.ts имаше минимална мрежа, atenderSinCaerse, която отговаряше 500 error interno и пишеше съобщението в терминала. Този урок я превръща в пълната мрежа, atenderSinFiltrar:
// La pila para la bitácora: el mensaje y solo los marcos del propio curso. Los marcos internos de Node
// (`node:events`, `node:_http_server`…) y los de `node_modules` cambian con cada versión y no los puede corregir quien aprende.
function pilaDelCurso(error: unknown): string {
if (!(error instanceof Error) || error.stack === undefined) return String(error);
return error.stack
.split("\n")
.filter((linea) => !/^\s+at .*(node:|node_modules)/.test(linea))
.join("\n");
}
// Lo inesperado: la pila va a la terminal de quien opera, con una referencia;
// a quien pidió solo le llega la referencia. Jamás la pila, el SQL, las rutas ni los secretos.
async function atenderSinFiltrar(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
try {
await atender(peticion, respuesta);
} catch (error) {
const referencia = randomUUID();
console.error(`error ${referencia} en ${peticion.method} ${peticion.url}: ${pilaDelCurso(error)}`);
if (!respuesta.headersSent) {
fallar(respuesta, 500, `error interno; referencia ${referencia}`);
} else {
respuesta.destroy();
}
}
}
Първо прочети atenderSinFiltrar, която е онази, която хваща. Всичко, което прави revisor, минава през await atender(...) вътре в try. Ако нещо хвърли, във всеки манипулатор, на всякаква дълбочина, изключението се изкачва до този catch. Там стават три неща, в този ред.
Първо, тегли се препратка с randomUUID(), от модула node:crypto, който според документацията на Node генерира случаен UUID идентификатор от версия 4 с криптографски генератор. Това, че е случаен, е важно по две причини. Едната: две грешки практически никога не споделят препратка (един UUID v4 носи 122 случайни бита: колизията е възможна на теория и пренебрежима на практика), така че препратката идентифицира заявка, а не причина. Другата: не разкрива нищо. Последователен номер (error 1043) би казал на онзи, който го получи, колко грешки е имал сървърът ти и с какво темпо; случайният UUID не казва нищо повече от „това събитие“.
Второ, стекът се записва в терминала, с препратката отпред. Терминалът е дневникът на онзи, който експлоатира системата: на истински сървър това би отишло в система за регистри, а принципът е същият.
Трето, отговаря се на клиента, но само ако още може. respuesta.headersSent казва дали статус редът и заглавията вече са излезли. Ако не са излезли, отговаря се 500 с препратката. Ако вече са излезли (манипулатор, който е започнал да отговаря 200 и е хвърлил по средата на тялото), кодът вече не може да се промени: статус редът е първото нещо, което пътува, и не може да се оттегли. Единственото честно е да се прекъсне връзката с respuesta.destroy(), за да види клиентът непълно тяло, а не 200, което изглежда цяло. От страна на curl това се вижда като грешка 18, „непълно предаване“: пристигнали са кодът и заглавията, но не и цялото обещано тяло. Измерено е със сървър играчка, който пишеше abc и прекъсваше: при Content-Length: 100 curl казва (18) transfer closed with 97 bytes remaining to read, а без Content-Length (предаване на части) казва (18) transfer closed with outstanding read data remaining; и в двата случая — грешка 18.
Сега pilaDelCurso. error.stack е текст: съобщението на първия ред и после по един ред за всеки кадър, всеки с at. Функцията запазва съобщението и кадрите, които не съдържат node: нито node_modules. Кадрите на node: са на самия Node (node:events, node:_http_server), а тези на node_modules биха били на зависимостите, ако курсът имаше такива. Махат се по две причини. Първата е практическа: номерата на редовете на вътрешните кадри на Node се менят с всяка версия, така че изход, който ги включва, би се менял при всяко обновяване на Node, дори твоят код да не се е променил. Втората е от занаята: тези кадри не може да ги поправи онзи, който се учи, а почти никога и онзи, който експлоатира системата. Онова, което ти трябва, за да поправиш 500, е в кадрите на твоя код и те си стоят там. Разделът „Грешката, която ще видиш“ показва пълния стек, с включените кадри на Node, и защо тази версия е проблем, когато пътува.
Обърни внимание на името на функцията, atenderSinFiltrar. Чете се като „да обслужим онова, което никой не е филтрирал преди“: това е мрежата за онова, което е избягало от всички манипулатори. Не значи, че не филтрира онова, което излиза: филтрира всичко и до клиента стига само препратката. Ако всеки манипулатор си вършеше добре работата, този catch никога нямаше да се задейства. Затова всеки път, когато се задейства, е новина за онзи, който експлоатира системата, а препратката е нишката, за която да дърпа.
4.3.4 Рамка за сигурност: какво никога не се казва в грешка
Рамка за сигурност. Грешката на сървъра е публична повърхност. Всичко, което пътува в тялото ѝ, може да го прочете онзи, който е направил заявката, може да го запази прокси, може да го копира някой в канал за поддръжка и може да го индексира търсачка, ако отговорът стигне до страница. Какво никога не се казва:
- Стекове от извиквания. Разкриват имена на файлове, на функции и на зависимости и вътрешната структура на програмата.
- Пътища на машината.
file:///home/despliegue/revisor/servicios.tsказва с кой потребител върви процесът, как е организиран сървърът и къде да се търси.- SQL заявки и имена на таблици или колони. Грешка от база данни, която повтаря заявката, учи онзи, който търси инжекция, точно какво изгражда сървърът с неговия вход.
- Тайни. Низове за връзка, токени, пароли, ключове. Появяват се в грешки повече, отколкото изглежда: изключение, което отпечатва целия конфигурационен обект, или URL с удостоверения вътре.
- Версии и имена на продукти. Уеб сървърът, езикът, рамката и нейната версия казват на нападателя кои известни уязвимости да изпробва.
- Имена на машини и вътрешни адреси. Същото, което
revisorвече избягва при отхвърлянето на SSRF от Урок 1: съобщението казва, че едно име се разрешава до частен адрес, но не до кой.Каталогът на често срещаните слабости на MITRE има име за това: CWE-209, „генериране на съобщение за грешка, съдържащо чувствителна информация“, и посочва сред примерите си изложените стекове, пълните пътища до файлове и структурата на заявките. Наръчникът с препоръки на OWASP за обработка на грешки го обобщава в същото правило, което следва
revisor: подробностите за грешката се записват от страна на сървъра, за да се разследват, и не се връщат на онзи, който е поискал, а той получава общо съобщение.Препратката е мостът. Без нея премълчаването на стека би направило
500безполезно за всички: клиентът не би знаел какво да съобщи, а онзи, който експлоатира системата, не би знаел какво да търси. С нея клиентът има идентификатор, който не разкрива нищо, а онзи, който експлоатира системата, има в дневника си всичко, което клиентът не е видял. Това е начинът да не казваш нищо, без да спираш да помагаш.Премълчавай и текста на грешката. Най-честата грешка не е да изпратиш целия стек, а да изпратиш само
error.message, което изглежда безобидно. Не е. Съобщението на едно изключение го пише онзи, който го е хвърлил, а това може да е библиотека, която не контролираш, и не знаеш какво ще съдържа: съобщението на драйвер за база данни може да носи фрагмента SQL, който се е провалил, а това на мрежова грешка — вътрешния адрес, към който не е могло да се свърже. Вrevisorще го видиш в раздел 4.4.2: когато една услуга отговори с боклук,fetchказваResponse does not match the HTTP/1.1 protocol (Expected HTTP/, RTSP/ or ICE/)и този текст се запазва вmotivoи не пътува.502казва, с думите наrevisor, „не даде валиден HTTP отговор: прекъсна връзката или отговори нещо, което не е HTTP“. Онова, което излиза навън, го пише твоята програма, със свои думи; текстът на едно изключение никога не се препраща.Дневникът също не е бездънно чекмедже.
revisorзаписва поискания път с неговата заявка (peticion.url, с всичко след?) и ако един API получава токени в URL, там те ще останат записани. Наръчникът с препоръки на OWASP за регистрите препоръчва да не се записват токени за достъп нито чувствителни данни: в истински API се записва пътят без заявката или със задраскани чувствителните стойности.
4.4 502 и 504: когато revisor играе ролята на шлюз
4.4.1 Въпросът, който решава
Двата кода имат общо условие, което RFC 9110 записва и в двете определения: сървърът отговаря така, докато действа като шлюз или прокси (gateway or proxy). Тоест: за да ти отговори, е трябвало да поиска нещо от друг сървър и този друг се е провалил. Раздел 15.6.3 казва, че 502 Bad Gateway означава, че сървърът е получил невалиден отговор от сървъра, при когото е ходил да пита (RFC го нарича inbound server: онзи, който е към произхода, от противоположната на клиента страна). Раздел 15.6.5 казва, че 504 Gateway Timeout означава, че не е получил отговор навреме от сървъра, от когото е имал нужда от нещо, за да завърши заявката.
Двата въпроса, които решават, са следователно:
- За да отговоря, трябваше ли да питам друг? Ако не, не е нито
502, нито504. Сървър, който не зависи от никого и се проваля поради непредвидена грешка в собствената си логика, отговаря500. (Ако причината е, че е претоварен или нарочно е в поддръжка, кодът е503, който този урок разглежда в 4.5;500е за онова, което никой не е предвидил.) - Какво ми отговори другият? Нищо навреме →
504. Нещо, което не е валиден HTTP отговор →502.
Има един случай, който RFC не назовава: не е могло дори да се свърже (никой не е слушал на този порт). Това не е невалиден отговор, защото не е имало отговор; нито е закъснял отговор, защото опитът е приключил начаса. revisor го третира като 502: онова, което е зад него, не е отговорило нищо валидно и проблемът е негов. Това е проектантско решение, не норма. Онова, което не е вариант, е 500: revisor е предвидил, че връзката може да се провали, и кодът му не е виновен.
Обърни внимание на онова, което 502 и 504 не казват: не казват, че revisor е зле. Казват на клиента „проблемът е зад мен“, а на онзи, който експлоатира revisor, казват да не търси в кода си. Това е отговорът на въпроса от 19:20 в сцената в началото.
4.4.2 cliente.consultar: четири изхода плюс един
Клиентската страна на revisor вече питаше услуги от Урок 1 с revisar, която връщаше вече класифициран резултат: ARRIBA, LENTO или CAIDO. За да се отговори като шлюз, трябват повече подробности: не е достатъчно да се знае, че нещо се е объркало, трябва да се знае какво се е объркало. Затова cliente.ts получава една функция, consultar, която връща изхода в суров вид:
// Lo que puede pasar al consultar: o hubo una respuesta HTTP, o no la hubo por una de tres razones.
export type Consulta =
| { tipo: "respuesta"; codigo: number; ms: number; reintentarEn: number | null }
| { tipo: "tiempo-agotado" }
| { tipo: "sin-conexion" }
| { tipo: "respuesta-invalida"; motivo: string }
| { tipo: "destino-prohibido"; motivo: string };
export async function consultar(url: string, limiteMs: number): Promise<Consulta> {
// Antes de CADA conexión se vuelve a revisar el destino: el DNS pudo cambiar desde el alta.
// LÍMITE DECLARADO: esto NO cierra el DNS rebinding. fetch vuelve a resolver el nombre, y entre
// esta revisión y la conexión el nombre puede apuntar a otra IP. La lección 8 lo completa:
// se conecta a la IP ya validada, con http.request y su opción lookup.
const veredicto = await revisarDestino(url);
if (!veredicto.permitido) return { tipo: "destino-prohibido", motivo: veredicto.motivo };
const inicio = performance.now();
try {
const respuesta = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(limiteMs) });
await respuesta.body?.cancel();
return {
tipo: "respuesta",
codigo: respuesta.status,
ms: Math.round(performance.now() - inicio),
reintentarEn: segundosDeEspera(respuesta.headers.get("retry-after")),
};
} catch (error) {
if (error instanceof DOMException && error.name === "TimeoutError") return { tipo: "tiempo-agotado" };
const causa = (error as { cause?: { code?: string; message?: string } }).cause;
if (causa?.code === "ECONNREFUSED" || causa?.code === "ENOTFOUND") return { tipo: "sin-conexion" };
return { tipo: "respuesta-invalida", motivo: causa?.message ?? String(error) };
}
}
Consulta е дискриминирано обединение: тип, който може да бъде в една от пет форми, а полето tipo казва в коя. Само първата, "respuesta", носи код; другите четири са начините да нямаш такъв. Обърни внимание, че типът прави невъзможна една грешка, която се прави много: да измислиш код за онова, което не е било отговор (0, -1, фалшиво 500). Ако не е имало отговор, няма поле codigo, което да се попълни. Урок 0 го постави в „Какво се прави зле“: третирането на „не можах да се свържа“ като код води до загуба на най-полезната информация — дали сървърът е отговорил, или не.
consultar решава така:
- Забранена цел. Преди всяка връзка целта се проверява отново спрямо списъка за SSRF от Урок 1, защото DNS е могъл да се промени след добавянето. Ако не е разрешена, дори не се опитва.
- Отговор. Ако
fetchе върнал отговор, е имало HTTP, с какъвто и да е код. Тялото не се чете (body?.cancel()): за да се знае как е една услуга, е достатъчен нейният код, а четенето на чуждо тяло е харчене на памет за нещо, което няма да се използва.redirect: "manual"прави така, че301от услугата да се съобщи като301и да не се следва:revisorказва какво е отговорила услугата, а не накъде го е пратила. - Изтекло време.
AbortSignal.timeout(limiteMs)прекратява заявката, ако мине границата на услугата, иfetchхвърляDOMExceptionс имеTimeoutError. Документацията на MDN различава това име отAbortError, който се появява, когато отмяната идва отнякъде другаде; затова кодът пита за точното име. - Без връзка. Ако причината за грешката носи кода
ECONNREFUSED(никой не слуша на този порт), не е имало с кого да се говори.ENOTFOUND(името е престанало да съществува) е оставен за пълнота, но почти никога не стига дотук:revisarDestinoвече е разрешил името миг преди това, а ако не е могъл, заявката приключва като забранена цел, с мотиваel nombre … no se pudo resolver. - Невалиден отговор. Всичко останало: свързал се е и онова, което е станало после, не е било HTTP отговор.
/basuraпопада тук, с причинатаResponse does not match the HTTP/1.1 protocol (Expected HTTP/, RTSP/ or ICE/), и/cortarсъщо, с причинатаother side closed(„другата страна затвори“). Текстът се запазва вmotivo, за онзи, който иска да дебъгва от кода, и, както каза рамката, не излиза в никой отговор.
Това, че „невалиден отговор“ е случаят по подразбиране, е решение: ако се появи повреда, която кодът не познава, тя се третира като най-общата от повредите на чужда услуга, а не като грешка на revisor. Ако revisor третираше непознатото като своя грешка, един вид мрежова повреда, който никой не е предвидил, щеше да завърши в catch на servidor.ts и да излезе като 500, пращайки да се търси на погрешното място.
А revisar, функцията от преди, сега е превод на consultar към резултата, който запазва една проверка:
export async function revisar(url: string, limiteMs: number): Promise<Resultado> {
const consulta = await consultar(url, limiteMs);
switch (consulta.tipo) {
case "tiempo-agotado":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "tiempo agotado" };
case "sin-conexion":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "sin conexión" };
case "respuesta-invalida":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "respuesta inválida" };
case "destino-prohibido":
return { estado: "CAIDO", codigo: null, ms: null, detalle: `destino prohibido: ${consulta.motivo}` };
case "respuesta": {
const { codigo, ms, reintentarEn } = consulta;
if (codigo >= 400) {
const espera = reintentarEn === null ? "" : ` (reintentar en ${reintentarEn} s)`;
return { estado: "CAIDO", codigo, ms, detalle: `${codigo}${espera}` };
}
return { estado: ms >= limiteMs / 2 ? "LENTO" : "ARRIBA", codigo, ms, detalle: String(codigo) };
}
}
}
switch покрива петте случая и TypeScript проверява, че нито един не липсва: ако утре някой добави шести тип към Consulta и забрави този switch, функцията вече нямаше да връща Resultado по всички пътища и tsc би го отбелязал. Класификацията не се промени спрямо Урок 1, освен в една подробност: ако услугата е отговорила с грешка и е донесла Retry-After, подробността го отбелязва, 503 (reintentar en 120 s). Раздел 4.5.3 обяснява как се чете тази стойност.
4.4.3 Заявката за състоянието
Проверките от Урок 1 са асинхронни: поискаш една, получаваш 202 и резултатът идва по-късно. За да упражниш 502 и 504, трябва обратното, заявка на живо, в която revisor пита услугата в този момент и ти отговаря с онова, което е станало. Това е маршрутът GET /servicios/{id}/salud:
// GET /servicios/{id}/salud: el revisor consulta el servicio en este momento y hace de pasarela.
// Si hubo respuesta HTTP, la informa con 200, sea cual sea su código. Si no la hubo, la culpa es de
// lo que está detrás: 504 si no contestó a tiempo, 502 si no contestó algo que sea HTTP.
export async function salud({ respuesta, parametros, credencial }: Contexto): Promise<void> {
// La consulta en vivo también sale a la red, igual que una revisión: sin límite, el revisor serviría
// para golpear un servicio ajeno a través de él. Cuenta por credencial, antes de buscar el servicio,
// en una ventana aparte de la de las revisiones.
const decision = permitir(`salud:${credencial.nombre}`, CONSULTAS_POR_VENTANA, VENTANA_MS);
if (!decision.permitido) {
fallar(respuesta, 429, `ya pediste ${CONSULTAS_POR_VENTANA} consultas en vivo en el último minuto`, {
"Retry-After": String(decision.esperaS),
});
return;
}
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const consulta = await consultarAhora(servicio.url, servicio.limiteMs);
switch (consulta.tipo) {
case "respuesta":
enviarJson(respuesta, 200, { servicio: servicio.id, codigo: consulta.codigo, ms: consulta.ms });
return;
case "tiempo-agotado":
fallar(respuesta, 504, `el servicio ${servicio.id} no respondió en ${servicio.limiteMs} ms`);
return;
case "sin-conexion":
fallar(respuesta, 502, `no se pudo conectar con el servicio ${servicio.id}`);
return;
case "destino-prohibido":
fallar(respuesta, 422, `destino prohibido: ${consulta.motivo}`);
return;
case "respuesta-invalida":
fallar(respuesta, 502, `el servicio ${servicio.id} no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP`);
return;
}
}
Първото е ограничение: всяко удостоверение може да поиска 3 заявки на живо в минута, защото всяка кара revisor да излезе в мрежата. Раздел 4.6 го обяснява заедно с ограничението на проверките; тук е достатъчно да знаеш, че съществува, защото решава как се разпределят опитите. След това услугата се търси с encontrar, същата функция, която използват всички маршрути с {id}: услуга на друг екип отговаря 404, също като несъществуваща, както в Урок 2. После се пита и всеки изход има своя код. Изпробвай я с петте услуги на екип a. Това са пет заявки, а всяко удостоверение има по три в минута, така че първите три са с lectura-a, а последните две — с escritura-a:
$ curl -i http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":1,"codigo":200,"ms":8}
$ curl -i http://127.0.0.1:18080/servicios/2/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":2,"codigo":503,"ms":1}
$ curl -i http://127.0.0.1:18080/servicios/4/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 38
Date: Wed, 21 Oct 2026 13:19:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":4,"codigo":200,"ms":1503}
$ curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 504 Gateway Timeout
Content-Type: text/plain; charset=utf-8
Content-Length: 39
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 5 no respondió en 1000 ms
tardó 1.004405 s
$ curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/6/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain; charset=utf-8
Content-Length: 102
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 6 no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP
tardó 0.002184 s
Една по една:
- Услуга 1 (
catalogo) отговаря200с"codigo":200. Услугата е добре, аrevisorсъщо. - Услуга 2 (
pagos) отговаря200с"codigo":503. Това е редът, който обърква най-много, и най-важният в раздела. Услугата за плащания е недостъпна, да, но е отговорила: казала е503, с валиден HTTP.revisorе направил онова, което са му поискали, а то е да установи как е услугата, и го е установил. Неговият отговор е успех, който носи лоша новина вътре. Акоrevisorотговореше тук503, би казал на клиента „аз,revisor, не мога да те обслужа“ и всяка машина по пътя (балансьор, монитор, кеш) би му повярвала: мониторът би отбелязалrevisorкато недостъпен по вина на чужда услуга. - Услуга 4 (
inventario) отговаря200с"ms":1503: забавила се е, но по-малко от границата си от 2000 ms. Това време е истинско: лабораторията е чакала 1500 ms, а останалото е времето на връзката. - Услуга 5 (
reportes) отговаря504иcurlизмери 1,004 секунди: границата на услугата е 1000 ms иrevisorе престанал да чака точно там. Лабораторията щеше да отговори на 3000 ms, ноrevisorвече не слушаше. Тялото казва колко е чакал, което е нужното на клиента, за да разбере какво е станало. - Услуга 6 (
facturacion) отговаря502и отне 2 милисекунди: лабораторията отговори с боклук веднага иrevisorго разпозна като такъв.
Остава петият изход, забранена цел, който отговаря 422. Това е същото правило за SSRF от Урок 1, приложено още веднъж непосредствено преди свързването: ако името на услуга, която е била добавена правилно, сега се разрешава до частен адрес или вече не се разрешава, revisor не се свързва. Тук няма изпълнение, защото не може да се предизвика от лабораторията: всеки URL, който попада в това правило, вече е бил отхвърлен с 422 при добавянето. Защо 422, а не 502? Защото revisor не е стигнал да говори с онова, което е отзад: не е повреда на друг сървър, а запазена стойност (URL на услугата), която днес не може да бъде изпълнена. Това е проектантско решение и има цена, която трябва да се каже: онзи, който е поискал състоянието, не е изпратил нищо грешно, а 422 му говори за стойност, която друг човек е добавил, може би преди месеци. detail казва причината, за да знае какво да поправи в услугата.
Обърни внимание на двете времена. 504 отне колкото казва границата; 502 — почти нищо. Не е случайност: 504, в един прост шлюз като този, струва времето, което е чакал, преди да се откаже, а 502 може да е мигновен. (Шлюз с прекъсвач на веригата или с вече изтекъл срок може да даде 504 почти начаса; онова, което определя 504, е, че срокът за отговора на другия е изтекъл, а не колко време е отнело на шлюза да го каже.) Тази разлика е важна за онзи, който проектира клиент: клиент, който чака 30 секунди един шлюз с граница от 1 секунда, пропилява 29.
Остава случаят /cortar. Няма начална услуга, която да го използва, така че добави една и поискай нейното състояние, пак с escritura-a, което е на втората заявка:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"colgado","url":"http://127.0.0.1:18081/cortar"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-6db25d01"
Content-Type: application/json
Content-Length: 83
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"colgado","url":"http://127.0.0.1:18081/cortar","limite_ms":2000}
$ curl -i http://127.0.0.1:18080/servicios/7/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain; charset=utf-8
Content-Length: 102
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 7 no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP
502, със същото тяло като услуга 6. От гледна точка на revisor услуга, която приема връзката и я затваря, без да каже нищо, не е дала валиден отговор: не е имало статус ред. consultar не различава двата случая (и двата са respuesta-invalida) и затова тялото казва двете неща, които са могли да станат, „прекъсна връзката или отговори нещо, което не е HTTP“, вместо да твърди едно, което може да е невярно. Да се разделят би изисквало да се погледне причината за грешката и не би променило кода, който е онова, което четат машините.
4.4.4 500 срещу 502 срещу 504
И трите са грешки на сървъра и трите се бъркат. Тази таблица събира онова, което ги разделя:
500 |
502 |
504 |
|
|---|---|---|---|
| Имаше ли друг сървър отзад? | не е нужно | да | да |
| Какво се случи? | нещо неочаквано в твоя код | онова отзад отговори нещо невалидно или не можа да се свърже | онова отзад не отговори навреме |
| Къде търси онзи, който експлоатира системата? | в кода на сървъра | в услугата отзад или във връзката | в услугата отзад: бавна е или е недостъпна |
| Колко време отнема отговорът? | колкото време отнеме да се провали | почти винаги малко | поне границата на шлюза |
В revisor |
GET /servicios/1/informe |
GET /servicios/6/salud |
GET /servicios/5/salud |
Краткото правило: 500 е „сгреших аз“; 502 и 504 са „сгреши онзи, който е зад мен“, а между двата разликата е дали е отговорил зле, или не е отговорил навреме.
Следствие от това правило, което разделът „Грешката, която ще видиш“ показва с изпълнение: ако revisor оставеше грешката на fetch да стигне до servidor.ts, 502 и 504 щяха да се превърнат в 500, с препратката и всичко. Отговорът щеше да е много възпитан (без стек, с препратка) и щеше да е грешен: би казал „сгреших аз“, когато онзи, който е сгрешил, е друг.
4.5 503 и Retry-After: затворено за поддръжка
4.5.1 Въпросът, който решава
RFC 9110 определя 503 Service Unavailable в раздел 15.6.4: сървърът не може да обслужи заявката в този момент заради временно претоварване или планирана поддръжка и това вероятно ще се реши след известно време. Сървърът може да изпрати Retry-After, за да подскаже колко да се чака. И добавя бележка, която си струва да се знае: това, че 503 съществува, не задължава претоварен сървър да го използва; някои просто отхвърлят връзката.
Въпросът, който решава, е: сървърът реши ли, или знае ли, че сега не може да обслужва и че това е временно? И двете условия са важни. „Реши или знае“: 503 не е грешка, която е избягала, а отговор, който сървърът е избрал да даде. „Временно“: 503 обещава на клиента, че ако изчака, положението ще се промени. Сървър, който отговаря 503 завинаги, казва нещо невярно.
Разликата с 500 идва оттам. И двата са на сървъра и двата казват на клиента, че проблемът не е негов. Но 500 казва „нещо се обърка и не знам кога ще се оправи“, а 503 казва „не мога сега, върни се по-късно“, много често с точния час. За един клиент разликата е да избере между това да съобщи за грешка и да изчака. За един кеш също: в Урок 0 видя, че stale-if-error позволява на кеша да обслужи предишното копие при 500, 502, 503 или 504, но един монитор, който брои грешките, трябва да брои обявената поддръжка по различен начин от повредата. Ако поддръжката се отговаря с 500, никой не може да ги различи.
4.5.2 Режимът на поддръжка
revisor получава прекъсвач: PUT /mantenimiento, с {"activo": true} или {"activo": false}, който може да използва само удостоверението admin. Докато е включен, всичко останало отговаря 503 с Retry-After: 120. Живее в нов файл, operacion.ts:
let enMantenimiento = false;
const VUELVE_EN_S = 120;
// Si está en mantenimiento, responde 503 y devuelve true. /mantenimiento siempre se atiende.
export function atajarPorMantenimiento(ruta: string, respuesta: ServerResponse): boolean {
if (!enMantenimiento || ruta === "/mantenimiento") return false;
fallar(respuesta, 503, "el revisor está en mantenimiento", { "Retry-After": String(VUELVE_EN_S) });
return true;
}
// PUT /mantenimiento con {"activo": true} o {"activo": false}. Solo la credencial admin.
export async function cambiarMantenimiento({ peticion, respuesta, credencial }: Contexto): Promise<void> {
if (credencial.rol !== "admin") {
fallar(respuesta, 403, "solo quien opera el revisor puede cambiar el mantenimiento");
return;
}
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
if (typeof datos.activo !== "boolean") {
rechazarDatos(respuesta, [{ campo: "activo", detalle: "debe ser true o false" }]);
return;
}
enMantenimiento = datos.activo;
console.log(`operación: mantenimiento ${enMantenimiento ? "encendido" : "apagado"}`);
enviarVacio(respuesta, 204);
}
Две функции. atajarPorMantenimiento се извиква от входната точка, преди всичко останало, и отговаря 503, ако режимът е включен; връща true, за да знае входната точка, че вече е отговорено. cambiarMantenimiento е манипулаторът на PUT /mantenimiento: проверява ролята, чете тялото със същия leerJson както винаги (така тяло, което не е JSON, получава своя 415 или 400, както в Урок 2), изисква булева стойност (иначе 422, със същата rechazarDatos от Урок 3) и сменя режима.
В rutas.ts спирането се добавя във входната точка, до другите спирания, които са преди седемте въпроса:
if (redirigir(url.pathname, url.search, respuesta)) return;
if (atajarPorMantenimiento(url.pathname, respuesta)) return;
if (versionRetirada(url.pathname, respuesta)) return;
Изпробвай го с двете удостоверения, а после поискай две неща, докато трае:
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"activo":true}'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 59
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
solo quien opera el revisor puede cambiar el mantenimiento
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":true}'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a'
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: text/plain; charset=utf-8
Content-Length: 34
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el revisor está en mantenimiento
$ curl -i http://127.0.0.1:18080/no-existe
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: text/plain; charset=utf-8
Content-Length: 34
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el revisor está en mantenimiento
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":false}'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a'
200
Шест отговора и всеки казва нещо:
escritura-aполучава403. Той е пишещ, а маршрутът е за писане, така че мина въпрос 4 на входната точка; но манипулаторът проверява освен това дали еadmin. Включването на поддръжката променя деня на всички екипи и не е работа на един.adminполучава204. Режимът се включи; няма какво да се връща. Терминалът наrevisorго записва:operación: mantenimiento encendido.GET /serviciosполучава503сRetry-After: 120. Тялото казва какво става; заглавието казва колко да се чака; кодът казва, че не е вина на клиента и че е временно./no-existe, без удостоверение, също получава503. Този заслужава пауза. Това е маршрут, който не съществува, поискан от някой, който не е казал кой е, и отговорът не е нито404, нито401. Работата е, че поддръжката е преди седемте въпроса. Причината е същата като в Урок 2 за пренасочванията: докатоrevisorе в поддръжка, никой отговор, който зависи от неговите данни, не е надежден, а да отговориш404на маршрут, който съществува, защото „днес не го обслужвам“, би било лъжа. Един-единствен отговор за всичко е най-честното и освен това не учи никого на нищо: непознатият не научава кои маршрути съществуват.adminизключва режима с още едно204./mantenimientoе единственият маршрут, който се обслужва по време на поддръжката, защото иначе никой не би могъл да го изключи. Обърни внимание, че изключението е записано в саматаatajarPorMantenimiento, а не във входната точка: прекъсвачът и неговото изключение живеят заедно.GET /serviciosотново дава200.
Защо поддръжката е след пренасочванията? Защото пренасочването не предава данни: казва къде е новият маршрут и това си остава вярно по време на поддръжката. Клиент, който поиска /viejo/servicios, получава 301, отива на /servicios и там получава 503. Всеки отговор казва нещо вярно.
4.5.3 Retry-After, в двата му вида
Retry-After е определен в раздел 10.2.3 на RFC 9110. Казва колко трябва да изчака клиентът, преди да поиска отново; при 503 — колко се очаква да продължи недостъпността. Стойността му има две форми и граматиката на RFC ги записва така:
Retry-After = HTTP-date / delay-seconds
delay-seconds = 1*DIGIT
Или HTTP дата (Fri, 31 Dec 1999 23:59:59 GMT, примерът от RFC), или брой секунди (120). 1*DIGIT означава „една или повече цифри“: цяло число без знак, без десетична запетая, без интервали вътре. 1.5 не е валидна стойност, нито -1, нито 2m.
Датата има на свой ред три формата, по исторически причини: раздел 5.6.7 определя предпочитания (Sun, 06 Nov 1994 08:49:37 GMT) и два остарели, които идват от стари версии на HTTP (Sunday, 06-Nov-94 08:49:37 GMT и Sun Nov 6 08:49:37 1994, форматът на функцията asctime на C), и казва, че онзи, който чете дата в HTTP поле, трябва да приема и трите. Онзи, който генерира HTTP дата, трябва да използва само първия.
Кога кой вид е подходящ? Секундите са относителни: „изчакай 120 секунди от получаването на това“. Датата е абсолютна: „върни се от 23:59 нататък“. Секундите са по-устойчиви, защото не зависят от това дали часовникът на клиента и този на сървъра са съгласни. Датата има смисъл, когато сървърът знае конкретен час, например края на обявен прозорец за поддръжка. revisor изпраща секунди във всички случаи.
От страна на клиента cliente.ts чете двата вида със segundosDeEspera и неговия помощник fechaHttp:
// Retry-After trae segundos («120») o una fecha HTTP, y nada más (RFC 9110, sección 10.2.3):
// Retry-After = HTTP-date / delay-seconds, con delay-seconds = 1*DIGIT. Si no es ninguna de las dos,
// se ignora (null) y quien llama usa su espera por omisión. No se usa Date.parse: acepta «1.5», «-1»
// o «12 30» como fechas del pasado (esperar 0 s y reintentar de inmediato) y lee la forma asctime en
// la hora local de la máquina, cuando HTTP siempre habla en GMT.
export function segundosDeEspera(valor: string | null): number | null {
if (valor === null) return null;
if (/^\d+$/.test(valor)) return Number(valor);
const fecha = fechaHttp(valor);
return fecha === null ? null : Math.max(0, Math.ceil((fecha - Date.now()) / 1000));
}
const MESES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MES = MESES.join("|");
// Las tres formas de HTTP-date (RFC 9110, sección 5.6.7). Quien recibe debe aceptar las tres.
const IMF_FIXDATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${MES}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
const RFC850 = new RegExp(
`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${MES})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`,
);
const ASCTIME = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${MES}) ( \\d|\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
// Milisegundos desde 1970 de una HTTP-date, siempre en GMT (Date.UTC, nunca la hora local); null si no lo es.
function fechaHttp(texto: string): number | null {
let partes: [string, string, number, string, string, string] | null = null;
const imf = IMF_FIXDATE.exec(texto);
const viejo = RFC850.exec(texto);
const asc = ASCTIME.exec(texto);
if (imf !== null) partes = [imf[1], imf[2], Number(imf[3]), imf[4], imf[5], imf[6]];
else if (asc !== null) partes = [asc[2].trim(), asc[1], Number(asc[6]), asc[3], asc[4], asc[5]];
else if (viejo !== null) {
// Año de dos cifras: si queda más de 50 años en el futuro, es del siglo anterior (sección 5.6.7).
const actual = new Date().getUTCFullYear();
let anio = Math.floor(actual / 100) * 100 + Number(viejo[3]);
if (anio > actual + 50) anio -= 100;
partes = [viejo[1], viejo[2], anio, viejo[4], viejo[5], viejo[6]];
}
if (partes === null) return null;
const [dia, mes, anio, horas, minutos, segundos] = partes;
const m = MESES.indexOf(mes);
const ms = Date.UTC(anio, m, Number(dia), Number(horas), Number(minutos), Number(segundos));
// Date.UTC acepta el 32 de octubre o las 25:00 y los pasa al día siguiente: se rechazan.
const comprobada = new Date(ms);
const exacta = comprobada.getUTCDate() === Number(dia) && comprobada.getUTCMonth() === m
&& comprobada.getUTCHours() === Number(horas) && comprobada.getUTCMinutes() === Number(minutos)
&& comprobada.getUTCSeconds() === Number(segundos);
return exacta ? ms : null;
}
Ако заглавието липсва, segundosDeEspera връща null: няма указание. Ако са само цифри, това са секундите. Ако не, моли fechaHttp да го прочете като дата и изчислява колко секунди остават, закръглявайки нагоре; дата, която вече е минала, дава 0, „можеш да опиташ веднага“. Ако не е дата, връща null: стойност, която не се разбира, не е указание и онзи, който вика, използва собственото си чакане по подразбиране.
fechaHttp е дълга, защото прави онова, което RFC изисква, и нищо повече. Има по един регулярен израз за всеки от трите формата, с имената на дните и месеците, които изброява RFC; сглобява датата с Date.UTC, защото HTTP дата винаги е в GMT, макар че формата asctime не го пише; прилага правилото на RFC 850 за двуцифрената година (ако остане на повече от 50 години в бъдещето, е от предишния век); и накрая проверява дали сглобената дата е същата, която е била поискана, защото Date.UTC приема 32 октомври и без да се оплаква го превръща в 1 ноември. Онова, което не прави, е най-изкушаващото: да предаде текста на Date.parse. Коментарът казва защо, а упражнение 2 ти го показва с изпълнение: Date.parse разбира като дата почти всичко и чете формата asctime в местния час на машината.
Тази програма изпробва segundosDeEspera с пет стойности и с отсъстващо заглавие:
// fig04_03.ts — leer Retry-After en sus dos formas: segundos o una fecha HTTP.
import { segundosDeEspera } from "./fig04_01/cliente.ts";
const dentroDe90 = new Date(Date.now() + 90_000).toUTCString();
const VALORES: (string | null)[] = ["120", "0", dentroDe90, "Wed, 21 Oct 2015 07:28:00 GMT", "mañana", null];
for (const valor of VALORES) {
const texto = valor === null ? "(sin la cabecera)" : JSON.stringify(valor);
console.log(`${texto.padEnd(34)} → ${segundosDeEspera(valor) ?? "null: no dice cuánto esperar"}`);
}
$ node fig04_03.ts
"120" → 120
"0" → 0
"Wed, 21 Oct 2026 13:21:26 GMT" → 90
"Wed, 21 Oct 2015 07:28:00 GMT" → 0
"mañana" → null: no dice cuánto esperar
(sin la cabecera) → null: no dice cuánto esperar
Секундите се четат такива, каквито са; датата след 90 секунди дава 90; тази от 2015 вече е минала и дава 0; а mañana и отсъствието дават null. Упражнение 2 я поставя срещу наивна версия, със стойности, които не са валидни.
Прочетената стойност стига до подробностите на проверката. Ще я видиш в 4.6.3: проверка на услуга 2, която отговаря 503 с Retry-After: 120 от лабораторията, остава с "detalle":"503 (reintentar en 120 s)". revisor още не прави нищо с това число, освен да го отбелязва; Урок 6 решава кога си струва да се изчака и повтори и кога не.
4.5.4 503 срещу 500
Бърз начин да не ги объркаш е да се запиташ кой е знаел. 503 го отговаря сървър, който знае, че не може да обслужва: пуснали са го в поддръжка или сам е измерил, че е претоварен (твърде много отворени връзки, пълна опашка). 500 го отговаря сървър, който е научил за грешката си едновременно с клиента. Ако си знаел и е временно, е 503; ако не си знаел, е 500.
Има един граничен случай, върху който си струва да помислиш: база данни, която не отговаря. За сървъра на API това 500 ли е, или 503? Ако кодът се опита да се свърже, провали се и изключението се изкачи до мрежата на servidor.ts, излиза 500 и не е зле: никой не е предвидил повредата. Ако кодът открие, че базата не е достъпна, и го каже нарочно, 503 с Retry-After е по-полезно, защото казва на клиента, че е временно. А има и такива, които отговарят 502 или 504, с аргумента, че базата данни е сървър, който е отзад. RFC не решава този случай: това е проектантско решение и важното е да го решиш, да го запишеш и да го прилагаш по един и същи начин във всички маршрути. revisor запазва 502 и 504 за заявката за състоянието, където играе ролята на шлюз изрично, защото работата му е да отговори как е друг сървър.
4.6 429: твърде много заявки
4.6.1 Въпросът, който решава
429 Too Many Requests не е в RFC 9110. Определи го през 2012 г. RFC 6585, в раздел 4, заедно с 428 от Урок 3. Определението е кратко: потребителят е изпратил твърде много заявки за дадено време („rate limiting“, ограничаване на честотата). Отговорът трябва да обяснява положението и може да носи Retry-After. Примерът на RFC е сайт, който позволява 50 заявки на час на всеки идентифициран потребител и отговаря с Retry-After: 3600.
Същият раздел казва две неща, които си струва да се подчертаят. Първото: RFC не определя как сървърът идентифицира потребителя, нито как брои заявките. Може да брои за ресурс, за целия сървър или между няколко сървъра и може да идентифицира потребителя по удостоверението му или по бисквитка. Тоест: колко, на какъв интервал и за кого са твои решения и затова трябва да са документирани. Второто: отговор 429 не бива да се записва в кеш. Има смисъл: „имаш твърде много“ е вярно за теб и сега, не за следващия клиент нито след минута.
Въпросът, който решава, е: клиентът поискал ли е повече, отколкото му се полага за дадено време? Ако отговорът е да, проблемът се решава с време и с клиент, който намалява темпото; затова е 4xx. Ако сървърът е натоварен от сбора на всички клиенти, а не от един конкретен, това не е 429, а 503: никой клиент не е направил нищо лошо.
RFC 6585 има и предупреждение в съображенията си за сигурността (раздел 7.2): когато сървърът е под атака, отговарянето с 429 на всяка заявка също харчи ресурси, така че сървърът не е задължен да използва 429; понякога е по-добре да прекъсне връзката. 429 служи както за клиента, който прекалява, така и за спиране на онзи, който злоупотребява; но при атака с голям обем отговарянето на всяка заявка също струва и RFC оставя на сървъра да прекъсне връзката, вместо да отговаря.
4.6.2 Фиксираният прозорец
Ограничението на revisor живее в нов файл, limitador.ts. Това е най-простият алгоритъм, който съществува, фиксираният прозорец: брои колко пъти е използван един ключ, откакто е започнал прозорецът, и когато прозорецът свърши, започва друг от нула:
// fig04_01/limitador.ts — cuántas veces se puede hacer algo en una ventana de tiempo.
// Ventana fija: sencillo de leer; las ventanas deslizantes reparten mejor, pero enseñan lo mismo.
type Ventana = { inicio: number; usos: number };
const ventanas = new Map<string, Ventana>();
export type Decision = { permitido: true } | { permitido: false; esperaS: number };
export function permitir(clave: string, maximo: number, ventanaMs: number): Decision {
const ahora = Date.now();
const ventana = ventanas.get(clave);
if (ventana === undefined || ahora - ventana.inicio >= ventanaMs) {
ventanas.set(clave, { inicio: ahora, usos: 1 });
return { permitido: true };
}
if (ventana.usos < maximo) {
ventana.usos++;
return { permitido: true };
}
return { permitido: false, esperaS: Math.ceil((ventana.inicio + ventanaMs - ahora) / 1000) };
}
Всеки ключ има свой прозорец: кога е започнал и колко употреби има. permitir(clave, maximo, ventanaMs) задава три въпроса. Няма ли прозорец или вече е свършил? Започва нов, с една употреба, и се разрешава. Има ли прозорец и остават ли употреби? Брои се още една и се разрешава. Свършили ли са? Отхвърля се и се казва колко секунди остават до края на прозореца, закръглени нагоре. Това число е онова, което пътува в Retry-After: revisor не измисля чакане, изчислява го. Закръглянето нагоре е важно: ако остават 0,3 секунди, казването на 0 би поканило клиента да опита веднага и той отново би получил 429.
limitador.ts не знае нищо за HTTP: не получава заявки и не отговаря нищо. Получава ключ и връща решение. Така може да се използва от всеки манипулатор и може да се тества сам, както ще видиш в 4.6.4.
4.6.3 В revisor: три в минута, за всяко удостоверение
Какво се ограничава? В revisor най-много струва онова, което го кара да излезе в мрежата: да поискаш проверка и да поискаш състоянието на живо (4.4.3). Без ограничение всеки с едно удостоверение може да го накара да прави хиляди връзки в минута срещу една услуга, своя или чужда, което е точно онова, което ставаше в сцената в началото. Ограничението е 3 проверки за удостоверение на всеки 60 секунди, в pedirRevision, и още 3 заявки на живо за удостоверение, в отделен прозорец, в salud:
export function pedirRevision({ respuesta, parametros, credencial }: Contexto): void {
// El límite cuenta por credencial y se consulta ANTES de buscar el servicio: así también cuentan los
// 404 de quien prueba ids uno tras otro (enumeración), y lo que gasta una credencial no lo paga otra.
const decision = permitir(`revisiones:${credencial.nombre}`, REVISIONES_POR_VENTANA, VENTANA_MS);
if (!decision.permitido) {
fallar(respuesta, 429, `ya pediste ${REVISIONES_POR_VENTANA} revisiones en el último minuto`, {
"Retry-After": String(decision.esperaS),
});
return;
}
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const revision = crearRevision(servicio.id);
setTimeout(async () => {
terminarRevision(revision.id, await revisar(servicio.url, servicio.limiteMs));
}, ESPERA_EN_COLA_MS);
enviarJson(respuesta, 202, representarRevision(revision), { Location: `/revisiones/${revision.id}` });
}
Две решения и двете са по-важни, отколкото изглеждат.
Ключът е удостоверението: revisiones: плюс името на удостоверението. Така онова, което харчи едно удостоверение, не го плаща друго. Ако ключът беше услугата, едно-единствено удостоверение би могло да изразходва трите проверки на една услуга и да остави всички останали една минута без възможност да я проверяват: 429 би стигнал до онзи, който не е направил нищо, а точно това кодът не бива да казва. RFC 6585 позволява броене за ресурс, за сървър или за потребител; revisor брои за онзи, който пита, защото 429 казва на този клиент да намали своето темпо.
Ограничението се пита преди encontrar. Така се броят и заявките, които завършват с 404. Изглежда подробност и е разликата между ограничение, което спира онзи, който изпробва идентификатори един след друг, и ограничение, което не го вижда: ако ограничението дойдеше по-късно, всяко 404 щеше да излезе, преди да докосне ограничителя, и онзи, който изброява, никога нямаше да изразходва нито един прозорец. Има и друга полза: ключът идва от нещо, което входната точка вече е проверила (удостоверението), а не от идентификатор, който клиентът пише, така че никой не може да създава безкрайно ключове в Map на limitador.ts, измисляйки id-та.
Изпробвай го: три проверки на услуга 2 с escritura-a, четвърта и една на услуга 4 с admin. След това четвърта заявка на живо с lectura-a, който вече е направил три в 4.4.3, и изброителят от Урок 2 с escritura-b, който иска проверки на id-та от 1 до 10. Накрая изчакай 20 секунди и опитай отново тази на escritura-a:
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain; charset=utf-8
Content-Length: 45
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 revisiones en el último minuto
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/4/revisiones -H 'Authorization: Bearer admin'
202
$ curl -i http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 429 Too Many Requests
Retry-After: 58
Content-Type: text/plain; charset=utf-8
Content-Length: 52
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 consultas en vivo en el último minuto
$ for id in 1 2 3 4 5 6 7 8 9 10; do curl -sS -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$id/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
404 404 202 429 429 429 429 429 429 429
$ sleep 20
$ curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
HTTP/1.1 429 Too Many Requests
Retry-After: 40
Content-Type: text/plain; charset=utf-8
Content-Length: 45
Date: Wed, 21 Oct 2026 13:19:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 revisiones en el último minuto
Ред по ред:
- Първите три,
202, както в Урок 1. Четвъртата,429, сRetry-After: 60: прозорецът наescritura-aе започнал преди миг и му остават 60 секунди. Тялото казва какво е станало с думите наrevisor, а кодът и заглавието казват какво да се направи. - Тази на
admin,202: има собствен прозорец. Онова, което е изразходвалоescritura-a, не го плаща друго удостоверение. - Четвъртата заявка на живо на
lectura-a,429, сRetry-After: 58: прозорецът ѝ за заявки е започнал с първата заявка от 4.4.3, няколко секунди по-рано. Това е друг прозорец, отделен от този на проверките: поискването на състоянието не харчи проверки, нито обратното. - Изброителят,
404 404 202 429 429 429 429 429 429 429.escritura-bе от екип b: за него 1 и 2 не съществуват (404), а 3 е негова (202). Тези три заявки напълниха прозореца му, макар две да бяха404, и от 4 до 10 получава429. С това темпо обхождането на сто хиляди идентификатора би отнело повече от три седмици. - Двайсет секунди по-късно, отново
429, но сега сRetry-After: 40. Числото не е фиксирано: казва колко наистина остава. Клиент, който се подчинява на това заглавие, не прави нито една излишна заявка.
Трите приети проверки на escritura-a и тази на admin вече приключиха. Погледни първата от всяка и отчета на услуга 2, която сега вече има проверки:
$ curl http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer lectura-a'
{"id":1,"servicio":2,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":503,"ms":1,"detalle":"503 (reintentar en 120 s)"}}
$ curl http://127.0.0.1:18080/revisiones/4 -H 'Authorization: Bearer lectura-a'
{"id":4,"servicio":4,"estado":"terminada","resultado":{"estado":"LENTO","codigo":200,"ms":1501,"detalle":"200"}}
$ curl -i http://127.0.0.1:18080/servicios/2/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 54
Date: Wed, 21 Oct 2026 13:19:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":2,"revisiones":3,"ultimo_estado":"CAIDO"}
Проверката на pagos остана CAIDO, с "detalle":"503 (reintentar en 120 s)": лабораторията отговори 503 с Retry-After: 120 и segundosDeEspera го прочете. Тази на inventario, LENTO, с 1501 ms при граница 2000: отне половината от границата или повече. А отчетът на услуга 2 отговаря 200: дефектът на informe хапе само когато няма завършени проверки, а тук има три.
4.6.4 Фиксираният прозорец, разгледан отблизо
Фиксираният прозорец се чете лесно и има дефект, който си струва да се види с очите. Тази програма използва същия limitador.ts на revisor, с ограничение 3 на всеки 1000 ms, за да не се чака една минута, и прави седем заявки в избрани моменти:
// fig04_02.ts — la ventana fija, vista de cerca: 3 permisos cada 1000 ms.
// Usa el mismo limitador del revisor, con una ventana corta para no esperar un minuto.
import { setTimeout as esperar } from "node:timers/promises";
import { permitir } from "./fig04_01/limitador.ts";
const MAXIMO = 3;
const VENTANA_MS = 1000;
// Cuándo pide cada petición, en milisegundos desde el arranque.
const PLAN = [0, 950, 960, 1005, 1010, 1015, 1020];
const inicio = performance.now();
const permitidas: number[] = [];
for (const cuando of PLAN) {
await esperar(Math.max(0, cuando - (performance.now() - inicio)));
const decision = permitir("demo", MAXIMO, VENTANA_MS);
const ms = Math.round(performance.now() - inicio);
if (decision.permitido) permitidas.push(ms);
const texto = decision.permitido ? "permitida" : `rechazada: Retry-After ${decision.esperaS}`;
console.log(`${String(ms).padStart(5)} ms ${texto}`);
}
const rafaga = permitidas.filter((ms) => ms >= 900);
console.log();
console.log(`límite: ${MAXIMO} cada ${VENTANA_MS} ms`);
console.log(`permitidas entre ${rafaga[0]} y ${rafaga[rafaga.length - 1]} ms: ${rafaga.length}`);
$ node fig04_02.ts
2 ms permitida
950 ms permitida
961 ms permitida
1005 ms permitida
1011 ms permitida
1016 ms permitida
1021 ms rechazada: Retry-After 1
límite: 3 cada 1000 ms
permitidas entre 950 y 1016 ms: 5
Ограничението казва „3 в секунда“, а между милисекунда 950 и 1016 минаха 5. Първата заявка отвори прозореца в първите милисекунди; тези от 950 и 961 го напълниха; в милисекунда 1000 прозорецът свърши и започна друг, който прие още три. Седмата, в 1021, беше отхвърлена с Retry-After: 1. На границата между два прозореца фиксираният прозорец пропуска до двойно повече от ограничението за много кратко време. Точните милисекунди се менят при всяко изпълнение (таймерите на Node не са точни до милисекунда), но петицата — не.
Сериозно ли е? Зависи от това какво пазиш. За revisor, който иска да избегне хиляди заявки в минута, не е важно, че една услуга получава шест вместо три при смяната на минутата. За ограничение, което пази нещо крехко, може да е важно и затова съществуват други алгоритми: плъзгащият се прозорец, който брои заявките от последните 60 секунди, считано от сега, и кофата с жетони (token bucket), която възстановява разрешения малко по малко и позволява малък откос, но никога двойно. И двата разпределят по-добре и учат същото като фиксирания прозорец: има максимум, има време и когато се свърши, 429 с колко да се чака. Коментарът в limitador.ts го казва с един ред.
4.6.5 Рамка за сигурност: 429 като защита
Рамка за сигурност. Ограничението на заявките не защитава само капацитета. То е и защита, защото много атаки изискват много заявки:
- Изброяването. В Урок 2 видя изброителя, който изпробва
/servicios/1,/servicios/2,/servicios/3… Входната точка наrevisorвече отговаря по един и същи начин на чуждото и на несъществуващото, така че няма какво да се научи от отговорите. Ограничението добавя още един слой, в случай че някой ден се изплъзне разлика: тъй като брои за удостоверение и преди да търси услугата, броят се и404, а в 4.6.3 видя изброителя спрян на четвъртата заявка. Онова, което го кара да работи, е броенето за онзи, който пита; ограничение за ресурс никога нямаше да го види, защото онзи, който изброява, всеки път иска различен ресурс. Упражнение 3 ти го показва с двете версии.- Изчерпването на ресурси. OWASP го поставя в списъка си с десетте най-важни рискове за API като API4:2023, Unrestricted Resource Consumption и препоръчва да се ограничава колко пъти един клиент може да взаимодейства с API за дадено време, с по-строги ограничения за скъпите операции, и да се задават ограничения за времето за изпълнение.
revisorправи и двете: ограничава онова, което излиза в мрежата (проверките и заявката на живо), а всяка заявка има ограничение във времето (limite_ms, от 100 до 10 000), което е онова, което произвежда504.revisorкато оръжие срещу други. Това е другото лице на SSRF от Урок 1. Списъкът с цели решава накъде се свързваrevisor; ограничението решава колко пъти. Без него всеки с едно удостоверение би могъл да използваrevisor, за да удря чужда услуга, а услугата би виждала връзките да идват отrevisor, а не от онзи, който ги е поискал.Какво това ограничение не решава: груба сила върху удостоверенията. Налучкването на удостоверение с изпробване на милиони стойности е атака с много заявки, но
revisorне я спира с ограничителя си и това е нарочно. Ограничение за удостоверение не върши работа срещу онзи, който още няма такова, а ограничение за адрес на произход, в този курс, би спряло и матриците, и програмите от уроците, които всички вървят от127.0.0.1. Грубата сила се спира на друг слой: доставчикът на идентичност или шлюзът, който е пред API, с дълги и случайни удостоверения, които не могат да се налучкат. Наръчникът с препоръки на OWASP за удостоверяване описва този слой: максимален брой неуспешни опити, броени за сметка, а не само за адрес, защото нападателят може да смени адреса. Четирите удостоверения наrevisorса думи за упражнение, записани в кода, за да може курсът да се следва; в истински API идват от доставчик на идентичност, както каза Урок 2.Ключът на ограничението решава кого защитаваш. За удостоверение, както прави
revisor, разпределя капацитета между клиентите и не наказва един за онова, което е направил друг. За ресурс, защитава ресурса, но всеки може да изразходва прозореца на всички. За адрес на произход, спира онзи, който няма удостоверение, но наказва всички, които споделят един и същи изход към интернет. RFC 6585 го оставя отворено нарочно. Онова, което не е по избор, е ключът да идва от нещо, което сървърът е проверил: ключ, направен от свободен текст на клиента, е начин да се напълни паметта на ограничителя.Какво още не е стандарт. Много API изпращат, освен
Retry-After, заглавия, които казват колко заявки остават в прозореца. Има проект на IETF за това, RateLimit header fields for HTTP, който определяRateLimit-PolicyиRateLimit; най-новата му версия е 11, от 23 май 2026 г., и все още е проект, а не RFC. А големите API използват свои имена: REST API на GitHub, например, отговаря403или429, когато се надхвърли ограничение, със заглавиятаx-ratelimit-remainingиx-ratelimit-reset, и препоръчва първо да се погледнеretry-after, ако присъства. Затоваrevisorизпраща самоRetry-After, който е стандарт, и затова курсът преподава останалите такива, каквито са: модел, който всеки API документира, а не изискване на HTTP.
4.7 Пълният revisor
4.7.1 Файловете, които се променят
Копирай папката си от Урок 3 като ~/curso-http/fig04_01/. Осем файла са нови или се променят. Първо двата нови:
// fig04_01/operacion.ts — el modo mantenimiento: lo enciende y lo apaga quien opera el revisor.
import type { ServerResponse } from "node:http";
import { enviarVacio, fallar } from "./respuestas.ts";
import { leerJson, rechazarDatos, type Contexto } from "./servicios.ts";
let enMantenimiento = false;
const VUELVE_EN_S = 120;
// Si está en mantenimiento, responde 503 y devuelve true. /mantenimiento siempre se atiende.
export function atajarPorMantenimiento(ruta: string, respuesta: ServerResponse): boolean {
if (!enMantenimiento || ruta === "/mantenimiento") return false;
fallar(respuesta, 503, "el revisor está en mantenimiento", { "Retry-After": String(VUELVE_EN_S) });
return true;
}
// PUT /mantenimiento con {"activo": true} o {"activo": false}. Solo la credencial admin.
export async function cambiarMantenimiento({ peticion, respuesta, credencial }: Contexto): Promise<void> {
if (credencial.rol !== "admin") {
fallar(respuesta, 403, "solo quien opera el revisor puede cambiar el mantenimiento");
return;
}
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
if (typeof datos.activo !== "boolean") {
rechazarDatos(respuesta, [{ campo: "activo", detalle: "debe ser true o false" }]);
return;
}
enMantenimiento = datos.activo;
console.log(`operación: mantenimiento ${enMantenimiento ? "encendido" : "apagado"}`);
enviarVacio(respuesta, 204);
}
limitador.ts е цялостен в раздел 4.6.2. Шестте, които се променят, цялостни:
// fig04_01/servidor.ts — la cara servidor del revisor: la API que administra los servicios observados.
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { fallar } from "./respuestas.ts";
import { atender } from "./rutas.ts";
const PUERTO = 18080;
// La pila para la bitácora: el mensaje y solo los marcos del propio curso. Los marcos internos de Node
// (`node:events`, `node:_http_server`…) y los de `node_modules` cambian con cada versión y no los puede corregir quien aprende.
function pilaDelCurso(error: unknown): string {
if (!(error instanceof Error) || error.stack === undefined) return String(error);
return error.stack
.split("\n")
.filter((linea) => !/^\s+at .*(node:|node_modules)/.test(linea))
.join("\n");
}
// Lo inesperado: la pila va a la terminal de quien opera, con una referencia;
// a quien pidió solo le llega la referencia. Jamás la pila, el SQL, las rutas ni los secretos.
async function atenderSinFiltrar(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
try {
await atender(peticion, respuesta);
} catch (error) {
const referencia = randomUUID();
console.error(`error ${referencia} en ${peticion.method} ${peticion.url}: ${pilaDelCurso(error)}`);
if (!respuesta.headersSent) {
fallar(respuesta, 500, `error interno; referencia ${referencia}`);
} else {
respuesta.destroy();
}
}
}
createServer((peticion, respuesta) => void atenderSinFiltrar(peticion, respuesta)).listen(PUERTO, "127.0.0.1", () => {
console.log(`revisor en http://127.0.0.1:${PUERTO}`);
});
// fig04_01/cliente.ts — la cara cliente del revisor: consulta un servicio y clasifica lo que respondió.
import type { Resultado } from "./almacen.ts";
import { revisarDestino } from "./destinos.ts";
// Lo que puede pasar al consultar: o hubo una respuesta HTTP, o no la hubo por una de tres razones.
export type Consulta =
| { tipo: "respuesta"; codigo: number; ms: number; reintentarEn: number | null }
| { tipo: "tiempo-agotado" }
| { tipo: "sin-conexion" }
| { tipo: "respuesta-invalida"; motivo: string }
| { tipo: "destino-prohibido"; motivo: string };
// Retry-After trae segundos («120») o una fecha HTTP, y nada más (RFC 9110, sección 10.2.3):
// Retry-After = HTTP-date / delay-seconds, con delay-seconds = 1*DIGIT. Si no es ninguna de las dos,
// se ignora (null) y quien llama usa su espera por omisión. No se usa Date.parse: acepta «1.5», «-1»
// o «12 30» como fechas del pasado (esperar 0 s y reintentar de inmediato) y lee la forma asctime en
// la hora local de la máquina, cuando HTTP siempre habla en GMT.
export function segundosDeEspera(valor: string | null): number | null {
if (valor === null) return null;
if (/^\d+$/.test(valor)) return Number(valor);
const fecha = fechaHttp(valor);
return fecha === null ? null : Math.max(0, Math.ceil((fecha - Date.now()) / 1000));
}
const MESES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MES = MESES.join("|");
// Las tres formas de HTTP-date (RFC 9110, sección 5.6.7). Quien recibe debe aceptar las tres.
const IMF_FIXDATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${MES}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
const RFC850 = new RegExp(
`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${MES})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`,
);
const ASCTIME = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${MES}) ( \\d|\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
// Milisegundos desde 1970 de una HTTP-date, siempre en GMT (Date.UTC, nunca la hora local); null si no lo es.
function fechaHttp(texto: string): number | null {
let partes: [string, string, number, string, string, string] | null = null;
const imf = IMF_FIXDATE.exec(texto);
const viejo = RFC850.exec(texto);
const asc = ASCTIME.exec(texto);
if (imf !== null) partes = [imf[1], imf[2], Number(imf[3]), imf[4], imf[5], imf[6]];
else if (asc !== null) partes = [asc[2].trim(), asc[1], Number(asc[6]), asc[3], asc[4], asc[5]];
else if (viejo !== null) {
// Año de dos cifras: si queda más de 50 años en el futuro, es del siglo anterior (sección 5.6.7).
const actual = new Date().getUTCFullYear();
let anio = Math.floor(actual / 100) * 100 + Number(viejo[3]);
if (anio > actual + 50) anio -= 100;
partes = [viejo[1], viejo[2], anio, viejo[4], viejo[5], viejo[6]];
}
if (partes === null) return null;
const [dia, mes, anio, horas, minutos, segundos] = partes;
const m = MESES.indexOf(mes);
const ms = Date.UTC(anio, m, Number(dia), Number(horas), Number(minutos), Number(segundos));
// Date.UTC acepta el 32 de octubre o las 25:00 y los pasa al día siguiente: se rechazan.
const comprobada = new Date(ms);
const exacta = comprobada.getUTCDate() === Number(dia) && comprobada.getUTCMonth() === m
&& comprobada.getUTCHours() === Number(horas) && comprobada.getUTCMinutes() === Number(minutos)
&& comprobada.getUTCSeconds() === Number(segundos);
return exacta ? ms : null;
}
export async function consultar(url: string, limiteMs: number): Promise<Consulta> {
// Antes de CADA conexión se vuelve a revisar el destino: el DNS pudo cambiar desde el alta.
// LÍMITE DECLARADO: esto NO cierra el DNS rebinding. fetch vuelve a resolver el nombre, y entre
// esta revisión y la conexión el nombre puede apuntar a otra IP. La lección 8 lo completa:
// se conecta a la IP ya validada, con http.request y su opción lookup.
const veredicto = await revisarDestino(url);
if (!veredicto.permitido) return { tipo: "destino-prohibido", motivo: veredicto.motivo };
const inicio = performance.now();
try {
const respuesta = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(limiteMs) });
await respuesta.body?.cancel();
return {
tipo: "respuesta",
codigo: respuesta.status,
ms: Math.round(performance.now() - inicio),
reintentarEn: segundosDeEspera(respuesta.headers.get("retry-after")),
};
} catch (error) {
if (error instanceof DOMException && error.name === "TimeoutError") return { tipo: "tiempo-agotado" };
const causa = (error as { cause?: { code?: string; message?: string } }).cause;
if (causa?.code === "ECONNREFUSED" || causa?.code === "ENOTFOUND") return { tipo: "sin-conexion" };
return { tipo: "respuesta-invalida", motivo: causa?.message ?? String(error) };
}
}
export async function revisar(url: string, limiteMs: number): Promise<Resultado> {
const consulta = await consultar(url, limiteMs);
switch (consulta.tipo) {
case "tiempo-agotado":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "tiempo agotado" };
case "sin-conexion":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "sin conexión" };
case "respuesta-invalida":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "respuesta inválida" };
case "destino-prohibido":
return { estado: "CAIDO", codigo: null, ms: null, detalle: `destino prohibido: ${consulta.motivo}` };
case "respuesta": {
const { codigo, ms, reintentarEn } = consulta;
if (codigo >= 400) {
const espera = reintentarEn === null ? "" : ` (reintentar en ${reintentarEn} s)`;
return { estado: "CAIDO", codigo, ms, detalle: `${codigo}${espera}` };
}
return { estado: ms >= limiteMs / 2 ? "LENTO" : "ARRIBA", codigo, ms, detalle: String(codigo) };
}
}
}
// fig04_01/servicios.ts — lo que hace cada ruta de la API del revisor.
import type { IncomingMessage, ServerResponse } from "node:http";
import {
buscarPorNombre, buscarRevision, buscarServicio, cambiarServicio, crearRevision, crearServicio,
equipoDeRetirado, etiqueta, listarServicios, retirarServicio, terminarRevision,
revisionesDe, type Revision, type Servicio,
} from "./almacen.ts";
import type { Credencial } from "./autenticacion.ts";
import { revisarDestino } from "./destinos.ts";
import { coincideDebil, coincideFuerte, hayCoincidencia, interpretarLista } from "./etiquetas.ts";
import { consultar as consultarAhora, revisar } from "./cliente.ts";
import { permitir } from "./limitador.ts";
import { CuerpoDemasiadoGrande, enviarJson, enviarVacio, fallar, leerCuerpo, TOPE_CUERPO, type Cabeceras } from "./respuestas.ts";
export type Contexto = {
peticion: IncomingMessage;
respuesta: ServerResponse;
parametros: string[];
credencial: Credencial;
};
const ESPERA_EN_COLA_MS = 1000;
// Pedir una revisión hace salir al revisor a la red: se limita a 3 por credencial cada 60 segundos.
const REVISIONES_POR_VENTANA = 3;
const VENTANA_MS = 60_000;
const CONSULTAS_POR_VENTANA = 3;
export function representar(servicio: Servicio): object {
return { id: servicio.id, nombre: servicio.nombre, url: servicio.url, limite_ms: servicio.limiteMs };
}
function representarRevision(revision: Revision): object {
return { id: revision.id, servicio: revision.servicio, estado: revision.estado, resultado: revision.resultado };
}
function delEquipo(equipo: string, credencial: Credencial): boolean {
return credencial.equipo === "*" || equipo === credencial.equipo;
}
// Busca un servicio que la credencial puede ver. Si no lo encuentra, ya respondió:
// 410 si existió y se retiró, 404 si nunca existió o es de otro equipo (el 404 esconde a propósito).
export function encontrar(id: string, credencial: Credencial, respuesta: ServerResponse): Servicio | undefined {
const servicio = buscarServicio(Number(id));
if (servicio !== undefined && delEquipo(servicio.equipo, credencial)) return servicio;
const equipo = equipoDeRetirado(Number(id));
if (servicio === undefined && equipo !== undefined && delEquipo(equipo, credencial)) {
fallar(respuesta, 410, `el servicio ${id} se retiró y no va a volver`);
} else {
fallar(respuesta, 404, `no existe el servicio ${id}`);
}
return undefined;
}
// 415 si el cuerpo no se declara application/json. Va aparte de leerJson porque el PUT lo pregunta
// antes que If-Match: el 415 se sabe sin procesar el contenido, y el RFC 9110 (sección 13.2.1) dice que
// las precondiciones no se evalúan si sin ellas la respuesta no habría sido 2xx ni 412.
function esJson(peticion: IncomingMessage, respuesta: ServerResponse): boolean {
const tipo = (peticion.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (tipo !== "application/json") {
fallar(respuesta, 415, `el cuerpo debe ser application/json, no ${tipo === "" ? "(sin tipo)" : tipo}`, {
Accept: "application/json",
});
return false;
}
return true;
}
// 415 si no es JSON, 400 si no se puede leer. Política del curso: 400 = no se puede leer.
export async function leerJson(peticion: IncomingMessage, respuesta: ServerResponse): Promise<Record<string, unknown> | undefined> {
if (!esJson(peticion, respuesta)) return undefined;
try {
const datos: unknown = JSON.parse(await leerCuerpo(peticion));
if (typeof datos === "object" && datos !== null && !Array.isArray(datos)) return datos as Record<string, unknown>;
} catch (error) {
// 413 (RFC 9110, sección 15.5.14): el cuerpo pasa del tope. «Connection: close» hace que Node cierre
// la conexión al terminar de responder, en vez de seguir recibiendo lo que el cliente quería mandar.
if (error instanceof CuerpoDemasiadoGrande) {
fallar(respuesta, 413, `el cuerpo pasa de ${TOPE_CUERPO} bytes`, { Connection: "close" });
return undefined;
}
// si no, cae al 400 de abajo
}
fallar(respuesta, 400, "el cuerpo no es un objeto JSON");
return undefined;
}
export type Falla = { campo: string; detalle: string };
type Validos = { nombre: string; url: string; limiteMs: number };
// Política del curso: 422 = se pudo leer, pero no se puede cumplir. Junta todas las fallas, no solo la primera.
function validar(datos: Record<string, unknown>): Validos | Falla[] {
const fallas: Falla[] = [];
const { nombre, url, limite_ms: limite } = datos;
if (typeof nombre !== "string" || nombre.trim() === "" || nombre.length > 40) {
fallas.push({ campo: "nombre", detalle: "debe ser un texto de 1 a 40 caracteres" });
}
let urlValida = false;
if (typeof url === "string") {
try {
urlValida = ["http:", "https:"].includes(new URL(url).protocol);
} catch {
urlValida = false;
}
}
if (!urlValida) fallas.push({ campo: "url", detalle: "debe ser una dirección http o https" });
if (limite !== undefined && (typeof limite !== "number" || !Number.isInteger(limite) || limite < 100 || limite > 10000)) {
fallas.push({ campo: "limite_ms", detalle: "debe ser un entero entre 100 y 10000" });
}
if (fallas.length > 0) return fallas;
return { nombre: nombre as string, url: url as string, limiteMs: (limite as number | undefined) ?? 2000 };
}
export function rechazarDatos(respuesta: ServerResponse, fallas: Falla[]): void {
fallar(respuesta, 422, fallas.map((f) => `${f.campo}: ${f.detalle}`).join("\n"));
}
// SSRF: el revisor se conecta a la URL que alguien escribió. Se revisa al registrarla y otra vez
// antes de cada conexión (en cliente.ts). Si no se permite, ya respondió y devuelve false.
async function destinoPermitido(url: string, respuesta: ServerResponse): Promise<boolean> {
const veredicto = await revisarDestino(url);
if (veredicto.permitido) return true;
fallar(respuesta, 422, `destino prohibido: ${veredicto.motivo}`);
return false;
}
export function listar({ respuesta, credencial }: Contexto): void {
const propios = listarServicios().filter((s) => delEquipo(s.equipo, credencial));
enviarJson(respuesta, 200, { servicios: propios.map(representar) });
}
export async function darDeAlta({ peticion, respuesta, credencial }: Contexto): Promise<void> {
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
const validos = validar(datos);
if (Array.isArray(validos)) {
rechazarDatos(respuesta, validos);
return;
}
if (!(await destinoPermitido(validos.url, respuesta))) {
return;
}
const existente = buscarPorNombre(credencial.equipo, validos.nombre);
if (existente !== undefined) {
fallar(respuesta, 409, `ya existe un servicio llamado ${validos.nombre}: /servicios/${existente.id}`);
return;
}
const servicio = crearServicio({ ...validos, equipo: credencial.equipo });
enviarJson(respuesta, 201, representar(servicio), {
Location: `/servicios/${servicio.id}`,
ETag: etiqueta(servicio),
});
}
export function consultar({ peticion, respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const actual = etiqueta(servicio);
// If-None-Match trae «*» o una lista, y se compara en débil (RFC 9110, secciones 13.1.2 y 8.8.3.2).
const condicion = peticion.headers["if-none-match"];
if (condicion !== undefined && hayCoincidencia(condicion, actual, coincideDebil)) {
enviarVacio(respuesta, 304, { ETag: actual });
return;
}
enviarJson(respuesta, 200, representar(servicio), { ETag: actual });
}
// PUT: cambiar sin pisar el cambio de otra persona. Exige If-Match con la etiqueta vigente.
export async function cambiar({ peticion, respuesta, parametros, credencial }: Contexto): Promise<void> {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
// Primero el formato (415), después la condición (428 y 412), después la lectura (400).
if (!esJson(peticion, respuesta)) return;
const condicion = peticion.headers["if-match"];
if (condicion === undefined) {
fallar(respuesta, 428, "para cambiar un servicio manda If-Match con su ETag");
return;
}
// If-Match se compara en fuerte (RFC 9110, secciones 13.1.1 y 8.8.3.2): una etiqueta W/ nunca coincide.
// El detail no repite la cabecera que mandó el cliente: dice qué pasó y qué hacer.
if (!hayCoincidencia(condicion, etiqueta(servicio), coincideFuerte)) {
// Si solo coincide en débil, no es que el servicio haya cambiado: la etiqueta W/ no sirve para escribir.
const detalle = hayCoincidencia(condicion, etiqueta(servicio), coincideDebil)
? "If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó"
: "tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo";
fallar(respuesta, 412, detalle);
return;
}
// La versión que coincidió. Entre aquí y la escritura hay dos await (leer el cuerpo y consultar el DNS),
// y en ese hueco otra petición puede cambiar el servicio: cambiarServicio vuelve a comparar al escribir.
const versionLeida = interpretarLista(condicion) === "*" ? null : servicio.version;
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
const validos = validar(datos);
if (Array.isArray(validos)) {
rechazarDatos(respuesta, validos);
return;
}
if (!(await destinoPermitido(validos.url, respuesta))) {
return;
}
const homonimo = buscarPorNombre(servicio.equipo, validos.nombre);
if (homonimo !== undefined && homonimo.id !== servicio.id) {
fallar(respuesta, 409, `ya existe un servicio llamado ${validos.nombre}: /servicios/${homonimo.id}`);
return;
}
const cambiado = cambiarServicio(servicio.id, versionLeida, validos);
if (cambiado === "conflicto") {
fallar(respuesta, 412, "el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo");
return;
}
if (cambiado === "retirado") {
// Otra petición lo retiró mientras ésta esperaba: 410, como si hubiera llegado después del retiro.
fallar(respuesta, 410, `el servicio ${servicio.id} se retiró y no va a volver`);
return;
}
enviarJson(respuesta, 200, representar(cambiado), { ETag: etiqueta(cambiado) });
}
export function retirar({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
retirarServicio(servicio.id);
enviarVacio(respuesta, 204);
}
export function pedirRevision({ respuesta, parametros, credencial }: Contexto): void {
// El límite cuenta por credencial y se consulta ANTES de buscar el servicio: así también cuentan los
// 404 de quien prueba ids uno tras otro (enumeración), y lo que gasta una credencial no lo paga otra.
const decision = permitir(`revisiones:${credencial.nombre}`, REVISIONES_POR_VENTANA, VENTANA_MS);
if (!decision.permitido) {
fallar(respuesta, 429, `ya pediste ${REVISIONES_POR_VENTANA} revisiones en el último minuto`, {
"Retry-After": String(decision.esperaS),
});
return;
}
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const revision = crearRevision(servicio.id);
setTimeout(async () => {
terminarRevision(revision.id, await revisar(servicio.url, servicio.limiteMs));
}, ESPERA_EN_COLA_MS);
enviarJson(respuesta, 202, representarRevision(revision), { Location: `/revisiones/${revision.id}` });
}
export function consultarRevision({ respuesta, parametros, credencial }: Contexto): void {
const revision = buscarRevision(Number(parametros[0]));
const servicio = revision === undefined ? undefined : buscarServicio(revision.servicio);
const equipo = servicio?.equipo ?? (revision === undefined ? undefined : equipoDeRetirado(revision.servicio));
if (revision === undefined || equipo === undefined || !delEquipo(equipo, credencial)) {
fallar(respuesta, 404, `no existe la revisión ${parametros[0]}`);
return;
}
const cabeceras: Cabeceras = revision.estado === "pendiente" ? { "Retry-After": "1" } : {};
enviarJson(respuesta, 200, representarRevision(revision), cabeceras);
}
// GET /servicios/{id}/salud: el revisor consulta el servicio en este momento y hace de pasarela.
// Si hubo respuesta HTTP, la informa con 200, sea cual sea su código. Si no la hubo, la culpa es de
// lo que está detrás: 504 si no contestó a tiempo, 502 si no contestó algo que sea HTTP.
export async function salud({ respuesta, parametros, credencial }: Contexto): Promise<void> {
// La consulta en vivo también sale a la red, igual que una revisión: sin límite, el revisor serviría
// para golpear un servicio ajeno a través de él. Cuenta por credencial, antes de buscar el servicio,
// en una ventana aparte de la de las revisiones.
const decision = permitir(`salud:${credencial.nombre}`, CONSULTAS_POR_VENTANA, VENTANA_MS);
if (!decision.permitido) {
fallar(respuesta, 429, `ya pediste ${CONSULTAS_POR_VENTANA} consultas en vivo en el último minuto`, {
"Retry-After": String(decision.esperaS),
});
return;
}
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const consulta = await consultarAhora(servicio.url, servicio.limiteMs);
switch (consulta.tipo) {
case "respuesta":
enviarJson(respuesta, 200, { servicio: servicio.id, codigo: consulta.codigo, ms: consulta.ms });
return;
case "tiempo-agotado":
fallar(respuesta, 504, `el servicio ${servicio.id} no respondió en ${servicio.limiteMs} ms`);
return;
case "sin-conexion":
fallar(respuesta, 502, `no se pudo conectar con el servicio ${servicio.id}`);
return;
case "destino-prohibido":
fallar(respuesta, 422, `destino prohibido: ${consulta.motivo}`);
return;
case "respuesta-invalida":
fallar(respuesta, 502, `el servicio ${servicio.id} no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP`);
return;
}
}
// GET /servicios/{id}/informe: el resumen de sus revisiones.
// DEFECTO A PROPÓSITO (lección 4): no contempla un servicio sin revisiones terminadas, y revienta.
// Se deja así en el resto del curso para tener un 500 que provocar.
export function informe({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const revisiones = revisionesDe(servicio.id);
const ultima = revisiones[revisiones.length - 1] as Revision;
enviarJson(respuesta, 200, {
servicio: servicio.id,
revisiones: revisiones.length,
ultimo_estado: (ultima.resultado as NonNullable<Revision["resultado"]>).estado,
});
}
В servicios.ts, освен онова, което вече видя (salud, informe и ограниченията на pedirRevision и salud), leerJson вече се експортира, защото operacion.ts я използва, за да чете тялото на PUT /mantenimiento. Всичко останало е от Урок 3.
// fig04_01/rutas.ts — la puerta de la API: qué ruta existe, qué método admite y quién puede usarlo.
import type { IncomingMessage, ServerResponse } from "node:http";
import { identificar, motivo, reto, type Credencial } from "./autenticacion.ts";
import { enviarVacio, fallar } from "./respuestas.ts";
import { atajarPorMantenimiento, cambiarMantenimiento } from "./operacion.ts";
import {
cambiar, consultar, consultarRevision, darDeAlta, informe, listar, pedirRevision, retirar, salud,
type Contexto,
} from "./servicios.ts";
import { consultarV1, listarV1, versionRetirada } from "./versiones.ts";
type Manejador = (contexto: Contexto) => void | Promise<void>;
// Cada método dice qué función lo atiende y qué rol mínimo exige.
type Metodo = { manejador: Manejador; escribe: boolean };
type Ruta = { patron: RegExp; metodos: Record<string, Metodo> };
const lee = (manejador: Manejador): Metodo => ({ manejador, escribe: false });
const escribe = (manejador: Manejador): Metodo => ({ manejador, escribe: true });
const RUTAS: Ruta[] = [
{ patron: /^\/servicios$/, metodos: { GET: lee(listar), POST: escribe(darDeAlta) } },
{ patron: /^\/servicios\/(\d+)$/, metodos: { GET: lee(consultar), PUT: escribe(cambiar), DELETE: escribe(retirar) } },
{ patron: /^\/servicios\/(\d+)\/revisiones$/, metodos: { POST: escribe(pedirRevision) } },
{ patron: /^\/servicios\/(\d+)\/salud$/, metodos: { GET: lee(salud) } },
{ patron: /^\/servicios\/(\d+)\/informe$/, metodos: { GET: lee(informe) } },
{ patron: /^\/revisiones\/(\d+)$/, metodos: { GET: lee(consultarRevision) } },
{ patron: /^\/mantenimiento$/, metodos: { PUT: escribe(cambiarMantenimiento) } },
// La versión anterior, desaprobada: solo lectura, con sus avisos.
{ patron: /^\/v1\/servicios$/, metodos: { GET: lee(listarV1) } },
{ patron: /^\/v1\/servicios\/(\d+)$/, metodos: { GET: lee(consultarV1) } },
];
function redirigir(ruta: string, consulta: string, respuesta: ServerResponse): boolean {
if (ruta === "/viejo/servicios" || ruta.startsWith("/viejo/servicios/")) {
enviarVacio(respuesta, 301, { Location: ruta.slice("/viejo".length) + consulta });
return true;
}
if (ruta === "/api/servicios" || ruta.startsWith("/api/servicios/")) {
enviarVacio(respuesta, 308, { Location: ruta.slice("/api".length) + consulta });
return true;
}
return false;
}
// Los métodos que admite una ruta, para la cabecera Allow (GET trae HEAD consigo).
function permitidos(ruta: Ruta): string {
const metodos = Object.keys(ruta.metodos);
if (metodos.includes("GET")) metodos.splice(metodos.indexOf("GET") + 1, 0, "HEAD");
return metodos.join(", ");
}
function puede(credencial: Credencial, metodo: Metodo): boolean {
return !metodo.escribe || credencial.rol === "escritor" || credencial.rol === "admin";
}
export async function atender(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
console.log(`${peticion.method} ${peticion.url}`);
const url = new URL(peticion.url ?? "/", "http://revisor");
if (redirigir(url.pathname, url.search, respuesta)) return;
if (atajarPorMantenimiento(url.pathname, respuesta)) return;
if (versionRetirada(url.pathname, respuesta)) return;
// 1. ¿Existe la ruta?
let ruta: Ruta | undefined;
let parametros: string[] = [];
for (const candidata of RUTAS) {
const encontrada = candidata.patron.exec(url.pathname);
if (encontrada !== null) {
ruta = candidata;
parametros = encontrada.slice(1);
break;
}
}
if (ruta === undefined) {
fallar(respuesta, 404, "no existe esa ruta");
return;
}
// 2. ¿Admite este método?
const nombreMetodo = peticion.method === "HEAD" ? "GET" : (peticion.method ?? "GET");
// Object.hasOwn y no ruta.metodos[nombreMetodo] a secas: el método lo escribe el cliente, y un objeto
// también «tiene» lo que hereda de Object.prototype (constructor, toString…). Igual que las credenciales.
const metodo = Object.hasOwn(ruta.metodos, nombreMetodo) ? ruta.metodos[nombreMetodo] : undefined;
if (metodo === undefined) {
fallar(respuesta, 405, `${peticion.method} no se admite en ${url.pathname}`, { Allow: permitidos(ruta) });
return;
}
// 3. ¿Quién eres?
const identificacion = identificar(peticion);
if (identificacion.tipo !== "valida") {
const detalle = motivo(identificacion);
fallar(respuesta, 401, detalle, { "WWW-Authenticate": reto(identificacion) });
return;
}
// 4. ¿Puedes?
if (!puede(identificacion.credencial, metodo)) {
fallar(respuesta, 403, "tu credencial solo puede leer; para escribir pide una con rol de escritor");
return;
}
// 5, 6 y 7. ¿Existe el recurso? ¿En qué formato viene? ¿Se puede leer? Lo decide cada manejador. Después, ¿se puede cumplir? (422)
await metodo.manejador({ peticion, respuesta, parametros, credencial: identificacion.credencial });
}
// fig04_01/laboratorio.ts
// El banco de pruebas de la lección 0, en el puerto 18081, con tres rutas más para la lección 4:
// /lento/MS responde 200 después de MS milisegundos, /cortar cierra la conexión sin responder,
// y /basura responde algo que no es HTTP.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
const PUERTO = 18081;
// Los nombres salen del registro de la IANA, no de Node, que guarda algunos viejos.
const NOMBRES: Partial<Record<number, string>> = {
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
301: "Moved Permanently", 302: "Found", 304: "Not Modified",
307: "Temporary Redirect", 308: "Permanent Redirect",
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
405: "Method Not Allowed", 406: "Not Acceptable", 409: "Conflict", 410: "Gone",
412: "Precondition Failed", 413: "Content Too Large", 415: "Unsupported Media Type",
422: "Unprocessable Content", 428: "Precondition Required", 429: "Too Many Requests",
500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway",
503: "Service Unavailable", 504: "Gateway Timeout",
};
// Las cabeceras que cada código exige o suele llevar, con valores de ejemplo.
const CABECERAS: Partial<Record<number, Record<string, string>>> = {
201: { Location: "/servicios/demo" },
301: { Location: "/codigo/200" },
302: { Location: "/codigo/200" },
307: { Location: "/codigo/200" },
308: { Location: "/codigo/200" },
304: { ETag: '"v1"' },
401: { "WWW-Authenticate": 'Bearer realm="revisor"' },
405: { Allow: "GET, HEAD" },
429: { "Retry-After": "30" },
503: { "Retry-After": "120" },
};
// Los códigos finales que este banco puede producir y que no pueden llevar cuerpo:
// 204 y 304 (RFC 9112, sección 6.3) y 205 (RFC 9110, sección 15.3.6: «MUST NOT generate content»).
// Un 1xx tampoco puede llevarlo, pero este banco ya se niega a producir uno; y a un HEAD
// Node no le manda el cuerpo aunque el programa lo escriba.
const SIN_CUERPO = new Set([204, 205, 304]);
function enviar(respuesta: ServerResponse, codigo: number, explicacion: string): void {
const nombre = NOMBRES[codigo];
const cabeceras: Record<string, string> = { ...CABECERAS[codigo] };
let cuerpo = "";
if (!SIN_CUERPO.has(codigo)) {
cuerpo = `${codigo} ${nombre ?? "(sin nombre en este banco)"}\n${explicacion}\n`;
cabeceras["Content-Type"] = "text/plain; charset=utf-8";
cabeceras["Content-Length"] = String(Buffer.byteLength(cuerpo));
}
// El 205 no se enmarca solo como el 204 y el 304 (RFC 9112, sección 6.3): sin Content-Length ni Transfer-Encoding
// terminaría al cerrar la conexión, y Node mandaría «Transfer-Encoding: chunked» con un cuerpo vacío.
// «Content-Length: 0» dice dónde termina sin mandar contenido.
if (codigo === 205) cabeceras["Content-Length"] = "0";
if (nombre === undefined) {
respuesta.writeHead(codigo, cabeceras);
} else {
respuesta.writeHead(codigo, nombre, cabeceras);
}
respuesta.end(cuerpo);
}
function atender(peticion: IncomingMessage, respuesta: ServerResponse): void {
console.log(`${peticion.method} ${peticion.url}`);
const lento = /^\/lento\/(\d{1,5})$/.exec(peticion.url ?? "");
if (lento !== null) {
setTimeout(() => enviar(respuesta, 200, `tardé ${lento[1]} ms`), Number(lento[1]));
return;
}
if (peticion.url === "/cortar") {
peticion.socket.destroy();
return;
}
if (peticion.url === "/basura") {
peticion.socket.end("esto no es una respuesta HTTP\r\n\r\n");
return;
}
if (peticion.method !== "GET" && peticion.method !== "HEAD") {
enviar(respuesta, 405, "el banco solo atiende GET y HEAD");
return;
}
const ruta = new URL(peticion.url ?? "/", "http://banco").pathname;
const pedido = /^\/codigo\/(\d{3})$/.exec(ruta);
if (pedido === null) {
enviar(respuesta, 404, "la única ruta es /codigo/NNN; prueba con /codigo/503");
return;
}
const codigo = Number(pedido[1]);
if (codigo < 100) {
enviar(respuesta, 400, "no existe un código de estado menor que 100");
} else if (codigo < 200) {
enviar(respuesta, 400, "un 1xx es una respuesta intermedia: no puede ser la última");
} else if (codigo > 599) {
enviar(respuesta, codigo, "un código mayor que 599 es inválido; el banco lo produce para que veas qué hace el cliente");
} else {
enviar(respuesta, codigo, `lo pediste en ${ruta}`);
}
}
createServer(atender).listen(PUERTO, "127.0.0.1", () => {
console.log(`laboratorio en http://127.0.0.1:${PUERTO}/codigo/NNN`);
});
// fig04_01/almacen.ts — los servicios que observa el revisor y las revisiones pedidas, en memoria.
import { randomBytes } from "node:crypto";
export type Servicio = {
id: number;
nombre: string;
url: string;
limiteMs: number;
equipo: string;
version: number;
};
export type Resultado = {
estado: "ARRIBA" | "LENTO" | "CAIDO";
codigo: number | null;
ms: number | null;
detalle: string;
};
export type Revision = {
id: number;
servicio: number;
estado: "pendiente" | "terminada";
resultado: Resultado | null;
};
export type DatosServicio = { nombre: string; url: string; limiteMs: number; equipo: string };
const servicios = new Map<number, Servicio>();
const revisiones = new Map<number, Revision>();
let siguienteServicio = 1;
let siguienteRevision = 1;
export function crearServicio(datos: DatosServicio): Servicio {
const servicio: Servicio = { id: siguienteServicio++, ...datos, version: 1 };
servicios.set(servicio.id, servicio);
return servicio;
}
export function buscarServicio(id: number): Servicio | undefined {
return servicios.get(id);
}
export function listarServicios(): Servicio[] {
return [...servicios.values()];
}
// Los servicios retirados se recuerdan, con su equipo, para responder 410 y no 404.
const retirados = new Map<number, string>();
export function retirarServicio(id: number): boolean {
const servicio = servicios.get(id);
if (servicio === undefined) return false;
servicios.delete(id);
retirados.set(id, servicio.equipo);
return true;
}
export function equipoDeRetirado(id: number): string | undefined {
return retirados.get(id);
}
export function buscarPorNombre(equipo: string, nombre: string): Servicio | undefined {
return [...servicios.values()].find((s) => s.equipo === equipo && s.nombre === nombre);
}
// Cambia los datos y sube la versión: la etiqueta vieja deja de valer.
// Compara la versión y escribe en un solo paso, sin ningún await de por medio: es el equivalente en
// memoria de «UPDATE servicios SET … WHERE id = ? AND version = ?». Quien llama dice qué versión leyó
// cuando revisó If-Match; si mientras tanto otra petición escribió o retiró el servicio, aquí ya no
// coincide y no se escribe nada: devuelve "conflicto" (412) o, si lo retiraron, "retirado" (410). Con
// If-Match: * se pasa null, porque «*» solo pide que el servicio exista.
export function cambiarServicio(
id: number,
versionLeida: number | null,
datos: Omit<DatosServicio, "equipo">,
): Servicio | "conflicto" | "retirado" {
const servicio = servicios.get(id);
if (servicio === undefined) return "retirado";
if (versionLeida !== null && servicio.version !== versionLeida) return "conflicto";
Object.assign(servicio, datos, { version: servicio.version + 1 });
return servicio;
}
export function crearRevision(servicio: number): Revision {
const revision: Revision = { id: siguienteRevision++, servicio, estado: "pendiente", resultado: null };
revisiones.set(revision.id, revision);
return revision;
}
export function buscarRevision(id: number): Revision | undefined {
return revisiones.get(id);
}
export function revisionesDe(servicio: number): Revision[] {
return [...revisiones.values()].filter((r) => r.servicio === servicio);
}
export function terminarRevision(id: number, resultado: Resultado): void {
const revision = revisiones.get(id);
if (revision !== undefined) {
revision.estado = "terminada";
revision.resultado = resultado;
}
}
// La etiqueta (ETag) cambia cada vez que cambia el servicio: "id-versión-arranque".
// El almacén vive en memoria: al reiniciar el revisor, los id y las versiones vuelven a empezar, y
// "4-1" podría describir otro servicio, con lo que un cliente recibiría un 304 falso (RFC 9110,
// sección 8.8.1: una etiqueta no se reutiliza para otra representación). Por eso cada arranque
// sortea un identificador propio: tras reiniciar, ninguna etiqueta vieja coincide.
const ARRANQUE = randomBytes(4).toString("hex");
export function etiqueta(servicio: Servicio): string {
return `"${servicio.id}-${servicio.version}-${ARRANQUE}"`;
}
// Tres servicios de arranque, para que cada corrida empiece igual.
crearServicio({ nombre: "catalogo", url: "http://127.0.0.1:18081/codigo/200", limiteMs: 2000, equipo: "a" });
crearServicio({ nombre: "pagos", url: "http://127.0.0.1:18081/codigo/503", limiteMs: 2000, equipo: "a" });
crearServicio({ nombre: "nomina", url: "http://127.0.0.1:18081/codigo/200", limiteMs: 2000, equipo: "b" });
// Desde la lección 4: uno lento, uno que no responde a tiempo y uno que no responde HTTP.
crearServicio({ nombre: "inventario", url: "http://127.0.0.1:18081/lento/1500", limiteMs: 2000, equipo: "a" });
crearServicio({ nombre: "reportes", url: "http://127.0.0.1:18081/lento/3000", limiteMs: 1000, equipo: "a" });
crearServicio({ nombre: "facturacion", url: "http://127.0.0.1:18081/basura", limiteMs: 2000, equipo: "a" });
В almacen.ts има две промени: revisionesDe, която използва informe, и трите нови начални услуги в края на файла.
4.7.2 Файловете, които не се променят
Останалите са тези от Урок 3, без нито една промяна. Дават се цялостни, за да може revisor да се сглоби от този урок:
// fig04_01/respuestas.ts — las tres formas de responder del revisor y la lectura del cuerpo.
import type { IncomingMessage, ServerResponse } from "node:http";
export type Cabeceras = Record<string, string>;
// Node trae los nombres viejos de estos dos códigos; aquí van los del registro de la IANA.
const FRASES: Partial<Record<number, string>> = { 413: "Content Too Large", 422: "Unprocessable Content" };
function escribirCabecera(respuesta: ServerResponse, codigo: number, cabeceras: Cabeceras): void {
const frase = FRASES[codigo];
if (frase === undefined) respuesta.writeHead(codigo, cabeceras);
else respuesta.writeHead(codigo, frase, cabeceras);
}
export function enviarJson(respuesta: ServerResponse, codigo: number, datos: unknown, cabeceras: Cabeceras = {}): void {
const cuerpo = JSON.stringify(datos) + "\n";
escribirCabecera(respuesta, codigo, {
...cabeceras,
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
// Para 204, 304 y las redirecciones: código y cabeceras, sin cuerpo.
export function enviarVacio(respuesta: ServerResponse, codigo: number, cabeceras: Cabeceras = {}): void {
// 204 y 304 no pueden llevar Content-Length de un cuerpo; los demás dicen «cero bytes».
const largo: Cabeceras = codigo === 204 || codigo === 304 ? {} : { "Content-Length": "0" };
escribirCabecera(respuesta, codigo, { ...cabeceras, ...largo });
respuesta.end();
}
// Un error: por ahora, una línea de texto. La lección 5 lo cambia por Problem Details.
export function fallar(respuesta: ServerResponse, codigo: number, detalle: string, cabeceras: Cabeceras = {}): void {
const cuerpo = detalle + "\n";
escribirCabecera(respuesta, codigo, {
...cabeceras,
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
// Tope del cuerpo: 16 KiB. Un servicio es un nombre de hasta 40 caracteres, una URL y un número; cabe
// de sobra. Sin tope, leerCuerpo guardaba en memoria todo lo que llegara: un POST de 50 MB subía la
// memoria del revisor unos 150 MB, y cualquiera con la dirección podía llenarla.
export const TOPE_CUERPO = 16 * 1024;
// La señal de que el cuerpo pasó del tope. Quien llama responde 413 (RFC 9110, sección 15.5.14).
export class CuerpoDemasiadoGrande extends Error {}
// Cuando el cuerpo ya pasó del tope, el cliente puede seguir mandando. Si el servidor responde el 413 y destruye la
// conexión en ese momento, lo que todavía llega a una conexión cerrada se contesta con un RST del sistema operativo,
// y ese RST puede borrar el 413 antes de que el cliente lo lea: el cliente ve EPIPE o ECONNRESET en vez de la
// respuesta. El RFC 9112, sección 9.6, aconseja un cierre en etapas: responder, cerrar solo la escritura (media cerrada),
// seguir leyendo y tirando lo que llegue hasta que el cliente cierre o pase un plazo (el RFC no fija ninguno; el de 2 s es del curso), y cerrar del todo.
// Node, con «Connection: close», llama a socket.destroySoon() al terminar de responder, que cierra y destruye; aquí
// se cambia ese método, solo en esta conexión, para que únicamente cierre la escritura.
const ESPERA_CIERRE_MS = 2000;
function cerrarConCalma(peticion: IncomingMessage): void {
const socket = peticion.socket;
socket.destroySoon = (): void => {
socket.end();
};
peticion.resume();
const reloj = setTimeout(() => socket.destroy(), ESPERA_CIERRE_MS);
socket.once("close", () => clearTimeout(reloj));
}
// Lee el cuerpo contando los bytes. Si Content-Length ya anuncia más del tope, rechaza sin guardar nada;
// si no lo anuncia (cuerpo por partes) o miente, deja de guardar en cuanto se pasa. En los dos casos no
// guarda más de TOPE_CUERPO bytes. No se destruye la petición, porque con ella se iría la conexión y
// no se podría responder: se prepara el cierre en etapas (ver arriba) y el 413 sale de inmediato, con
// «Connection: close».
export function leerCuerpo(peticion: IncomingMessage): Promise<string> {
return new Promise((resolver, rechazar) => {
const demasiado = (): void => {
cerrarConCalma(peticion);
rechazar(new CuerpoDemasiadoGrande(`el cuerpo pasa de ${TOPE_CUERPO} bytes`));
};
if (Number(peticion.headers["content-length"] ?? 0) > TOPE_CUERPO) {
demasiado();
return;
}
const trozos: Buffer[] = [];
let total = 0;
const alLlegar = (trozo: Buffer): void => {
total += trozo.length;
if (total > TOPE_CUERPO) {
peticion.off("data", alLlegar);
peticion.off("end", alTerminar);
demasiado();
return;
}
trozos.push(trozo);
};
const alTerminar = (): void => resolver(Buffer.concat(trozos).toString("utf8"));
peticion.on("data", alLlegar);
peticion.on("end", alTerminar);
peticion.on("error", rechazar);
});
}
// fig04_01/autenticacion.ts — quién eres (la credencial) y qué puedes hacer (el rol).
import type { IncomingMessage } from "node:http";
export type Credencial = { nombre: string; equipo: string; rol: "lector" | "escritor" | "admin" };
// Credenciales de práctica. En una API real vienen de un proveedor de identidad, no del código.
// Van en un Map y no en un objeto literal: CREDENCIALES[token] sobre un objeto también encuentra lo
// que hereda de Object.prototype, y «Bearer constructor» o «Bearer toString» pasarían como válidas.
// Map.get solo devuelve lo que se guardó con set.
const CREDENCIALES = new Map<string, Credencial>([
["lectura-a", { nombre: "lectura-a", equipo: "a", rol: "lector" }],
["escritura-a", { nombre: "escritura-a", equipo: "a", rol: "escritor" }],
["escritura-b", { nombre: "escritura-b", equipo: "b", rol: "escritor" }],
["admin", { nombre: "admin", equipo: "*", rol: "admin" }],
]);
// «ninguna»: no vino Authorization. «otro-esquema»: vino, pero no es Bearer (Basic, por ejemplo).
// «invalida»: vino Bearer con una credencial que no existe o mal escrita.
export type Identificacion =
| { tipo: "ninguna" }
| { tipo: "otro-esquema" }
| { tipo: "invalida" }
| { tipo: "valida"; credencial: Credencial };
export function identificar(peticion: IncomingMessage): Identificacion {
const cabecera = peticion.headers.authorization;
if (cabecera === undefined) return { tipo: "ninguna" };
// credentials = auth-scheme [ 1*SP token68 ] (RFC 9110, sección 11.4). El nombre del esquema no
// distingue mayúsculas (sección 11.1): «Bearer», «bearer» y «BEARER» son el mismo esquema.
const esquema = /^[^ ]+/.exec(cabecera);
if (esquema === null || esquema[0].toLowerCase() !== "bearer") return { tipo: "otro-esquema" };
const encontrada = /^[^ ]+ +(\S+)$/.exec(cabecera);
const credencial = encontrada === null ? undefined : CREDENCIALES.get(encontrada[1]);
return credencial === undefined ? { tipo: "invalida" } : { tipo: "valida", credencial };
}
// La frase del 401 para cada caso.
export function motivo(identificacion: Identificacion): string {
if (identificacion.tipo === "ninguna") return "falta la credencial";
if (identificacion.tipo === "otro-esquema") return "solo se admite el esquema Bearer";
return "la credencial no es válida";
}
// El reto que exige un 401 (RFC 9110, sección 11.6.1; el esquema Bearer es del RFC 6750).
// error="invalid_token" solo cuando llegó una credencial Bearer y no sirve (RFC 6750, sección 3).
// Sin credencial, o con un esquema que no se admite, no va ningún código de error (sección 3.1).
export function reto(identificacion: Identificacion): string {
return identificacion.tipo === "invalida"
? 'Bearer realm="revisor", error="invalid_token"'
: 'Bearer realm="revisor"';
}
// fig04_01/destinos.ts — a dónde puede conectarse el revisor. Defensa contra SSRF.
//
// SSRF (server-side request forgery): quien da de alta un servicio escribe una URL, y el revisor
// se conecta a ella DESDE SU RED. Sin esta revisión, cualquiera con permiso de escritura podría hacer
// que el revisor consulte 127.0.0.1, la red interna o 169.254.169.254, donde las nubes publican los
// metadatos de la máquina (y a veces sus credenciales). Un revisor de servicios es el caso típico:
// conectarse a URLs ajenas es justo su trabajo, así que no se puede quitar; se acota.
//
// La regla: se resuelve el nombre y CADA dirección debe estar en la lista de destinos permitidos
// (ip:puerto) o ser pública. Se valida al registrar la URL Y antes de cada conexión, porque el DNS
// puede cambiar entre una y otra.
//
// LÍMITE DECLARADO: aun validando antes de cada fetch, el DNS puede cambiar entre la validación y la
// conexión (DNS rebinding). La defensa completa es conectarse a la IP ya validada (con http.request
// y su opción lookup), no volver a resolver el nombre. La lección 8 la hace en cliente.ts; hasta
// entonces, el límite queda escrito aquí.
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
// Destinos permitidos aunque sean privados: el laboratorio del curso. Se cambian con REVISOR_DESTINOS.
const PERMITIDOS = new Set((process.env.REVISOR_DESTINOS ?? "127.0.0.1:18081").split(",").map((d) => d.trim()));
// Qué direcciones son «privadas o reservadas». Fuente: los registros de direcciones de uso especial
// de la IANA (IPv4 e IPv6, que actualizan al RFC 6890) y la OWASP SSRF Prevention Cheat Sheet.
//
// IPv4 va con LISTA NEGRA: no existe un bloque «global» que se pueda permitir entero, así que se
// rechazan los bloques del registro que no son alcanzables desde internet, más el multicast.
const IPV4_PROHIBIDAS = new BlockList();
IPV4_PROHIBIDAS.addSubnet("0.0.0.0", 8, "ipv4"); // «esta red», incluye 0.0.0.0
IPV4_PROHIBIDAS.addSubnet("10.0.0.0", 8, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("100.64.0.0", 10, "ipv4"); // espacio compartido (CGNAT); Alibaba Cloud publica ahí sus metadatos
IPV4_PROHIBIDAS.addSubnet("127.0.0.0", 8, "ipv4"); // loopback
IPV4_PROHIBIDAS.addSubnet("169.254.0.0", 16, "ipv4"); // enlace local, incluye los metadatos de la nube
IPV4_PROHIBIDAS.addSubnet("172.16.0.0", 12, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("192.0.0.0", 24, "ipv4"); // asignaciones de protocolo del IETF
IPV4_PROHIBIDAS.addSubnet("192.0.2.0", 24, "ipv4"); // documentación (TEST-NET-1)
IPV4_PROHIBIDAS.addSubnet("192.88.99.0", 24, "ipv4"); // relevo 6to4, retirado
IPV4_PROHIBIDAS.addSubnet("192.168.0.0", 16, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("198.18.0.0", 15, "ipv4"); // pruebas de rendimiento
IPV4_PROHIBIDAS.addSubnet("198.51.100.0", 24, "ipv4"); // documentación (TEST-NET-2)
IPV4_PROHIBIDAS.addSubnet("203.0.113.0", 24, "ipv4"); // documentación (TEST-NET-3)
IPV4_PROHIBIDAS.addSubnet("224.0.0.0", 4, "ipv4"); // multicast
IPV4_PROHIBIDAS.addSubnet("240.0.0.0", 4, "ipv4"); // reservado, incluye 255.255.255.255 (difusión)
// IPv6 va con LISTA BLANCA: solo 2000::/3, el unicast global. Así quedan fuera de un golpe ::1, ::,
// fc00::/7, fe80::/10, el multicast ff00::/8, 100::/64 y 64:ff9b:1::/48 (traducción local, RFC 8215),
// sin nombrarlos uno por uno. Dentro de 2000::/3 se rechazan los bloques que no son de internet.
const IPV6_GLOBAL = new BlockList();
IPV6_GLOBAL.addSubnet("2000::", 3, "ipv6");
const IPV6_PROHIBIDAS = new BlockList();
IPV6_PROHIBIDAS.addSubnet("2001::", 23, "ipv6"); // protocolos del IETF: Teredo (2001::/32), pruebas, ORCHID…
IPV6_PROHIBIDAS.addSubnet("2001:db8::", 32, "ipv6"); // documentación
IPV6_PROHIBIDAS.addSubnet("3fff::", 20, "ipv6"); // documentación
IPV6_PROHIBIDAS.addSubnet("5f00::", 16, "ipv6"); // segment routing (SRv6)
// Cuatro formatos IPv6 llevan una IPv4 adentro. La IPv4 se saca y se revisa contra la lista IPv4:
// [64:ff9b::a9fe:a9fe] es 169.254.169.254 por dentro. Se revisan ANTES que la lista blanca, porque
// ::ffff:8.8.8.8 o 2002:808:808:: llevan una dirección pública y deben pasar.
type Embebida = { prefijo: BlockList; ipv4: (g: number[]) => number[] };
function prefijo(direccion: string, bits: number): BlockList {
const lista = new BlockList();
lista.addSubnet(direccion, bits, "ipv6");
return lista;
}
const ultimos32 = (g: number[]): number[] => [g[6], g[7]];
const EMBEBIDAS: Embebida[] = [
{ prefijo: prefijo("::ffff:0:0", 96), ipv4: ultimos32 }, // IPv4 mapeada (RFC 4291)
{ prefijo: prefijo("::", 96), ipv4: ultimos32 }, // IPv4 compatible, obsoleta; incluye :: y ::1 (0.0.0.0 y 0.0.0.1)
{ prefijo: prefijo("64:ff9b::", 96), ipv4: ultimos32 }, // NAT64 (RFC 6052)
{ prefijo: prefijo("2002::", 16), ipv4: (g) => [g[1], g[2]] }, // 6to4 (RFC 3056): la IPv4 va en los bits 16 a 47
];
// Los ocho grupos de 16 bits de una IPv6 («::» rellena con ceros; acepta la IPv4 escrita al final).
function grupos(ipv6: string): number[] {
let texto = ipv6;
const cola = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(texto);
if (cola !== null) {
const [a, b, c, d] = cola.slice(1).map(Number);
texto = texto.slice(0, cola.index) + ((a << 8) | b).toString(16) + ":" + ((c << 8) | d).toString(16);
}
const [izquierda, derecha] = texto.split("::");
const antes = izquierda === "" ? [] : izquierda.split(":");
const despues = derecha === undefined || derecha === "" ? [] : derecha.split(":");
const ceros = derecha === undefined ? [] : Array<string>(8 - antes.length - despues.length).fill("0");
return [...antes, ...ceros, ...despues].map((grupo) => parseInt(grupo, 16));
}
function privada(direccion: string): boolean {
if (isIP(direccion) === 4) return IPV4_PROHIBIDAS.check(direccion, "ipv4");
const ipv6 = direccion.replace(/%.*$/, ""); // sin el índice de zona (fe80::1%en0)
for (const { prefijo, ipv4 } of EMBEBIDAS) {
if (prefijo.check(ipv6, "ipv6")) {
const [alto, bajo] = ipv4(grupos(ipv6));
return IPV4_PROHIBIDAS.check([alto >> 8, alto & 0xff, bajo >> 8, bajo & 0xff].join("."), "ipv4");
}
}
return !IPV6_GLOBAL.check(ipv6, "ipv6") || IPV6_PROHIBIDAS.check(ipv6, "ipv6");
}
export type Veredicto = { permitido: true } | { permitido: false; motivo: string };
export async function revisarDestino(texto: string): Promise<Veredicto> {
let url: URL;
try {
url = new URL(texto);
} catch {
return { permitido: false, motivo: "no es una URL" };
}
if (url.protocol !== "http:" && url.protocol !== "https:") return { permitido: false, motivo: "solo http o https" };
const puerto = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
const nombre = url.hostname.replace(/^\[|\]$/g, "");
let direcciones: string[];
try {
direcciones = isIP(nombre) !== 0 ? [nombre] : (await lookup(nombre, { all: true })).map((d) => d.address);
} catch {
return { permitido: false, motivo: `el nombre ${nombre} no se pudo resolver` };
}
// Basta con que UNA de las direcciones caiga en un rango prohibido para rechazar el destino.
const prohibidas = direcciones
.map((direccion) => (isIP(direccion) === 6 ? `[${direccion}]:${puerto}` : `${direccion}:${puerto}`))
.filter((destino, i) => !PERMITIDOS.has(destino) && privada(direcciones[i]));
if (prohibidas.length > 0) {
// Una dirección escrita por el cliente se repite tal cual (es suya). Un NOMBRE no enumera lo que resolvió:
// la lista cambia según el sistema (macOS devuelve IPv6 e IPv4, Linux a veces solo IPv4) y revelarla
// le da al cliente un mapa de la red interna. Para depurar, la dirección concreta (`prohibidas`) puede
// registrarla el servidor en su bitácora; aquí no se imprime para que la salida de la matriz sea igual en todo sistema.
if (isIP(nombre) !== 0) return { permitido: false, motivo: `${prohibidas.join(", ")}: dirección privada o reservada` };
return { permitido: false, motivo: `${nombre} resuelve a una dirección privada o reservada` };
}
return { permitido: true };
}
// fig04_01/etiquetas.ts — leer y comparar etiquetas (ETag) como pide el RFC 9110.
//
// Una etiqueta es un texto entre comillas, con «W/» delante si es débil: "1-1" o W/"1-1" (sección 8.8.3).
// If-None-Match e If-Match no traen una etiqueta: traen «*» o una LISTA separada por comas
// (secciones 13.1.1 y 13.1.2). Por eso comparar la cabecera entera con la etiqueta, como texto, falla:
// W/"1-1" y "9-9", "1-1" no son iguales a "1-1", y las dos deben coincidir en If-None-Match.
//
// Hay dos maneras de comparar (sección 8.8.3.2):
// débil — If-None-Match (304): basta con que el texto entre comillas sea igual, con W/ o sin él;
// fuerte — If-Match (412): además, ninguna de las dos puede ser débil.
// «*» aparte; si no, la lista de etiquetas tal como vienen (W/"1-1", "1-1"…).
export type Lista = "*" | string[];
// Lo que puede ir entre las comillas: visibles menos la comilla, y los bytes de 0x80 en adelante.
const OPACA = /^"[\x21\x23-\x7E\x80-\xFF]*"$/;
// Lee la cabecera respetando las comillas: una coma DENTRO de las comillas es parte de la etiqueta,
// no un separador. Los elementos vacíos («"1-1", , "2-1"») se saltan (sección 5.6.1).
// Si algo no es una etiqueta, la lista entera no vale y se devuelve vacía: así no coincide con nada,
// que es lo más prudente (If-None-Match responde completo; If-Match responde 412).
// «*» va sin compañía: mezclado con etiquetas es inválido (secciones 13.1.1 y 13.1.2).
export function interpretarLista(cabecera: string): Lista {
const texto = cabecera.trim();
if (texto === "*") return "*";
const etiquetas: string[] = [];
let i = 0;
while (i < texto.length) {
const letra = texto[i];
if (letra === " " || letra === "\t" || letra === ",") {
i++;
continue;
}
const inicio = i;
if (texto.startsWith("W/", i)) i += 2;
if (texto[i] !== '"') return [];
const cierre = texto.indexOf('"', i + 1);
if (cierre === -1) return [];
const etiqueta = texto.slice(inicio, cierre + 1);
if (!OPACA.test(etiqueta.replace(/^W\//, ""))) return [];
etiquetas.push(etiqueta);
i = cierre + 1;
// Después de una etiqueta solo pueden venir espacios y luego una coma o el final.
while (texto[i] === " " || texto[i] === "\t") i++;
if (i < texto.length && texto[i] !== ",") return [];
}
return etiquetas;
}
function esDebil(etiqueta: string): boolean {
return etiqueta.startsWith("W/");
}
function opaca(etiqueta: string): string {
return esDebil(etiqueta) ? etiqueta.slice(2) : etiqueta;
}
// Débil: W/"1" y "1" coinciden; W/"1" y W/"2" no.
export function coincideDebil(a: string, b: string): boolean {
return opaca(a) === opaca(b);
}
// Fuerte: solo "1" con "1". Si cualquiera de las dos es débil, no coinciden.
export function coincideFuerte(a: string, b: string): boolean {
return !esDebil(a) && !esDebil(b) && a === b;
}
// ¿Alguna etiqueta de la cabecera coincide con la vigente? «*» coincide si el recurso existe, y quien
// llama ya lo encontró antes de preguntar. Se elige la comparación: débil para If-None-Match,
// fuerte para If-Match.
export function hayCoincidencia(cabecera: string, vigente: string, comparar: (a: string, b: string) => boolean): boolean {
const lista = interpretarLista(cabecera);
if (lista === "*") return true;
return lista.some((etiqueta) => comparar(etiqueta, vigente));
}
// fig04_01/versiones.ts — las versiones viejas de la API: /v1 está desaprobada y /v0 ya se retiró.
import type { ServerResponse } from "node:http";
import { listarServicios, type Servicio } from "./almacen.ts";
import { enviarJson, fallar, type Cabeceras } from "./respuestas.ts";
import { encontrar, type Contexto } from "./servicios.ts";
// Deprecation (RFC 9745): desde cuándo está desaprobada, como fecha de campo estructurado (@segundos).
// Sunset (RFC 8594): cuándo probablemente dejará de responder, como fecha HTTP. Nunca antes que Deprecation.
const AVISOS: Cabeceras = {
Deprecation: "@1782864000", // 1 de julio de 2026, 00:00 UTC
Sunset: "Sun, 31 Jan 2027 00:00:00 GMT",
Link: '<https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"',
};
// La v1 hablaba de «direccion» y del límite en segundos.
function representarV1(servicio: Servicio): object {
return { id: servicio.id, nombre: servicio.nombre, direccion: servicio.url, limite: servicio.limiteMs / 1000 };
}
export function listarV1({ respuesta, credencial }: Contexto): void {
const propios = listarServicios().filter((s) => credencial.equipo === "*" || s.equipo === credencial.equipo);
enviarJson(respuesta, 200, { servicios: propios.map(representarV1) }, AVISOS);
}
export function consultarV1({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
enviarJson(respuesta, 200, representarV1(servicio), AVISOS);
}
// La v0 se retiró: 410 para cualquier ruta suya, sin importar quién pregunte.
export function versionRetirada(ruta: string, respuesta: ServerResponse): boolean {
if (ruta !== "/v0" && !ruta.startsWith("/v0/")) return false;
fallar(respuesta, 410, "la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios");
return true;
}
{
"type": "module"
}
4.7.3 Цялостното изпълнение
С току-що стартирани лабораторията и revisor (раздел 4.1.3) това са всички заявки на урока, по ред, с онова, което са отговорили. Събрани са тук, за да ги сравниш подред със своите:
$ curl -i http://127.0.0.1:18081/basura
curl: (1) Received HTTP/0.9 when not allowed
$ curl -i -m 1 http://127.0.0.1:18081/lento/3000
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received
$ curl -i http://127.0.0.1:18081/cortar
curl: (52) Empty reply from server
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia affc3b8a-5ab9-42f5-8b37-2face1f8eafd
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia bf0e3b0a-790f-4625-80e7-017b0bcfa403
$ curl -i http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":1,"codigo":200,"ms":8}
$ curl -i http://127.0.0.1:18080/servicios/2/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35
Date: Wed, 21 Oct 2026 13:19:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":2,"codigo":503,"ms":1}
$ curl -i http://127.0.0.1:18080/servicios/4/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 38
Date: Wed, 21 Oct 2026 13:19:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":4,"codigo":200,"ms":1503}
$ curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 504 Gateway Timeout
Content-Type: text/plain; charset=utf-8
Content-Length: 39
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 5 no respondió en 1000 ms
tardó 1.004405 s
$ curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/6/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain; charset=utf-8
Content-Length: 102
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 6 no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP
tardó 0.002184 s
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"colgado","url":"http://127.0.0.1:18081/cortar"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-6db25d01"
Content-Type: application/json
Content-Length: 83
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"colgado","url":"http://127.0.0.1:18081/cortar","limite_ms":2000}
$ curl -i http://127.0.0.1:18080/servicios/7/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain; charset=utf-8
Content-Length: 102
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 7 no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"activo":true}'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 59
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
solo quien opera el revisor puede cambiar el mantenimiento
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":true}'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a'
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: text/plain; charset=utf-8
Content-Length: 34
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el revisor está en mantenimiento
$ curl -i http://127.0.0.1:18080/no-existe
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: text/plain; charset=utf-8
Content-Length: 34
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el revisor está en mantenimiento
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":false}'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a'
200
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain; charset=utf-8
Content-Length: 45
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 revisiones en el último minuto
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/4/revisiones -H 'Authorization: Bearer admin'
202
$ curl -i http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 429 Too Many Requests
Retry-After: 58
Content-Type: text/plain; charset=utf-8
Content-Length: 52
Date: Wed, 21 Oct 2026 13:19:09 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 consultas en vivo en el último minuto
$ for id in 1 2 3 4 5 6 7 8 9 10; do curl -sS -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$id/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
404 404 202 429 429 429 429 429 429 429
$ sleep 20
$ curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
HTTP/1.1 429 Too Many Requests
Retry-After: 40
Content-Type: text/plain; charset=utf-8
Content-Length: 45
Date: Wed, 21 Oct 2026 13:19:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 revisiones en el último minuto
$ curl http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer lectura-a'
{"id":1,"servicio":2,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":503,"ms":1,"detalle":"503 (reintentar en 120 s)"}}
$ curl http://127.0.0.1:18080/revisiones/4 -H 'Authorization: Bearer lectura-a'
{"id":4,"servicio":4,"estado":"terminada","resultado":{"estado":"LENTO","codigo":200,"ms":1501,"detalle":"200"}}
$ curl -i http://127.0.0.1:18080/servicios/2/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 54
Date: Wed, 21 Oct 2026 13:19:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":2,"revisiones":3,"ultimo_estado":"CAIDO"}
А това записа терминалът на revisor по време на цялото изпълнение. Обърни внимание, че единствените стекове са тези на двете 500 и че всеки започва с препратката, която е получил клиентът:
revisor en http://127.0.0.1:18080
GET /servicios/1/informe
error affc3b8a-5ab9-42f5-8b37-2face1f8eafd en GET /servicios/1/informe: TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:304:28)
at atender (file://<carpeta>/rutas.ts:104:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:23:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:35:44)
GET /servicios/1/informe
error bf0e3b0a-790f-4625-80e7-017b0bcfa403 en GET /servicios/1/informe: TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:304:28)
at atender (file://<carpeta>/rutas.ts:104:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:23:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:35:44)
GET /servicios/1/salud
GET /servicios/2/salud
GET /servicios/4/salud
GET /servicios/5/salud
GET /servicios/6/salud
POST /servicios
GET /servicios/7/salud
PUT /mantenimiento
PUT /mantenimiento
operación: mantenimiento encendido
GET /servicios
GET /no-existe
PUT /mantenimiento
operación: mantenimiento apagado
GET /servicios
POST /servicios/2/revisiones
POST /servicios/2/revisiones
POST /servicios/2/revisiones
POST /servicios/2/revisiones
POST /servicios/4/revisiones
GET /servicios/1/salud
POST /servicios/1/revisiones
POST /servicios/2/revisiones
POST /servicios/3/revisiones
POST /servicios/4/revisiones
POST /servicios/5/revisiones
POST /servicios/6/revisiones
POST /servicios/7/revisiones
POST /servicios/8/revisiones
POST /servicios/9/revisiones
POST /servicios/10/revisiones
POST /servicios/2/revisiones
GET /revisiones/1
GET /revisiones/4
GET /servicios/2/informe
4.7.4 Проверка на типовете с tsc (по желание)
Както в предишните уроци, програмите се изпълняват с node archivo.ts без да се компилира нищо, а tsc служи само за проверка на типовете. От папката fig04_01/:
npm install --no-save typescript@7.0.2 @types/node@24
npx tsc --strict --noEmit --target esnext --module nodenext --erasableSyntaxOnly --verbatimModuleSyntax --allowImportingTsExtensions --types node *.ts
Не отпечатва нищо, ако всичко е наред. Тринайсетте файла на fig04_01/, fig04_02.ts, fig04_03.ts, двете версии от defectos/ и решенията от ejercicios/ минават без грешки. Обърни внимание какво означава това за дефекта на informe: минава tsc в строг режим. Типовете казват каква форма имат данните, а не дали съществуват; едно as отнема на TypeScript възможността да те предупреди.
Грешката, която ще видиш
Стекът, който пътува
Това е catch, който почти всички пишат първия път, защото е най-полезният, докато програмираш: ако нещо се провали, изпрати ми всичко. За да го предизвикаш, копирай папката fig04_01/ като rota/ и в rota/servidor.ts смени функцията atenderSinFiltrar с тази (това е фрагмент; цялостната версия е в defectos/pila/servidor.ts, който в първия си ред предупреждава, че не се копира):
async function atenderSinFiltrar(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
try {
await atender(peticion, respuesta);
} catch (error) {
const pila = error instanceof Error ? (error.stack ?? error.message) : String(error);
console.error(`error en ${peticion.method} ${peticion.url}`);
if (!respuesta.headersSent) {
fallar(respuesta, 500, pila);
} else {
respuesta.destroy();
}
}
}
Стартирай лабораторията и revisor от rota/ и поискай отчета на услуга 1, която няма проверки:
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 865
Date: Wed, 21 Oct 2026 13:19:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:304:28)
at atender (file://<carpeta>/rutas.ts:104:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:11:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:23:44)
at Server.emit (node:events:508:28)
at parserOnIncoming (node:_http_server:1210:12)
at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
(Папката на машината се записва <carpeta>, както в терминала на revisor; Content-Length: 865 брои истинската папка, която е по-дълга.)
Кодът е правилен: 500. Всичко останало е подарък за онзи, който не бива да го получава. Прочети го така, както би го прочел някой, който иска да атакува твоя API:
- Пътищата. Всеки кадър носи пълната папка, в която живее програмата. На истински сървър това казва с кой потребител върви процесът, как се казва проектът на диска и къде да се търси, ако намери друг начин да чете файлове.
- Вътрешните имена.
servicios.ts,rutas.ts,servidor.ts, функциятаinforme, полетоresultado. Това е картата на твоя код, безплатно. - Технологията и нейната версия.
node:events:508:28иnode:_http_server:1210:12са кадри на Node и номерата на редовете им се менят с всяка версия: онзи, който познава Node, може да заключи коя версия върви и да потърси известните ѝ уязвимости. - Как да се предизвика отново. Съобщението казва, че е бил прочетен
resultadoвърхуundefined. Заедно с поискания маршрут казва на онзи, който го е получил, точно кое условие чупи сървъра и му позволява да го повтаря, когато поиска.
И погледни терминала на revisor в тази версия:
revisor en http://127.0.0.1:18080
GET /servicios/1/informe
error en GET /servicios/1/informe
Един ред, без препратка и без стек. Този catch даде всичко на клиента и почти нищо на онзи, който експлоатира системата. Ако клиентът не съобщи тялото, което е получил, никой от страната на сървъра не знае какво е станало. Това е грешката наопаки, цялостно: информацията е от погрешната страна.
Поправката е atenderSinFiltrar от fig04_01/: стекът отива в терминала, подрязан до кадрите на курса, а до клиента стига само препратката. Ако си мислиш „но при разработка ми е полезно да виждам стека в отговора“, отговорът е, че го имаш в терминала, до теб, със същата информация; и че един catch, който се държи различно при разработка и в продукция, е catch, чиято продукционна версия никога не си изпробвал. Спри програмите от rota/, когато приключиш.
Всичко е 500
Втората грешка е по-коварна, защото отговорът е безупречен по форма и неверен по същество. Копирай отново fig04_01/ като rota2/ и в rota2/servicios.ts смени функцията salud с тази, която пита с fetch директно, без да минава през consultar (цялостната версия е в defectos/sin-pasarela/servicios.ts, със същото предупреждение в първия ред):
export async function salud({ respuesta, parametros, credencial }: Contexto): Promise<void> {
// La consulta en vivo también sale a la red, igual que una revisión: sin límite, el revisor serviría
// para golpear un servicio ajeno a través de él. Cuenta por credencial, antes de buscar el servicio,
// en una ventana aparte de la de las revisiones.
const decision = permitir(`salud:${credencial.nombre}`, CONSULTAS_POR_VENTANA, VENTANA_MS);
if (!decision.permitido) {
fallar(respuesta, 429, `ya pediste ${CONSULTAS_POR_VENTANA} consultas en vivo en el último minuto`, {
"Retry-After": String(decision.esperaS),
});
return;
}
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
// DEFECTO A PROPÓSITO: llama a fetch directo y no clasifica lo que puede salir mal. Si el servicio
// no responde a tiempo o no responde HTTP, fetch lanza, y la excepción llega a servidor.ts.
const veredicto = await revisarDestino(servicio.url);
if (!veredicto.permitido) {
fallar(respuesta, 422, `destino prohibido: ${veredicto.motivo}`);
return;
}
const inicio = performance.now();
const contestacion = await fetch(servicio.url, { redirect: "manual", signal: AbortSignal.timeout(servicio.limiteMs) });
await contestacion.body?.cancel();
enviarJson(respuesta, 200, { servicio: servicio.id, codigo: contestacion.status, ms: Math.round(performance.now() - inicio) });
}
Чете се добре и прави правилното, когато услугата отговаря. Стартирай лабораторията и revisor от rota2/ и поискай състоянието на услуги 2, 5 и 6:
$ curl -i http://127.0.0.1:18080/servicios/2/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 36
Date: Wed, 21 Oct 2026 13:19:32 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":2,"codigo":503,"ms":20}
$ curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia 53423d0e-11e2-4ce0-befe-6f701044a854
tardó 1.002529 s
$ curl -i http://127.0.0.1:18080/servicios/6/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia 3ec30ef1-4589-44c0-a95c-00c1195b86d9
Услуга 2 излиза както преди, 200 с 503 вътре. Но 5, която не отговори навреме, и 6, която отговори с боклук, излизат и двете като 500, с препратка и без стек. Мрежата на servidor.ts си свърши добре работата: хвана изключение, което никой не очакваше, не разказа нищо и остави нишката в терминала:
revisor en http://127.0.0.1:18080
GET /servicios/2/salud
GET /servicios/5/salud
error 53423d0e-11e2-4ce0-befe-6f701044a854 en GET /servicios/5/salud: TimeoutError: The operation was aborted due to timeout
GET /servicios/6/salud
error 3ec30ef1-4589-44c0-a95c-00c1195b86d9 en GET /servicios/6/salud: TypeError: fetch failed
Проблемът е, че се очакваше. Това, че една чужда услуга не отговаря навреме или отговаря с боклук, е най-нормалното нещо на света за програма, чиято работа е да пита чужди услуги. Отговорът 500 казва на онзи, който експлоатира системата, „в кода ти има грешка“ и го праща да търси в servicios.ts дефект, който не съществува, докато истинският проблем е в услугата за отчети и в тази за фактуриране. Това е точно объркването от 19:20 в сцената в началото. И обърни внимание на втория ред на грешката: TypeError: fetch failed, без нито един кадър на курса. Стекът на fetch живее изцяло вътре в Node и pilaDelCurso го е подрязала целия. Дори терминалът не казва къде е станало.
Поправката е salud от fig04_01/: да питаш consultar, която превръща всеки начин на провал в изход с име, и да избереш код за всеки. Онова, което може да се предвиди, не се оставя да стигне до мрежата на 500: мрежата е за онова, което наистина никой не е предвидил. Спри програмите от rota2/, когато приключиш.
Какво се прави зле
500 за всичко, което се обърка. Това е грешката, която този урок съществува, за да предотврати. 500 заради бавна зависимост, заради поддръжка или заради ограничение на заявките праща онзи, който експлоатира системата, да търси в собствения си код и казва на клиента „не знам какво е станало“, когато се е знаело.
Стекът, SQL или съобщението на изключението в тялото. Първото е грешката от „Стекът, който пътува“. Последното е онова, което най-често се изплъзва, защото изглежда безобидно: текстът на едно изключение го пише онзи, който го е хвърлил, и може да носи заявка, път или вътрешен адрес.
500 без нищо, което позволява да се намери. Обратното на предишното: да се премълчи всичко, дори от страната на онзи, който експлоатира системата. 500 без препратка, на сървър, който обслужва хиляди заявки в минута, не може да се свърже с никой ред на дневника.
503 без Retry-After или с измислен. RFC не задължава да се изпраща, но 503 без него оставя клиента да налучква колко да чака и всеки клиент налучква различно. А Retry-After: 1, сложен „за всеки случай“ при двучасова поддръжка, кара всички клиенти да се връщат всяка секунда, което е обратното на онова, което си искал.
Постоянно 503. 503 обещава, че е временно. За нещо, което е оттеглено завинаги, кодът е 410 (Урок 3); за нещо, което не съществува, 404.
Отговор с кода на услугата отзад. Ако услугата за плащания отговори 503 и твоята заявка за състоянието отговори 503, всяка машина по пътя заключава, че недостъпният си ти. Заявката е сработила: това е 200 с 503 вътре, както в 4.4.3. И обратно, ако твоят API използва друга услуга, за да обслужва, 404 от тази услуга не е твое 404: не препращай на клиента кодовете на друг.
429 без Retry-After или ограничение, което никой не е документирал. Клиентът, който получи 429, без да знае колко да чака, повтаря веднага и отново получава 429. RFC 6585 оставя отворени колко, на какъв интервал и за кого; ако ги оставиш и недокументирани, ограничението става загадка, която всеки клиент открива на тласъци.
429 за претоварване, което не е причинил този клиент. Ако сървърът е претоварен от всички, кодът е 503. 429 обвинява конкретен клиент, че е прекалил.
Ограничение, чийто ключ идва от свободен текст на клиента. Ограничаването по идентификатор, който клиентът може да измисли безплатно (несъществуващо id, заглавие, което никой не е проверил), дава на всеки начин да създава безкрайно ключове и да напълни паметта на ограничителя.
Да чакаш зависимост без ограничение във времето. Без AbortSignal.timeout заявка към забила услуга чака вечно, а всяка заявка, която идва междувременно, заема още една връзка. Без ограничение във времето няма 504: има сървър, който му свършват ресурсите.
Упражнения
Четирите се проверяват сами: всяко завършва с изпълнение, което ти казва дали си познал. Запиши прогнозата си в дневника преди да изпълниш нещо. За 1, 3 и 4 ти трябват лабораторията и revisor от 4.7, изпълнявани, току-що стартирани.
Упражнение 1 — Кой е виновен?
Предскажи кода на всяка от тези шест заявки и за всяка кой е виновен и кой може да я поправи. Внимание: не всички са грешки на сървъра.
curl -i http://127.0.0.1:18080/servicios/3/salud -H 'Authorization: Bearer lectura-a'
curl -i http://127.0.0.1:18080/servicios/4/informe -H 'Authorization: Bearer lectura-a'
curl -i -X POST http://127.0.0.1:18080/servicios/5/revisiones -H 'Authorization: Bearer lectura-a'
curl -i http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer escritura-a'
curl -i http://127.0.0.1:18080/servicios/2/salud -H 'Authorization: Bearer escritura-a'
curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":"si"}'
След това ги изпълни и сравни.
Упражнение 2 — Retry-After, който не е валиден
В 4.5.3 видя, че segundosDeEspera не използва Date.parse. Това упражнение ти показва защо. Напиши наивна версия, segundosConDateParse, която приема цифрите и предава всичко останало на Date.parse, и я сравни с тази на revisor в програма (ejercicios/ej2.ts) с тези девет стойности:
"120","1.5","-1"и"12 30""Xyz, 21 Oct 2026 10:00:00 GMT"и"Wed, 32 Oct 2026 10:00:00 GMT"- същата дата, след една минута, в трите форми на HTTP-date: предпочитаната, тази на RFC 850 и тази на
asctime
Преди да изпълниш нещо, реши кои са валидните стойности на Retry-After според граматиката на RFC 9110 и предскажи какво връща всяка версия за всяка. Подсказка: граматиката на RFC изброява дните и месеците и казва, че HTTP датата винаги е в GMT; Date.parse не знае нищо за това.
Упражнение 3 — Изброителят срещу ограничението
С лабораторията и revisor от 4.7, току-що стартирани, предскажи какви кодове получава escritura-b, като поиска проверки на id-та от 1 до 10, едно след друго. После escritura-a иска три проверки на услуга 2, а веднага след това admin иска една на същата услуга: какво получава admin?
for id in 1 2 3 4 5 6 7 8 9 10; do curl -sS -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$id/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer admin'
Сега направи копие на fig04_01/ и в неговия servicios.ts смени ограничението на pedirRevision с онова, което изглежда най-естествено: за услуга (ключът revisiones: плюс id на услугата) и след encontrar. Не пипай своя fig04_01/, който е revisor, получаван от Урок 5. Стартирай копието, повтори същите заявки и предскажи отново. Коя от двете версии спира изброителя? Коя наказва admin за онова, което е направил escritura-a?
Упражнение 4 — Без лаборатория
Спри лабораторията (Ctrl+C в нейния терминал) и остави revisor да работи. Предскажи какво отговарят тези заявки и колко отнема втората:
curl -i http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'
curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer lectura-a'
curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer escritura-a'
sleep 2
curl http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer lectura-a'
Услуга 5 даваше 504 в 4.4.3. Продължава ли да дава 504?
Решения
Решение 1
$ curl -i http://127.0.0.1:18080/servicios/3/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:19:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 3
$ curl -i http://127.0.0.1:18080/servicios/4/informe -H 'Authorization: Bearer lectura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Content-Length: 63
Date: Wed, 21 Oct 2026 13:19:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
error interno; referencia d88e2544-137d-436f-b5d2-c206d50ca348
$ curl -i -X POST http://127.0.0.1:18080/servicios/5/revisiones -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:19:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
$ curl -i http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 504 Gateway Timeout
Content-Type: text/plain; charset=utf-8
Content-Length: 39
Date: Wed, 21 Oct 2026 13:19:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 5 no respondió en 1000 ms
$ curl -i http://127.0.0.1:18080/servicios/2/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35
Date: Wed, 21 Oct 2026 13:19:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicio":2,"codigo":503,"ms":5}
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":"si"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 30
Date: Wed, 21 Oct 2026 13:19:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
activo: debe ser true o false
По ред:
404: услуга 3 е на екип b и заlectura-aне съществува. Това е грешка на клиента, същата като в Урок 2, а заявката за състоянието никога не излезе в мрежата. Вина и поправка: на клиента.500: услуга 4 (inventario) няма завършени проверки иinformeгърми, също както с 1. Вина: кодът наrevisor. Поправка: онзи, който го експлоатира, като поправиinforme(препратката от отговора води до стека в терминала).403: да поискаш проверка е писане, аlectura-aсамо чете. Не е429: въпрос 4 на входната точка се отговаря преди ограничението, което живее вътре в манипулатора. Вина и поправка: на клиента или на онзи, който му е дал това удостоверение.504:reportesне отговаря за 1000 ms. Вина: услугата за отчети. Поправка: онзи, който я експлоатира.revisorняма какво да поправя.200:pagosотговори503иrevisorго съобщи успешно. Никой не е сгрешил с тази заявка; лошата новина е стойността, която носи.422:"si"не е булева стойност.adminмина входната точка и проверката на ролята, а тялото можа да се прочете, но не може да се изпълни: това е политиката400/422от Урок 3. Вина и поправка: на клиента.
Само две от шестте са грешки на сървъра и са на два различни сървъра. Ако си сгрешил третата, прегледай реда: разрешението на ролята се пита във входната точка, а ограничението — вътре в манипулатора.
Решение 2
Само "120" е валиден delay-seconds (1*DIGIT). "1.5", "-1" и "12 30" не са нито секунди, нито HTTP дата. "Xyz, …" има формата на предпочитаната дата, но Xyz не е ден: RFC изброява седемте. "Wed, 32 Oct …" има правилната форма, но 32 октомври не съществува. Последните три са валидни: предпочитаната и двете остарели, които онзи, който чете, трябва да приема (раздел 5.6.7). Правилна функция връща 120, пет null и около 60 секунди три пъти. Тази програма сравнява двете:
// ejercicios/ej2.ts — Retry-After: la lectura del revisor contra una ingenua que confía en Date.parse.
import { segundosDeEspera } from "../fig04_01/cliente.ts";
// La versión ingenua: dígitos, o lo que Date.parse quiera entender como fecha.
function segundosConDateParse(valor: string): number | null {
if (/^\d+$/.test(valor)) return Number(valor);
const fecha = Date.parse(valor);
return Number.isNaN(fecha) ? null : Math.max(0, Math.ceil((fecha - Date.now()) / 1000));
}
// La misma fecha, un minuto adelante, en las tres formas de HTTP-date (RFC 9110, sección 5.6.7).
const enUnMinuto = new Date(Math.ceil((Date.now() + 60_000) / 1000) * 1000);
const imf = enUnMinuto.toUTCString();
const [, dia, num, mes, anio, hora] = /^(\w{3}), (\d{2}) (\w{3}) (\d{4}) (\S+) GMT$/.exec(imf) ?? [];
const DIAS_LARGOS: Record<string, string> = {
Mon: "Monday", Tue: "Tuesday", Wed: "Wednesday", Thu: "Thursday", Fri: "Friday", Sat: "Saturday", Sun: "Sunday",
};
const rfc850 = `${DIAS_LARGOS[dia]}, ${num}-${mes}-${anio.slice(2)} ${hora} GMT`;
const asctime = `${dia} ${mes} ${num.replace(/^0/, " ")} ${hora} ${anio}`;
const VALORES = ["120", "1.5", "-1", "12 30", "Xyz, 21 Oct 2026 10:00:00 GMT", "Wed, 32 Oct 2026 10:00:00 GMT", imf, rfc850, asctime];
console.log(`${"valor".padEnd(36)}${"Date.parse".padStart(11)}${"revisor".padStart(9)}`);
for (const valor of VALORES) {
const ingenua = segundosConDateParse(valor);
console.log(`${JSON.stringify(valor).padEnd(36)}${String(ingenua).padStart(11)}${String(segundosDeEspera(valor)).padStart(9)}`);
}
$ node ejercicios/ej2.ts
valor Date.parse revisor
"120" 120 120
"1.5" 0 null
"-1" 0 null
"12 30" 0 null
"Xyz, 21 Oct 2026 10:00:00 GMT" 0 null
"Wed, 32 Oct 2026 10:00:00 GMT" null null
"Wed, 21 Oct 2026 13:20:57 GMT" 61 61
"Wednesday, 21-Oct-26 13:20:57 GMT" 61 61
"Wed Oct 21 13:20:57 2026" 21661 61
(Трите валидни дати дават 61, а не 60, защото програмата закръглява „след една минута“ до следващата секунда, а segundosDeEspera закръглява нагоре.)
Колоната на revisor е правилната във всичките девет реда. Тази на Date.parse има два вида грешки.
Приема онова, което не е дата. При "1.5", "-1", "12 30" и "Xyz, …" наивната версия връща 0, „можеш да опиташ веднага“. Не са цифри, затова се опитва да ги прочете като дата, а Date.parse на V8 е толкова либерален, че ги приема: "1.5" го чете като дата от 2001 г., а в "Xyz, …" дори не поглежда името на деня. Тъй като тези дати вече са минали, резултатът е 0. Сървър, който изпрати зле написан Retry-After, получава обратното на онова, което е искал: клиент, който настоява веднага. Версията на revisor връща null, което е честното: „не ми казаха колко да чакам“. (При "Wed, 32 Oct …", обратно, Date.parse отказа; Date.UTC, който използва fechaHttp, би го приел и затова fechaHttp проверява датата накрая.)
Чете asctime в местния час. С датата във формат asctime наивната версия връща 21661, около шест часа, а тази на revisor — 61. Форматът asctime не записва часовата зона, а RFC казва, че е GMT; Date.parse я чете като местен час. Това изпълнение е направено на машина в UTC−6, така че датата се оказа шест часа в бъдещето. На машина в UTC двете колони биха дали същото: това е повреда, която зависи от това къде върви програмата, най-лошият вид повреда, защото не се вижда на машината на онзи, който я е написал.
Урокът за твоя собствен код: чети едно заглавие с неговата граматика, а не с най-близката функция, която „изглежда, че работи“. Date.parse върши работа, за да прочетеш дата, за която вече знаеш, че е дата, и в зона, за която вече знаеш каква е.
Решение 3
С revisor от fig04_01/:
$ for id in 1 2 3 4 5 6 7 8 9 10; do curl -sS -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$id/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
404 404 202 429 429 429 429 429 429 429
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer admin'
HTTP/1.1 202 Accepted
Location: /revisiones/5
Content-Type: application/json
Content-Length: 60
Date: Wed, 21 Oct 2026 13:19:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":5,"servicio":2,"estado":"pendiente","resultado":null}
Изброителят получава 404 404 202 и след това 429 седем пъти: първите три заявки напълниха прозореца му, макар две да са 404, защото ограничението се пита преди търсенето на услугата и брои за удостоверение. А admin получава 202: онова, което е изразходвало escritura-a, не го плаща друго удостоверение.
С копието, в което ограничението брои за услуга и се пита след encontrar (цялостният файл е в ejercicios/ej3/servicios.ts):
export function pedirRevision({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
// Ejercicio 3: el límite como estaba antes, por servicio y después de encontrar. NO es el del revisor.
const decision = permitir(`revisiones:${servicio.id}`, REVISIONES_POR_VENTANA, VENTANA_MS);
if (!decision.permitido) {
fallar(respuesta, 429, `ya pediste ${REVISIONES_POR_VENTANA} revisiones de este servicio en el último minuto`, {
"Retry-After": String(decision.esperaS),
});
return;
}
const revision = crearRevision(servicio.id);
setTimeout(async () => {
terminarRevision(revision.id, await revisar(servicio.url, servicio.limiteMs));
}, ESPERA_EN_COLA_MS);
enviarJson(respuesta, 202, representarRevision(revision), { Location: `/revisiones/${revision.id}` });
}
$ for id in 1 2 3 4 5 6 7 8 9 10; do curl -sS -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$id/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
404 404 202 404 404 404 404 404 404 404
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -i -X POST http://127.0.0.1:18080/servicios/2/revisiones -H 'Authorization: Bearer admin'
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: text/plain; charset=utf-8
Content-Length: 62
Date: Wed, 21 Oct 2026 13:19:37 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya pediste 3 revisiones de este servicio en el último minuto
Двата резултата се обръщат. Изброителят обхожда десетте id-та без нито едно 429: всяко 404 излиза от encontrar, преди да докосне ограничителя, и единственото, което го докосва, е 3, веднъж. При сто хиляди id-та би било същото. А admin получава 429 с Retry-After: 60, заради три проверки, които е поискал друг: кодът го обвинява в темпо, което не е било негово. „Естествената“ версия има двата дефекта едновременно и затова revisor не я използва. Спри копието, когато приключиш.
Решение 4
$ curl -i http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain; charset=utf-8
Content-Length: 38
Date: Wed, 21 Oct 2026 13:19:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no se pudo conectar con el servicio 1
$ curl -i -w 'tardó %{time_total} s\n' http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer lectura-a'
HTTP/1.1 502 Bad Gateway
Content-Type: text/plain; charset=utf-8
Content-Length: 38
Date: Wed, 21 Oct 2026 13:19:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no se pudo conectar con el servicio 5
tardó 0.001450 s
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer escritura-a'
202
$ sleep 2
$ curl http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer lectura-a'
{"id":1,"servicio":1,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":null,"ms":null,"detalle":"sin conexión"}}
И двете са 502, „не можа да се свърже“: със спряна лаборатория никой не слуша на порт 18081, връзката се отхвърля (ECONNREFUSED) и consultar я класифицира като sin-conexion. Услуга 5 вече не дава 504: дава 502 и за по-малко от 2 милисекунди (0.001450 s), а не за 1000. 504 изисква някой да приеме връзката и да не отговори; тук никой не я прие и опитът приключи начаса. Същата услуга, със същата граница, може да произведе два различни кода в зависимост от начина, по който се проваля онова, което е отзад. А проверката, поискана със спряна лаборатория, остана CAIDO с "detalle":"sin conexión", без код и без милисекунди: не е имало отговор и резултатът не измисля такъв. Стартирай отново лабораторията, когато приключиш.
Как знам, че съм успял
- За всеки от петте кода на урока казваш по памет кой е виновен, кой може да го поправи и какво прави клиентът.
-
curl -iкъм/servicios/1/informe, току-що стартиранrevisor, дава500сerror interno; referencia <uuid>и намираш тази препратка в терминала, със стека, който започва вservicios.ts. -
/servicios/2/saludдава200с"codigo":503и обясняваш защо не е503. -
/servicios/5/saludдава504за малко повече от секунда, а/servicios/6/saludдава502почти начаса. - С режима на поддръжка, включен от
admin, всяка заявка, дори към несъществуващ маршрут и без удостоверение, дава503сRetry-After: 120; аPUT /mantenimientoсescritura-aдава403. - Четвъртата проверка, поискана със същото удостоверение за минута, дава
429сRetry-After: 60, а двайсет секунди по-късно — с по-малъкRetry-After; проверка с друго удостоверение продължава да дава202. -
escritura-b, която иска проверки на id-та от 1 до 10, получава404 404 202и след това429и обясняваш защо ограничение за услуга не би я спряло. - Обясняваш, с изпълнението на
fig04_02.ts, защо фиксиран прозорец от 3 в секунда пропуска 5 за по-малко от 70 ms. - Обясняваш какво разкрива един стек в тялото на
500, с изпълнението наdefectos/pila/. - Когато приключиш, не остава нито една програма да слуша:
curl -i http://127.0.0.1:18080/иcurl -i http://127.0.0.1:18081/codigo/200дават и двете грешка 7.
Резюме
Отговори, без да гледаш урока:
- На кои два въпроса трябва да се отговори при всяка повреда и какво отговарят
429,500,502,503и504? - Защо
429е4xx, ако говори за капацитет? И защо503е5xx, ако сървърът работи? - Какво условие споделят
502и504в RFC 9110 и какво ги разделя? - Защо заявката за състоянието на услуга, която отговаря
503, е200? - Кои са двата вида на
Retry-Afterи защо1.5не е валидна стойност? - Какво влиза в тялото на
500, какво влиза в терминала и какво ги свързва? - Защо ограничението на проверките брои за удостоверение и се пита преди
encontrar? Какво би станало с ограничение за услуга? - Какъв дефект има фиксираният прозорец и кои алгоритми го избягват?
Източници
Всички са консултирани на 21 октомври 2026 г.
- RFC 9110, HTTP Semantics, раздел 15.6: семейството
5xx, „сървърът знае, че е сгрешил, или не е в състояние“, и обяснението дали условието е временно, или постоянно. - RFC 9110, раздел 15.6.1:
500, неочаквано условие, което е попречило да се изпълни заявката. - RFC 9110, раздел 15.6.3:
502, невалиден отговор, получен, докато се действа като шлюз или прокси; и раздел 3.7: какво е шлюз и какво е прокси и термините inbound и upstream. - RFC 9110, раздел 15.6.4:
503, временно претоварване или планирана поддръжка,Retry-Afterпо избор и бележката, че претоварен сървър може просто да отхвърли връзката. - RFC 9110, раздел 15.6.5:
504, отговор, който не е пристигнал навреме, докато се действа като шлюз или прокси. - RFC 9110, раздел 10.2.3:
Retry-After = HTTP-date / delay-seconds,delay-seconds = 1*DIGITи смисълът му при503; и раздел 5.6.7: трите формата на HTTP датата, които онзи, който чете, трябва да приема. - RFC 6585, Additional HTTP Status Codes, раздел 4:
429, „твърде много заявки за дадено време“,Retry-Afterпо избор, свободата да се идентифицира потребителят и да се брои и забраната да се запазва в кеш; и раздел 7.2: отговарянето с429под атака също харчи ресурси и не е задължително. - MITRE, CWE-209, Generation of Error Message Containing Sensitive Information: стековете, пътищата и структурата на заявките в съобщение за грешка като слабост.
- OWASP, Error Handling Cheat Sheet: подробностите за грешката се записват от страна на сървъра, а на потребителя се отговаря с общо съобщение; и Logging Cheat Sheet: токените за достъп и чувствителните данни не се записват такива, каквито са.
- OWASP API Security Top 10, API4:2023 Unrestricted Resource Consumption: да се ограничава колко пъти един клиент взаимодейства с API за дадено време и да се задават ограничения за времето за изпълнение.
- OWASP, Authentication Cheat Sheet, раздел Protect Against Automated Attacks: ограничението на опитите срещу груба сила, броено за сметка, а не само за адрес на произход.
- IETF, RateLimit header fields for HTTP,
draft-ietf-httpapi-ratelimit-headers-11(23 май 2026 г.): определяRateLimit-PolicyиRateLimit; проект е, а не RFC. - Документация на REST API на GitHub, Rate limits for the REST API:
403или429при надхвърляне на ограничение,retry-after,x-ratelimit-remainingиx-ratelimit-reset. - MDN,
AbortSignal.timeout(): сигналът прекратява сDOMExceptionс имеTimeoutError, различно отAbortError; и Node.js 24:crypto.randomUUID(): UUID от версия 4, генериран с криптографски генератор. - Ръководство на
curl:-m(--max-time),--http0.9и изходните кодове 1 (неподдържан протокол), 7 (не можа да се свърже), 18 (непълно предаване), 28 (времето изтече) и 52 (сървърът не отговори нищо).
Термини в този урок
| шлюз (gateway) | сървър, който, за да отговори, пита нещо друг сървър и отговаря с онова, което е получил; ако другият се провали, отговаря 502 или 504 |
| зависимост | услуга, от чийто отговор друга има нужда, за да си върши работата |
| препратка | случаен идентификатор, който пътува в 500 и остава в дневника до стека, за да се намери една грешка, без да се разкрива нищо |
| стек от извиквания (stack trace) | списъкът от функции, които са се изпълнявали, когато е възникнала грешка, от най-скорошната към най-старата |
| дневник | онова, което сървърът пише за онзи, който го експлоатира; в revisor — неговият терминал |
| ограничаване на заявките (rate limiting) | колко заявки се приемат за дадено време; при надхвърляне — 429 |
| фиксиран прозорец | ограничение, което брои от началото на един прозорец и се връща на нула, когато прозорецът свърши |
| режим на поддръжка | състояние, в което сървърът решава да не обслужва и отговаря 503 с Retry-After |
Retry-After |
заглавието, което казва колко да се чака, преди да се поиска отново: секунди или HTTP дата |
Предпочитате имейл? Пишете ни на hola@habil.mx