Съдържание на курса
Урок 6 — Повторни опити и идемпотентност
От Dorian Chávez · основател и главен изпълнителен директор на Hábil ·
Часът е 23:50 в последния ден на месеца и екипът по експлоатация регистрира със скрипт четиринайсетте нови услуги, които revisor трябва да следи от полунощ. Скриптът не говори с revisor директно: минава през шлюза на компанията, както всичко останало. В 23:52 шлюзът има лоша минута. Скриптът изпраща регистрацията на agenda, revisor я получава, записва я и отговаря 201, а този отговор се губи по обратния път. До скрипта стига 502 Bad Gateway. Онзи, който го е писал, е бил предпазлив: при 502 скриптът опитва отново. Вторият опит минава и revisor отговаря 409 Conflict: „вече съществува услуга с име agenda“. Скриптът третира 409 като грешка, спира и оставя тринайсет услуги нерегистрирани. В 00:00 започват проверките и половината табло е празно. Никой не знае дали agenda я е създал скриптът или някой друг, защото 409 не го казва. А дежурният, за да не рискува, изтрива agenda и я създава отново ръчно точно когато друг колега пуска целия скрипт наново.
Нито едно от тези решения не беше глупаво. Липсваше начин да повториш една заявка, без да се боиш, че ще направи едно и също нещо два пъти, и ясно правило кога да я повториш. Точно това изгражда този урок.
Време: 2 × 45 мин (прекъсването е между раздели 6.4 и 6.5)
Какво изграждаш: POST /servicios, който може да се повтаря, без да създава дубликати, докато сървърът помни ключа (раздел 6.7 казва докъде стига това), със заглавието Idempotency-Key; шлюз, който губи отговори нарочно, за да видиш 502, който не казва дали записът се е случил; клиент, който повтаря регистрацията със същия ключ; проверките на revisor с повторни опити, експоненциално отлагане (backoff) и jitter; и програма, която проверява идемпотентността на регистрацията
Какво научаваш: какво са безопасните и идемпотентните методи според RFC 9110 и какво идемпотентността не обещава; двата въпроса, които решават дали да има повторен опит; експоненциалното отлагане с пълен jitter и защо случайността пази сървъра; кога да се подчиниш на Retry-After и кога да не чакаш; Idempotency-Key и защо все още не е стандарт; колко трае един ключ, какво става, ако тялото се промени, и какво не покрива; и какво да правиш с 502, който не казва дали записът се е случил
Откъде идваш. От Урок 5, с revisor, чиито грешки вече са Problem Details: стабилен type, title, който не се променя, detail за човека, който чете. В Урок 4 се научи да разчиташ Retry-After в двата му вида и видя, че една проверка на pagos остава с "detalle":"503 (reintentar en 120 s)", но revisor не правеше нищо с това число. В Урок 1 видя, че втори DELETE не променя нищо и че това си има име: идемпотентност. Този урок събира трите неща. Досега повторената регистрация създава друга услуга или се блъска в 409 за повтореното име, без да знаеш дали услугата е била твоя, а проверките никога не опитват отново.
Какво не прави този урок. Не пише таблицата, която решава какво да се прави с всеки код от страна на клиента; това е Урок 7, който решава и какво да се прави с 409 за ключ, чиято обработка още тече. Не документира заглавието в OpenAPI; това е Урок 8. Не пази ключовете в база данни: revisor ги пази в паметта, а раздел 6.7 казва какво се променя в истински API. И не прилага споделен бюджет за повторни опити, нито прекъсвач (circuit breaker): споменава ги в 6.3.3, защото съществуват и е добре да ги познаваш, но revisor само ограничава колко пъти повтаря всяка заявка.
За изпълненията. Linux Mint 22 е референтната система на курса. Изходите на този урок са получени на 21 октомври 2026 г. под macOS с Node 24.14.1 и curl 8.7.1, като към всеки curl беше добавено -sS, защото изходът отиваше във файл (-s изключва индикатора за напредъка, -S оставя грешките видими и нито едното, нито другото променя отговора). Всички серии бяха изпълнени на портове 18080, 18081 и 18082, които ще въвеждаш и ти, върху крайната версия на програмите, и всеки файл .salida.txt е записът такъв, какъвто е излязъл, с изключение на това, че в редовете с команди -sS е премахнато (fig06_02.salida.txt не използва сървъри: това е случайна извадка). Счупените версии от „Грешката, която ще видиш“ и решението на упражнение 3 бяха изпълнени от копия в други папки; в изходите проверяващата програма се появява като node fig06_03.ts, защото така ще я изпълняваш ти. При твоето изпълнение ще се различават датата в Date, последните осем цифри на всеки ETag (revisor ги избира случайно при стартиране), измерените милисекунди, изчакванията, които избира jitter (тези на fig06_02.ts са различни всеки път, нарочно), ключовете, които измисля alta-segura.ts, и имената, които измисля fig06_03.ts. revisor пази всичко в паметта, и услугите, и ключовете: за да получиш същото като в урока, стартирай наново лабораторията, revisor и шлюза преди всяка серия заявки. Не е изпълнявано на десктоп инсталация на Mint и нищо от използваното не зависи от macOS. Програмите са в programas/06-reintentos-idempotencia/, в хранилището на курса: revisor във fig06_01/, отлагането, видяно отблизо, във fig06_02.ts, проверката на идемпотентността във fig06_03.ts, симулираният сървър във fig06_04.ts, счупените версии в defectos/, решението на упражнение 3 в ejercicios/ej3/ и всяко изпълнение в своя файл .salida.txt (това с изчерпаните опити — в agotados.salida.txt). Всички минават tsc в строг режим (раздел 6.10).
След урока ще можеш да
- Да кажеш за всеки HTTP метод дали е безопасен, дали е идемпотентен, или не е нито едното, нито другото, като цитираш RFC 9110, и да го докажеш, като повториш заявка срещу
revisorи сравниш състоянието преди и след. - Да обясниш защо повторен
DELETE, който отговаря204и после410, остава идемпотентен и защо повторенPUTсIf-Matchотговаря412, макар промяната му да се е случила. - Да решиш дали една повреда заслужава повторен опит с два въпроса — може ли да се оправи сама? и безопасно ли е да се повтори? — и да обосновеш защо
revisorповтаря502, а не404. - Да изчислиш горната граница на изчакването за всеки опит с експоненциално отлагане и пълен jitter и да покажеш с изпълнение, че случайността разпределя повторните опити на хиляда клиенти.
- Да накараш повторен
POSTсъс същияIdempotency-Keyда върне същия отговор, без да създаде друг ресурс, и да предизвикаш двата отказа, които предлага проектът:422, ако ключът пристигне с друго тяло, и409, ако оригиналната заявка още се обработва. - Да обясниш границите на един ключ за идемпотентност: колко трае, какво се брои за „същото тяло“ и какво не покрива, всяко с изпълнение, което го доказва.
- Да обясниш какво прави клиентът, когато опитите му се изчерпят или никой не му отговаря (излиза със съобщение и с код
1, без да чака излишно), и да предизвикаш тези случаи със спрянrevisorили спрян шлюз. - Да предвидиш какво прави
alta-segura.tsс4xx, с кратъкRetry-Afterи с дълъг, със сървър, който не отговаря, и с двата409, и да го провериш със симулирания сървър. - Да изпълниш програма, която проверява идемпотентността на регистрацията, и да разчетеш грешката, която тя отчита срещу нарочно счупена версия.
Защо, преди как
Повторният опит е най-естественият отговор на повреда, а също и най-опасният. Естествен, защото много повреди са временни: услуга, която се е рестартирала, шлюз с лоша минута, връзка, която е прекъснала. Опасен, по две причини, които този урок разделя внимателно.
Първата е, че не винаги знаеш дали поисканото се е случило. Когато една заявка се провали, преди да стигне до сървъра, повторението ѝ е безвредно: сървърът никога не я е видял. Когато се провали, след като сървърът я е обслужил, но преди отговорът да стигне до теб, повторението ѝ означава да поискаш същото за втори път. От твоя страна двата случая изглеждат еднакво: 502, изтекло време, прекъсната връзка. Сцената от началото е точно това: revisor създаде agenda, а скриптът така и не разбра. Ако операцията е четене, няма значение. Ако операцията е създаване, плащане, изпращане на имейл или движение на наличности, повторението ѝ може да удвои ефекта.
Втората причина е, че повторният опит струва на сървъра. Ако една услуга се проваля, защото е претоварена, и хиляда клиенти опитват отново в един и същи миг, онова, което получава, не е облекчение, а нова вълна от хиляда заявки, а после още една. Зле направеният повторен опит превръща двусекундно препъване в двайсетминутен срив.
HTTP вече решава част от първия проблем. RFC 9110 класифицира методите: някои са идемпотентни и повторението им оставя сървъра в същото състояние, както ако са изпълнени веднъж. С тях повторният опит е безопасен по определение. Но методът, с който се създават неща, POST, не е идемпотентен, а точно при него повредата боли най-много. За него индустрията измисли заглавие, Idempotency-Key, което един проект на IETF се опита да стандартизира, без да стигне до публикуване като RFC. Вторият проблем, този с претоварения сървър, се решава в клиента: да се чака все повече между опитите, изчакването да се избира случайно и да се слуша сървърът, когато казва колко да се чака.
Урокът има четири части:
- Какво може да се повтаря (6.1 до 6.3):
revisorв този урок, безопасните и идемпотентните методи и двата въпроса, които решават дали да има повторен опит. - Как се повтаря (6.4): експоненциално отлагане, jitter и
Retry-After, с проверките наrevisor. - Онова, което не може да се повтаря… докато не стане възможно (6.5 до 6.7):
502, който не казва дали записът се е случил,Idempotency-Keyи неговите граници. - Пълният
revisor(6.8 до 6.10): файловете, изпълнението и програмата, която го проверява.
Концепциите
6.1 revisor в този урок
6.1.1 Какво се променя
revisor от Урок 5 получава три нови файла и променя четири:
idempotencia.ts(нов) пази за всяко удостоверение и за всеки ключ отпечатъка на заявката и отговора, който тя е получила. Това е раздел 6.6.pasarela.ts(нов) е отделна програма, която слуша на порт 18082 и препраща всичко къмrevisor. Със заглавиетоX-Simular: perder-primera, първия път, когато види дадена заявка, оставяrevisorда я обслужи и изхвърля отговора: на клиента отговаря502. Акоrevisorне отговаря (защото е спрян), също отговаря502и продължава да слуша. Това е раздел 6.5.alta-segura.ts(нов) е клиент, който регистрира услуга през шлюза и повтаря със същия ключ до четири пъти, а щом опитите се изчерпят, спира. Подчинява се наRetry-After, прекъсва всеки опит по време и излиза с 0 само ако регистрацията е направена. И той е от раздел 6.5.servicios.tsчетеIdempotency-Keyпри регистрацията, а проверките използватrevisarConReintentosвместоrevisar.cliente.tsотделя класификацията (clasificar) от заявката и получава отлагането с jitter иrevisarConReintentos. Това е раздел 6.4.problemas.tsдобавя два типа в каталога:llave-en-curso(409) иllave-reutilizada(422).respuestas.tsпроменя четенето на тялото: когато то надхвърли горната граница от 16 KiB,revisorдочита онова, което клиентът още изпраща, преди да отговори413, за да не се изгуби отговорът. Това е раздел 6.8.1.
Нито един маршрут, нито един код на входната точка и нито едно заглавие от предишните уроци не се променя. Новото заглавие е незадължително: регистрация без него се държи точно както в Урок 5.
6.1.2 Стартиране на трите
revisor живее в ~/curso-http/fig06_01/; раздел 6.8 съдържа всичките му файлове. Отвори три терминала в тази папка:
$ node laboratorio.ts
laboratorio en http://127.0.0.1:18081/codigo/NNN
$ node servidor.ts
revisor en http://127.0.0.1:18080
$ node pasarela.ts
pasarela en http://127.0.0.1:18082 → http://127.0.0.1:18080
До раздел 6.5 не използваш шлюза, но е добре да е стартиран отсега. И едно предупреждение, което важи за целия урок: revisor пази услугите, проверките и ключовете в паметта. Всяка серия заявки в този урок започва с трите програми, току-що стартирани; ако твоята следва друга, спри ги с Ctrl+C и ги стартирай наново, иначе идентификаторите и отговорите няма да съвпаднат.
6.2 Безопасен и идемпотентен: какво казва RFC 9110
6.2.1 Двете свойства
RFC 9110 определя две свойства на методите и е добре да ги прочетеш внимателно, защото лесно се бъркат.
Един метод е безопасен, ако семантиката му по същество е само за четене: онзи, който го използва, не иска, нито очаква каквато и да е промяна на състоянието в сървъра (раздел 9.2.1). Безопасните методи в RFC са GET, HEAD, OPTIONS и TRACE. „Безопасен“ не означава, че сървърът не прави нищо: самият RFC дава за пример дневника на достъпа, който расте с всеки GET и дори би могъл да напълни диска. Означава, че клиентът не е поискал този ефект и не носи отговорност за него.
Един метод е идемпотентен, ако ефектът, който се цели в сървъра с няколко еднакви заявки, е същият като с една-единствена (раздел 9.2.2). Идемпотентните в RFC са PUT, DELETE и всички безопасни. Думата идва от математиката: да приложиш една операция два пъти дава същото като да я приложиш веднъж.
А раздел 9.2.2 казва за какво служи разграничението, с яснота, която си струва да се цитира: идемпотентните методи се отличават с това, че заявката може да се повтори автоматично, ако комуникацията се провали, преди клиентът да прочете отговора. Ако връзката на един PUT се е затворила, преди да е получено каквото и да е, клиентът може да отвори друга и да го повтори: знае, че повторението ще има същия целен ефект, дори оригиналната заявка да е успяла, „though the response might differ“ — макар отговорът да може да е различен.
| Метод | Безопасен | Идемпотентен | Източник |
|---|---|---|---|
GET, HEAD, OPTIONS, TRACE |
да | да | RFC 9110, 9.2.1 и 9.2.2 |
PUT, DELETE |
не | да | RFC 9110, 9.2.2 |
POST |
не | не | RFC 9110, 9.3.3 |
PATCH |
не | не | RFC 5789, раздел 2 |
POST не е идемпотентен, защото семантиката му е „обработи това според собствените си правила“ (раздел 9.3.3), а една от типичните му функции е да създава ресурс, който сървърът още не е идентифицирал. Два еднакви POST могат да създадат два ресурса. PATCH според RFC 5789 не е нито безопасен, нито идемпотентен: една корекция може да казва „добави едно“, а да добавиш едно два пъти не е същото като да го добавиш веднъж.
Практическото следствие е в един ред от същия RFC: клиентът не бива да повтаря автоматично заявка с неидемпотентен метод, освен ако няма начин да знае, че точно тази заявка е идемпотентна, или начин да установи, че оригиналната никога не е била приложена. Тези два изхода са точно онова, което ще изградиш: първият е Idempotency-Key; вторият — съгласуването (да прочетеш отново състоянието и да видиш какво е станало). А един ред по-надолу има още едно правило, което е важно за онзи, който експлоатира шлюзове: проксито не трябва да повтаря автоматично неидемпотентни заявки. Шлюз, който повтаря POST на своя глава, може да удвои онова, което клиентът му никога не е искал да удвоява.
6.2.2 Да го видим в revisor
С трите програми, току-що стартирани, повтори всяка операция по два пъти и виж какво остава. Започни с PUT с неговия етикет, както те научи Урок 3:
$ ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-1-a9fec589"
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-2-a9fec589"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 22:25:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
Content-Language: es
Content-Length: 269
Date: Wed, 21 Oct 2026 22:25:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/etiqueta-vencida","title":"Tu etiqueta ya no es la vigente","status":412,"detail":"tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo","instance":"/servicios/1"}
$ curl http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
Първият PUT промени лимита на 1500 и отговори 200 с нов етикет. Вторият, еднакъв с него, отговори 412. И въпреки това крайното състояние е поисканото: "limite_ms":1500. Идемпотентността е спазена, защото ефектът от два PUT е същият като от един; промени се отговорът, а точно за това предупреждава RFC. 412 не казва „промяната ти не се случи“; казва „етикетът ти вече не е текущият“. Който повтаря условен PUT и получи 412, трябва да прочете отново, преди да заключи, че е загубил: възможно е текущата версия да е именно неговата.
Сега един DELETE два пъти:
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/4 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 22:25:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/4 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 410 Gone
Content-Type: application/problem+json
Content-Language: es
Content-Length: 188
Date: Wed, 21 Oct 2026 22:25:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/servicio-retirado","title":"El servicio se retiró","status":410,"detail":"el servicio 4 se retiró y no va a volver","instance":"/servicios/4"}
204 и после 410. Различни отговори, еднакъв ефект: след един или след два DELETE услуга 4 е изведена от поддръжка. Това е случаят, който Урок 1 предвиди. Клиент, който повтаря DELETE и получи 410 (или 404), не бива да го третира като повреда: онова, което е искал, вече е факт.
Сега един POST за регистрация два пъти, без нищо друго:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-a9fec589"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 22:25:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Content-Language: es
Content-Length: 220
Date: Wed, 21 Oct 2026 22:25:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/nombre-repetido","title":"Ya existe un servicio con ese nombre","status":409,"detail":"ya existe un servicio llamado avisos","instance":"/servicios","existente":"/servicios/7"}
Първият създаде avisos с идентификатор 7. Вторият не създаде друга, но не защото POST е идемпотентен: спря го бизнес правило — името да е уникално в рамките на екипа. Тази мрежа те спаси от дубликат, но в замяна те остави със съмнението от сцената: 409 казва, че /servicios/7 съществува, не казва кой го е създал. Ако първият ти опит се беше изгубил по пътя, нямаше да знаеш дали /servicios/7 е твой, или на колега, който е избрал същото име.
И един POST, който няма никаква мрежа:
$ curl -i -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer escritura-a'
HTTP/1.1 202 Accepted
Location: /revisiones/1
Content-Type: application/json
Content-Length: 60
Date: Wed, 21 Oct 2026 22:25:58 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"servicio":1,"estado":"pendiente","resultado":null}
$ curl -i -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer escritura-a'
HTTP/1.1 202 Accepted
Location: /revisiones/2
Content-Type: application/json
Content-Length: 60
Date: Wed, 21 Oct 2026 22:25:58 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":2,"servicio":1,"estado":"pendiente","resultado":null}
Две заявки, две проверки: /revisiones/1 и /revisiones/2. Искането на проверка не е идемпотентно и нищо не го спира. Тук цената е малка (една заявка повече към услугата), но виж модела: това е формата на всеки POST, който създава нещо без стойност, която да го прави уникално. Поръчка за покупка, плащане, съобщение.
6.2.3 Какво идемпотентността не обещава
Три неща, които се бъркат с идемпотентността, а не са.
Не обещава същия отговор. RFC го казва черно на бяло и току-що го видя: 204 и 410, 200 и 412. Клиент, който сравнява втория отговор с първия, за да реши дали нещо се е объркало, мери не онова, което трябва.
Не обещава, че нищо няма да се промени. Повторен PUT с If-Match: * оставя същото съдържание, но revisor вдига версията при всеки запис, така че ETag се сменя от "1-2-…" на "1-3-…" (ще го видиш в упражнение 1). RFC оставя място за това: идемпотентността се отнася до ефекта, който е поискал потребителят, а сървърът е свободен да води отделно история, дневници или версии.
Не е свойство на мрежата, а на метода и на сървъра. Това, че RFC казва, че PUT е идемпотентен, е обещание, което сървърът трябва да изпълни. Ако твоят сървър реализира PUT, който добавя, или DELETE, който изтрива „последния“, ти си нарушил обещанието и всички клиенти, проксита и библиотеки, които разчитат на него, ще удвояват ефекти, без да знаят. RFC го казва за безопасните методи и същото важи и тук: ако един URI прави нещо небезопасно с безопасен метод, отговорността е на онзи, който го е проектирал.
6.3 Кога да опиташ отново и кога не
6.3.1 Двата въпроса
Всеки път, когато една заявка се провали, revisor (и всеки клиент, който решиш да напишеш) си задава два въпроса, в този ред:
- Може ли да се оправи сама? Претоварена услуга, шлюз, който се рестартира, изтекло време: изчакването може да го оправи. Невалидно удостоверение, услуга, която не съществува, неправилно оформено тяло: не. Да повтаряш онова, което не може да се оправи само, означава само да хабиш търпението на сървъра и своето.
- Безопасно ли е да се повтори? Ако методът е идемпотентен — да. Ако не е — само с ключ за идемпотентност или ако знаеш, че оригиналната заявка никога не е стигнала.
Повторен опит има само ако двата отговора са „да“. Това е принцип на проектиране, а не норма: RFC ти казва кога е безопасно да повториш, а всеки API решава кога си струва.
6.3.2 Какво повтаря revisor
Проверките на revisor са GET към услугите, които следи: безопасни и идемпотентни, така че отговорът на втория въпрос винаги е „да“. На първия отговаря функцията vale в cliente.ts:
| Изход от заявката | Може ли да се оправи сама? | Повторен опит? |
|---|---|---|
| няма връзка, изтекло време, отговор, който не е HTTP | може: онова отзад може би тъкмо се стартира | да |
429 Too Many Requests |
да: ограничението се нулира с времето | да, ако Retry-After не иска повече от 5 s |
502, 503, 504 |
да: повредата е в нещо, което е отзад, или е временна | да (ако отговорът носи Retry-After — при същото условие) |
500 |
почти никога: това е дефект на сървъра | не |
всеки друг 4xx |
не: онова, което трябва да се промени, е заявката | не |
| забранена дестинация (SSRF) | не: правилото няма да се промени | не |
500 заслужава отделен ред, защото много клиенти все пак го повтарят. RFC 9110 казва, че 500 е неочаквано условие, което е попречило на сървъра да изпълни заявката; Урок 4 го третира като дефект на сървъра, а повторението на заявка срещу дефект почти винаги възпроизвежда дефекта. revisor решава да не го повтаря. Други API решават различно и това е решение, което се записва, а не такова, което се наследява от някоя библиотека.
Всеки от тези отговори може да носи Retry-After (обичайно е да го видиш при 429 или 503): сървърът казва колко да се чака и revisor се подчинява, ако е разумно. Ще го видиш в 6.4.3.
6.3.3 Колко пъти и на кой слой
RFC 9110 има още една фраза в края на раздел 9.2.2, която е добре да познаваш: клиентът не бива да повтаря автоматично автоматичен повторен опит, който се е провалил. Написана е за случая с прекъсната връзка (потребителският агент, който отваря връзката отново и повтаря веднъж), а духът ѝ е ясен: повторните опити трябва да имат край. revisor прави до три опита на заявка, а alta-segura.ts — до четири, така че надхвърлят буквата на правилото, и това е решение, взето пред очите на всички: те са клиенти на едно приложение, които познават операцията, чакат все повече и спират при фиксирана граница. Ако пишеш общ клиент, който не знае нищо за операцията, препоръката на RFC е благоразумната: един повторен опит и толкова.
Броят на опитите има по-голямо значение, отколкото изглежда, заради усилването. Ръководството на Amazon за времената за изчакване и повторните опити го обяснява с пример: пет слоя услуги, всеки от които повтаря по три пъти на своя глава, умножават 243 пъти натоварването върху базата данни в дъното точно когато тя е най-слаба. Книгата за SRE на Google разказва същото явление с три слоя: едно-единствено действие на един човек завършва с 64 опита срещу базата данни. Двата източника стигат до една и съща препоръка: повторни опити само на едно място във веригата, ограничаване на повторните опити за всяка заявка и бюджет за повторни опити за всеки процес, който спира повторенията, щом се изчерпа (книгата за SRE дава за пример 60 в минута; ръководството на Amazon предлага кофа с жетони (token bucket)).
В revisor проверките ги повтаря revisor и никой друг. Шлюзът в този урок не повтаря нищо: ако го правеше с POST, щеше да наруши правилото на RFC за проксита.
6.4 Експоненциално отлагане с jitter
6.4.1 Защо да чакаш все повече
Ако една заявка се е провалила, защото сървърът е претоварен, незабавният повторен опит влошава онова, което си искал да оправиш. Идеята на експоненциалното отлагане (exponential backoff) е да чакаш все повече: малко след първия неуспех, двойно след втория, пак двойно след третия, до една горна граница. Ако повредата е била за секунда, първият повторен опит я преодолява. Ако е била за минута, повторните опити се разреждат и оставят на сървъра пространство да се възстанови.
revisor използва основа от 250 ms и горна граница от 2000 ms. Таванът на опит n е 250 × 2^n, без да надхвърля 2000: 500 ms преди втория опит, 1000 преди третия, а след това вече би опрял в горната граница.
6.4.2 Защо случайно
Самото отлагане не стига. Ако хиляда клиенти се провалят в един и същи миг (защото сървърът е паднал за всички едновременно) и всички чакат точно 500 ms, след 500 ms сървърът получава хилядата повторни опита наведнъж. Това е същата вълна, малко по-късно. Решението, което Marc Brooker анализира и популяризира в блога за архитектура на AWS през 2015 г. и което днес е обичайната практика, е изчакването да се избира случайно: пълният jitter (full jitter) чака случайно време между 0 и тавана. Brooker сравни три начина за случаен избор (пълен, „равен“ и „декорелиран“): „равният“ излезе губещ, а между другите два разликата беше по-малко ясна. Курсът използва пълния, защото е най-простият за писане и за обясняване.
Ето как е написано в cliente.ts (фрагмент):
export function esperaConJitter(intento: number, baseMs: number, topeMs: number): number {
return Math.round(Math.random() * Math.min(topeMs, baseMs * 2 ** intento));
}
За да го видиш, fig06_02.ts използва същата функция, без сървър. Запиши го в ~/curso-http/fig06_02.ts, до папката fig06_01/, защото импортира ./fig06_01/cliente.ts. Първо избира случайно пет изчаквания за всеки опит; после симулира хиляда клиенти, които се провалят в един и същи миг и опитват отново веднъж, и брои колко повторни опита попадат във всеки прозорец от 100 ms:
// fig06_02.ts — el retroceso exponencial, con y sin jitter: cuánto espera cada intento y qué ve el servidor.
// Usa la misma función del revisor. No necesita ningún servidor: solo hace cuentas.
import { esperaConJitter } from "./fig06_01/cliente.ts";
const BASE_MS = 250;
const TOPE_MS = 2000;
// 1. El techo de cada intento: base × 2^intento, sin pasar del tope. El jitter completo sortea entre 0 y el techo.
console.log("intento techo (ms) cinco esperas sorteadas (ms)");
for (let intento = 1; intento <= 5; intento++) {
const techo = Math.min(TOPE_MS, BASE_MS * 2 ** intento);
const sorteadas = Array.from({ length: 5 }, () => esperaConJitter(intento, BASE_MS, TOPE_MS));
console.log(`${String(intento).padStart(7)} ${String(techo).padStart(10)} ${sorteadas.join(", ")}`);
}
// 2. Mil clientes fallan en el mismo instante y reintentan una vez (intento 1, techo de 500 ms).
// Se cuentan sus reintentos en ventanas de 100 ms, que es lo que el servidor recibe.
const CLIENTES = 1000;
const VENTANA_MS = 100;
function contar(esperas: number[]): number[] {
const cuentas = Array.from({ length: 6 }, () => 0);
for (const espera of esperas) cuentas[Math.min(5, Math.floor(espera / VENTANA_MS))]++;
return cuentas;
}
const sinJitter = contar(Array.from({ length: CLIENTES }, () => Math.min(TOPE_MS, BASE_MS * 2 ** 1)));
const conJitter = contar(Array.from({ length: CLIENTES }, () => esperaConJitter(1, BASE_MS, TOPE_MS)));
console.log();
console.log(`${CLIENTES} clientes reintentan a la vez; reintentos por cada ${VENTANA_MS} ms:`);
console.log("ventana (ms) sin jitter con jitter");
for (let i = 0; i < 6; i++) {
const ventana = i < 5 ? `${i * VENTANA_MS}-${(i + 1) * VENTANA_MS - 1}` : `${i * VENTANA_MS} o más`;
console.log(`${ventana.padEnd(12)} ${String(sinJitter[i]).padStart(10)} ${String(conJitter[i]).padStart(10)}`);
}
console.log(`la ventana más cargada: ${Math.max(...sinJitter)} sin jitter, ${Math.max(...conJitter)} con jitter`);
$ node fig06_02.ts
intento techo (ms) cinco esperas sorteadas (ms)
1 500 159, 181, 478, 282, 231
2 1000 451, 774, 876, 523, 309
3 2000 205, 575, 343, 1365, 1722
4 2000 1966, 464, 1257, 575, 492
5 2000 1555, 1327, 634, 75, 1138
1000 clientes reintentan a la vez; reintentos por cada 100 ms:
ventana (ms) sin jitter con jitter
0-99 0 179
100-199 0 192
200-299 0 190
300-399 0 217
400-499 0 219
500 o más 1000 3
la ventana más cargada: 1000 sin jitter, 219 con jitter
Първата таблица показва тавана на всеки опит и как изчакванията попадат където и да е под него: при опит 3 излязоха от 205 до 1722 ms. От опит 3 нататък таванът вече е горната граница от 2000. Втората таблица е тази, която има значение. Без jitter хилядата повторни опита попадат в един и същ прозорец, този на 500 ms: сървърът, който тъкмо се стартира, получава хиляда заявки наведнъж. С jitter те се разпределят в пет прозореца с по около двеста всеки. Работата е същата, но пикът е пет пъти по-нисък. (Трите, които попадат в „500 или повече“, са онези, за които е изтеглено число толкова близо до 500, че Math.round го е закръглил на 500.) Твоите числа ще са различни; формата — същата.
6.4.3 Retry-After командва, с горна граница
Когато сървърът казва колко да се чака, клиентът не гадае: подчинява се. Retry-After (RFC 9110, раздел 10.2.3) посочва колко трябва да изчака потребителският агент преди следващата заявка, в секунди (120) или като HTTP дата. При 503 посочва колко се очаква услугата да остане недостъпна (същият раздел 10.2.3); раздел 15.6.4 добавя, че сървърът може да го изпрати, за да подскаже колко да се изчака преди повторен опит; при 429 RFC 6585 позволява да се използва, за да се каже кога да се опита отново. Урок 4 те научи да го разчиташ стриктно: само цифри или един от трите вида HTTP дата, винаги в GMT, а всичко друго се пренебрегва.
А ако сървърът поиска да се чака две минути? Проверка, която заспива за две минути в setTimeout, не служи на никого: резултатът пристига късно, а междувременно заема памет. Затова revisor има горна граница: ако Retry-After иска повече от 5 секунди, не настоява и отбелязва в резултата какво е казала услугата. Това е същата грижа като при периодичното запитване (polling) от Урок 1, което ограничаваше изчакването между 1 и 5 секунди, за да не го приспи сгрешил сървър за един час, но тук решението е друго. Периодичното запитване пита за състоянието на една задача — евтино четене — и може да попита отново след 5 s. Проверка, която опитва отново след 5 s, когато услугата е поискала 120, пристига по-рано, отколкото услугата е поискала: това би било неподчинение. Честното е да не се опитва отново и да се каже защо. Ако Retry-After липсва или не може да се разчете, се използва отлагането с jitter.
6.4.4 Проверките с повторни опити
revisarConReintentos събира всичко (фрагмент от cliente.ts; пълният файл е в 6.8.1):
const INTENTOS = 3;
const BASE_MS = 250;
const TOPE_MS = 2000;
const TOPE_RETRY_AFTER_S = 5;
function vale(consulta: Consulta): boolean {
if (consulta.tipo === "destino-prohibido") return false;
return consulta.tipo !== "respuesta" || [429, 502, 503, 504].includes(consulta.codigo);
}
export async function revisarConReintentos(url: string, limiteMs: number): Promise<Resultado> {
let consulta = await consultar(url, limiteMs);
for (let intento = 1; intento < INTENTOS && vale(consulta); intento++) {
const pedida = consulta.tipo === "respuesta" ? consulta.reintentarEn : null;
if (pedida !== null && pedida > TOPE_RETRY_AFTER_S) break;
const espera = pedida !== null ? pedida * 1000 : esperaConJitter(intento, BASE_MS, TOPE_MS);
await new Promise((listo) => setTimeout(listo, espera));
consulta = await consultar(url, limiteMs);
}
return clasificar(consulta, limiteMs);
}
Прочети го на глас: прави заявката веднъж; докато има оставащи опити и повредата си струва, гледа дали услугата е поискала изчакване; ако е поискала повече от 5 s, излиза от цикъла; ако е поискала нещо разумно, чака толкова; ако не е поискала нищо, избира случайно; и прави заявката отново. Накрая класифицира последната заявка със същата функция като винаги, която урокът отдели от revisar, за да може да я използва тук. Обърни внимание къде е изчакването: в тялото на for, точно преди следващата заявка. Когато опитите се изчерпат (intento < INTENTOS престане да е вярно), тялото вече не се изпълнява, така че никога не се чака след последната заявка. Редът на двете условия не променя това: само избягва извикването на vale, когато вече не остават опити.
За да го видиш в действие, са нужни услуги, които се провалят по различни начини. С трите програми, току-що стартирани, регистрирай три услуги от лабораторията: една, която отговаря 502, друга — 429 (с Retry-After: 30), и трета — 404. После поискай по една проверка за всяка от тях и още една за pagos, която отговаря 503 с Retry-After: 120. Първите три с escritura-a, а четвъртата с admin, защото всяко удостоверение има по три проверки в минута (Урок 4):
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"puerta","url":"http://127.0.0.1:18081/codigo/502"}'
201 /servicios/7
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"cupo","url":"http://127.0.0.1:18081/codigo/429"}'
201 /servicios/8
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"ausente","url":"http://127.0.0.1:18081/codigo/404"}'
201 /servicios/9
$ 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/7/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/8/revisiones -H 'Authorization: Bearer escritura-a'
202
$ curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/9/revisiones -H 'Authorization: Bearer admin'
202
$ sleep 4
$ for i in 1 2 3 4; do curl http://127.0.0.1:18080/revisiones/$i -H 'Authorization: Bearer escritura-a'; done
{"id":1,"servicio":2,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":503,"ms":12,"detalle":"503 (reintentar en 120 s)"}}
{"id":2,"servicio":7,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":502,"ms":1,"detalle":"502"}}
{"id":3,"servicio":8,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":429,"ms":1,"detalle":"429 (reintentar en 30 s)"}}
{"id":4,"servicio":9,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":404,"ms":2,"detalle":"404"}}
Четирите резултата казват CAIDO и в Урок 4 щяха да казват същото. Разликата е в терминала на лабораторията, който отпечатва всяка заявка, която получава:
--- terminal del laboratorio:
laboratorio en http://127.0.0.1:18081/codigo/NNN
GET /codigo/503
GET /codigo/502
GET /codigo/429
GET /codigo/404
GET /codigo/502
GET /codigo/502
Преброй редовете. /codigo/503 се появява веднъж: поискала е да се чака 120 s, повече от горната граница, и revisor не настоя; затова детайлът запазва (reintentar en 120 s). /codigo/429 също се появява веднъж, по същата причина (30 s). /codigo/404 се появява веднъж: 404 не се оправя сам. А /codigo/502 се появява три пъти: първия път по ред с останалите, а другите два — след тях, разделени от случайно избраните изчаквания (до 500 ms и до 1000 ms). Резултатът е този от третия опит, който също се провали. Три опита, две изчаквания и нито един повече.
Ако се питаш защо проверката на 502 продължава да казва "ms":1 след почти секунда и половина повторни опити: ms мери последната заявка, а не цялата проверка. Това е времето, за което услугата е отговорила, а точно него следи revisor.
6.5 502, който не казва дали записът се е случил
6.5.1 Шлюзът, който губи отговори
Втората половина на урока е посветена на сцената от началото. За да я предизвикваш по желание, pasarela.ts застава пред revisor и със заглавието X-Simular: perder-primera пропуска първата заявка на всяка регистрация, получава отговора на revisor и го изхвърля. На клиента отговаря онова, което би отговорил истински шлюз, който не е получил пълен отговор от онова, което е зад него: 502 Bad Gateway (RFC 9110, раздел 15.6.3), като Problem Details с about:blank. Разпознава „същата заявка“ по нейния Idempotency-Key, а ако тя не носи ключ — по отпечатъка на тялото ѝ:
// fig06_01/pasarela.ts — una pasarela delante del revisor (18082 → 18080) que puede perder respuestas.
// Con la cabecera «X-Simular: perder-primera», la primera vez que ve una petición (por su
// Idempotency-Key o, si no trae, por su cuerpo) la deja pasar,
// recibe la respuesta del revisor y la tira: al cliente le contesta 502.
// Así se ve el caso que la lección 6 enseña: la escritura ocurrió, pero el cliente no lo sabe.
import { createHash } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
const PUERTO = 18082;
const DESTINO = "http://127.0.0.1:18080";
const PASAN = ["authorization", "content-type", "idempotency-key", "if-match", "if-none-match"];
const VUELVEN = ["content-type", "content-length", "content-language", "location", "etag", "retry-after", "allow", "www-authenticate"];
const vistas = new Set<string>();
async function leer(peticion: IncomingMessage): Promise<Buffer> {
const trozos: Buffer[] = [];
for await (const trozo of peticion) trozos.push(trozo as Buffer);
return Buffer.concat(trozos);
}
function malaPuerta(respuesta: ServerResponse, detail: string): void {
const problema = JSON.stringify({ type: "about:blank", title: "Bad Gateway", status: 502, detail }) + "\n";
respuesta.writeHead(502, { "Content-Type": "application/problem+json", "Content-Length": String(Buffer.byteLength(problema)) });
respuesta.end(problema);
}
// Si el revisor no responde (apagado, conexión cortada), fetch rechaza la promesa. Sin este catch
// nadie la esperaba y Node tumbaba la pasarela; ahora responde 502 y sigue escuchando.
async function pasarSinCaerse(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
try {
await pasar(peticion, respuesta);
} catch {
console.log("pasarela: el revisor no respondió");
if (!respuesta.headersSent) malaPuerta(respuesta, "la pasarela no recibió respuesta del revisor");
else respuesta.destroy();
}
}
async function pasar(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
const cabeceras: Record<string, string> = {};
for (const nombre of PASAN) {
const valor = peticion.headers[nombre];
if (typeof valor === "string") cabeceras[nombre] = valor;
}
const cuerpo = await leer(peticion);
const delRevisor = await fetch(DESTINO + (peticion.url ?? "/"), {
method: peticion.method,
headers: cabeceras,
body: cuerpo.length > 0 ? new Uint8Array(cuerpo) : undefined,
redirect: "manual",
});
const texto = await delRevisor.text();
const llave = cabeceras["idempotency-key"] ?? createHash("sha256").update(cuerpo).digest("hex");
if (peticion.headers["x-simular"] === "perder-primera" && !vistas.has(llave)) {
vistas.add(llave);
console.log(`pasarela: el revisor respondió ${delRevisor.status}, pero la respuesta se pierde`);
malaPuerta(respuesta, "la pasarela no recibió una respuesta completa del revisor");
return;
}
const vuelven: Record<string, string> = {};
for (const nombre of VUELVEN) {
const valor = delRevisor.headers.get(nombre);
if (valor !== null) vuelven[nombre] = valor;
}
respuesta.writeHead(delRevisor.status, vuelven);
respuesta.end(texto);
}
createServer((peticion, respuesta) => void pasarSinCaerse(peticion, respuesta)).listen(PUERTO, "127.0.0.1", () => {
console.log(`pasarela en http://127.0.0.1:${PUERTO} → ${DESTINO}`);
});
Обърни внимание какво записва шлюзът в терминала си: „el revisor respondió 201, pero la respuesta se pierde“ (revisor отговори 201, но отговорът се губи). Този ред е онова, което клиентът никога не вижда. От негова страна 502 може да означава, че заявката не е стигнала до revisor, или че е стигнала и нещо се е провалило, или че е стигнала, изпълнена е и отговорът се е изгубил. 502 не казва кое от трите, и не може да го каже: шлюзът също не винаги го знае. Същото важи за 504 и за прекъсната връзка.
Шлюзът има и втори 502, който не е симулиран. Ако revisor е спрян или прекъсне връзката, fetch на шлюза се проваля, а promise, който се проваля, без никой да го чака, кара Node да срине целия процес. Затова pasar е обвита в pasarSinCaerse: хваща грешката, записва „el revisor no respondió“ (revisor не отговори), отговаря 502 с друг detail (шлюзът не е получил отговор, а не „пълен отговор“) и продължава да слуша. Ще го видиш в 6.5.3. Шлюз, който пада заедно с онова, което пази, оставя всичките си клиенти дори без грешка, която да разчетат. А в отговора, който все пак пропуска, препраща между другите заглавия и content-length, за да получи клиентът същия размер, който е дал revisor (ще го видиш в 201 от 6.8.4).
6.5.2 Клиентът, който опитва отново
alta-segura.ts е скриптът от сцената, написан внимателно: регистрира услуга през шлюза и ако получи 502, 503 или 504, или ако не получи никакъв отговор, чака с jitter и опитва отново, до четири опита. Винаги изпраща X-Simular: perder-primera, за да може сцената да се възпроизвежда по желание; истински шлюз никога не се подчинява на такъв превключвател от заявката на клиент: той е само за лабораторията. С опцията --sin-llave се държи като скрипта от сцената; без нея изпраща Idempotency-Key, който измисля при стартиране:
// fig06_01/alta-segura.ts — dar de alta un servicio a través de la pasarela y reintentar sin duplicar.
// Uso: node alta-segura.ts <nombre> <url> [--sin-llave]
// Código de salida: 0 si el alta se hizo (un 2xx); 1 si no se hizo o no se sabe: se agotaron los intentos,
// el servidor pidió esperar más de lo razonable, o respondió algo que repetir no arregla (un 4xx);
// 2 si faltan argumentos.
import { randomUUID } from "node:crypto";
import { esperaConJitter, segundosDeEspera } from "./cliente.ts";
const [nombre, url, opcion] = process.argv.slice(2);
if (nombre === undefined || url === undefined) {
console.error("uso: node alta-segura.ts <nombre> <url> [--sin-llave]");
process.exit(2);
}
// Una llave por intención, no por intento: todos los reintentos de esta alta llevan la misma.
const llave = opcion === "--sin-llave" ? undefined : randomUUID();
const INTENTOS = 4;
// Cuánto espera una respuesta antes de darla por perdida. ALTA_LIMITE_MS lo cambia (sirve para probar).
const LIMITE_MS = Number(process.env.ALTA_LIMITE_MS) > 0 ? Number(process.env.ALTA_LIMITE_MS) : 2000;
// Lo más que obedece de Retry-After: el mismo tope que cliente.ts. Si el servidor pide más, no insiste.
const TOPE_RETRY_AFTER_S = 5;
const LLAVE_EN_CURSO = "https://revisor.example/problemas/llave-en-curso";
const como = llave === undefined ? "sin llave (podría duplicar)" : "con la misma llave";
type Intento = { codigo: number; cuerpo: string; reintentarEn: number | null } | { sinRespuesta: string };
// Un intento: la respuesta (código, cuerpo y Retry-After) o, si no llegó ninguna, por qué. Sin respuesta
// (conexión rechazada, cortada o tiempo agotado) la petición pudo llegar o no: es el mismo caso que el 502,
// y con la misma llave repetirla es seguro.
async function intentar(): Promise<Intento> {
const cabeceras: Record<string, string> = {
"Authorization": "Bearer escritura-a",
"Content-Type": "application/json",
"X-Simular": "perder-primera",
};
if (llave !== undefined) cabeceras["Idempotency-Key"] = llave;
try {
const respuesta = await fetch("http://127.0.0.1:18082/servicios", {
method: "POST",
headers: cabeceras,
body: JSON.stringify({ nombre, url }),
signal: AbortSignal.timeout(LIMITE_MS),
});
const reintentarEn = segundosDeEspera(respuesta.headers.get("retry-after"));
return { codigo: respuesta.status, cuerpo: (await respuesta.text()).trim(), reintentarEn };
} catch (error) {
if (error instanceof Error && error.name === "TimeoutError") {
return { sinRespuesta: `tiempo agotado: ${LIMITE_MS} ms` };
}
const causa = (error as { cause?: { code?: string } }).cause?.code;
return { sinRespuesta: causa ?? String(error) };
}
}
// ¿Puede mejorar solo? Sin respuesta, 502, 503 y 504 sí. Un 409 también, pero solo el de «llave en curso»:
// la petición original sigue guardándose y esperar basta. El 409 de «nombre repetido» no mejora esperando.
function reintentable(intento: Intento): boolean {
if ("sinRespuesta" in intento) return true;
if ([502, 503, 504].includes(intento.codigo)) return true;
if (intento.codigo !== 409) return false;
try {
return (JSON.parse(intento.cuerpo) as { type?: unknown }).type === LLAVE_EN_CURSO;
} catch {
return false;
}
}
const pendiente = llave === undefined
? "no se sabe si el alta ocurrió"
: `repetir más tarde con la llave ${llave} es seguro`;
let desenlace: "hecho" | "rechazado" | "agotado" | "sin-insistir" = "agotado";
let ultimo: Intento = { sinRespuesta: "ninguno" };
let huboDuda = false;
let pedidaLarga = 0;
for (let intento = 1; intento <= INTENTOS; intento++) {
ultimo = await intentar();
if ("sinRespuesta" in ultimo) {
console.log(`intento ${intento}: sin respuesta (${ultimo.sinRespuesta})`);
} else {
console.log(`intento ${intento}: ${ultimo.codigo} ${ultimo.cuerpo}`);
if (ultimo.codigo >= 200 && ultimo.codigo < 300) {
desenlace = "hecho";
break;
}
}
// Se reintenta solo lo que puede mejorar solo. Un 4xx no mejora repitiéndolo.
if (!reintentable(ultimo)) {
desenlace = "rechazado";
break;
}
huboDuda = true;
// Después del último intento no se espera: no queda nada que reintentar.
if (intento === INTENTOS) break;
// Si el servidor dice cuánto esperar, se obedece; si pide más del tope, no se insiste.
const pedida = "sinRespuesta" in ultimo ? null : ultimo.reintentarEn;
if (pedida !== null && pedida > TOPE_RETRY_AFTER_S) {
desenlace = "sin-insistir";
pedidaLarga = pedida;
break;
}
const espera = pedida !== null ? pedida * 1000 : esperaConJitter(intento, 250, 2000);
console.log(
pedida !== null
? ` el servidor pide esperar ${pedida} s (Retry-After); espero ${pedida} s y reintento ${como}`
: ` espero entre 0 y ${Math.min(2000, 250 * 2 ** intento)} ms y reintento ${como}`,
);
await new Promise((listo) => setTimeout(listo, espera));
}
if (desenlace === "agotado") {
console.log(`se agotaron los ${INTENTOS} intentos sin una respuesta definitiva; ${pendiente}`);
} else if (desenlace === "sin-insistir") {
console.log(`el servidor pidió esperar ${pedidaLarga} s (Retry-After), más de los ${TOPE_RETRY_AFTER_S} s que este programa espera; no se reintenta; ${pendiente}`);
} else if (desenlace === "rechazado" && !("sinRespuesta" in ultimo)) {
const duda = llave === undefined && huboDuda
? "; sin llave no se sabe si lo que ya existe lo creó un intento anterior"
: "";
console.log(`el servidor respondió ${ultimo.codigo}: repetir lo mismo no lo arregla${duda}`);
}
if (desenlace !== "hecho") process.exitCode = 1;
Редът, който има значение, е този с коментара: един ключ за всяко намерение, а не за всеки опит. Ключът се създава веднъж, преди цикъла, и всички повторни опити на тази регистрация го носят. Ако се създаваше в цикъла, всеки повторен опит щеше да е нова заявка за revisor, а точно това е дефектът от „Грешката, която ще видиш“.
Останалата част от програмата следва реда на двата въпроса от 6.3.1 и на всяка стъпка взема решение, което се вижда при изпълнение (6.5.3 и 6.5.4):
intentarправи една заявка и връща отговора (код, тяло иRetry-After) или, ако не е пристигнал никакъв, причината: кода на мрежовата грешка, който отчита Node, напримерECONNREFUSED(никой не слуша на този порт), илиtiempo agotado(изтекло време). Заявката носиsignal: AbortSignal.timeout(LIMITE_MS): ако сървърът приеме връзката и не отговори, опитът се прекъсва след 2000 ms (променливатаALTA_LIMITE_MSго променя, за проби). Без тази граница Node чака 300 s за всеки опит. Заявка без отговор е за онзи, който я е изпратил, същият случай като502: може да е стигнала, а може и да не е. Със същия ключ повторението ѝ не създава друга услуга, стигаrevisorвсе още да помни този ключ (6.7).reintentableотговаря на първия въпрос: може ли да се оправи само? Без отговор,502,503и504— да.409също, но само този заllave-en-curso, който се разпознава по свояtype, а не по кода си: оригиналната заявка още се записва и е достатъчно да се изчака.409заnombre-repetido,422и всеки друг4xxне се оправят с повторение.- Изчакванията. Ако сървърът е казал колко да се чака (
Retry-After, разчетено съсsegundosDeEsperaотcliente.ts— същата функция и същата горна граница от 5 s, които използват проверките), се изпълнява; ако иска повече, не се настоява и се казва защо. Ако не е казал нищо, се избира случайно с jitter. Редътif (intento === INTENTOS) break;стои преди изчакването, за да не се приспива никой след последния опит. - Краят. Когато цикълът свърши, програмата казва защо (един ред) и излиза със съответния код:
0само ако регистрацията е направена (2xx);1, ако не е направена или не се знае, било защото опитите са се изчерпали, защото сървърът е поискал да се чака твърде дълго, или защото е отговорил нещо, което повторението не оправя. Скрипт, който извикваalta-segura.tsза четиринайсет услуги, разчита този код и знае кои трябва да провери.
Започни с версията без ключ. Това изпълнение е част от серията в 6.6.4, която преди това е регистрирала avisos (7), correo (8) и agenda (9); затова идентификаторът, който ще видиш, е 10:
$ node alta-segura.ts nomina2 http://127.0.0.1:18081/codigo/200 --sin-llave; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento sin llave (podría duplicar)
intento 2: 409 {"type":"https://revisor.example/problemas/nombre-repetido","title":"Ya existe un servicio con ese nombre","status":409,"detail":"ya existe un servicio llamado nomina2","instance":"/servicios","existente":"/servicios/10"}
el servidor respondió 409: repetir lo mismo no lo arregla; sin llave no se sabe si lo que ya existe lo creó un intento anterior
(código de salida: 1)
Първият опит получи 502. Клиентът изчака и опита отново, а вторият опит получи 409, „ya existe un servicio llamado nomina2“ (вече съществува услуга с име nomina2), с "existente":"/servicios/10". Услуга 10 е създадена от първия опит: записът се е случил, клиентът така и не е разбрал, а сега държи в ръка 409, който не му казва дали тази услуга е негова. Това е сцената, пълна. Без правилото за уникално име вторият опит щеше да създаде услуга 11, еднаква с нея, и никой нямаше да получи никаква грешка. Последните два реда са на програмата: не повтаря 409 (не се оправя с изчакване), казва, че без ключ не знае дали онова, което вече съществува, е създадено от предишен опит, и излиза с код 1.
Какво прави клиентът с това, без ключ? Съгласува: прочита отново състоянието и решава. 409 носи existente; клиентът може да поиска /servicios/10 и да сравни с онова, което е искал да създаде. Ако съвпада, приема, че е бил той. Работи, наполовина: ако колега е създал същата услуга секунда по-рано, клиентът я присвоява, без да знае. Урок 7 се връща към това действие. Следващият раздел кара сървъра да отговори на въпроса вместо теб.
6.5.3 Когато никой не отговаря
Дотук всяка повреда носеше отговор, макар и 502. Остават два по-грозни случая: шлюзът със спрян revisor и клиентът без шлюз. И двата са разновидност на „не знам дали е минало“, а alta-segura.ts и pasarela.ts ги имат решени. С трите програми, току-що стартирани, спри revisor с Ctrl+C в неговия терминал (при изпълнението за урока беше спрян по PID, затова командата не се вижда) и поискай отново регистрация, този път директно към шлюза:
$ curl -i -X POST http://127.0.0.1:18082/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"sin-revisor","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 502 Bad Gateway
Content-Type: application/problem+json
Content-Length: 115
Date: Wed, 21 Oct 2026 22:21:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
revisor вече не съществува. Шлюзът отговори 502 с Problem Details и в своя detail казва какво му се е случило: „no recibió respuesta del revisor“ (не получи отговор от revisor). Сравни със симулирания detail от 6.5.1, „no recibió una respuesta completa“ (не получи пълен отговор): онова беше revisor, който е отговорил и чийто отговор е бил изхвърлен; това е revisor, който не е отговорил. За клиента това е същият 502 и пак не казва дали регистрацията се е случила. Важното е, че шлюзът остава жив, за да отговори: следващата заявка я обслужва по същия начин.
Сега клиентът. Със все още спрения revisor alta-segura.ts получава 502 и в четирите опита:
$ node alta-segura.ts sin-revisor http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave 9f1d5f3d-0c37-4587-b4fe-5a8056f35347 es seguro
(código de salida: 1)
Прочети го ред по ред. Първите четири реда, които започват с intento, са четирите отговора, всички 502. Между тях — три изчаквания: до 500, до 1000 и до 2000 ms, които са таваните на отлагането за опити 1, 2 и 3 (250 × 2^n, с горна граница 2000). Четвърто изчакване няма, защото след опит 4 не остава нищо за повтаряне. Следващият ред казва защо е свършило: „se agotaron los 4 intentos sin una respuesta definitiva“ (четирите опита се изчерпаха без окончателен отговор). А понеже регистрацията носеше ключ, добавя честния изход: „repetir más tarde con la llave … es seguro“ (по-късно повторението с ключа … е безопасно). Който прочете този ред (човек или скрипт), има ключа, за да опита отново, когато revisor се върне. Но не е достатъчно да изпълниш alta-segura.ts отново, защото ще измисли друг ключ: трябва да се повтори същата заявка с този ключ и със същите байтове (6.7.2), например с curl … -H 'Idempotency-Key: <la llave del mensaje>' -d '{"nombre":"sin-revisor","url":"http://127.0.0.1:18081/codigo/200"}'. А повторният опит е защитен само ако сървърът все още помни ключа: в срока му на валидност (6.7.1) и ако не го е загубил при рестартиране (6.7.4). Накрая (código de salida: 1): програмата не е завършила успешно. 0 е запазено за регистрация, която е направена, така че скрипт, който извиква alta-segura.ts за списък от четиринайсет услуги, може да погледне този код и да знае кои трябва да провери. В 6.5.4 ще видиш, че 1 покрива и 4xx.
Най-лошият случай на изчакване е сборът на таваните: 500 + 1000 + 2000 = 3500 ms, плюс времето на заявките. Тук всяка заявка има граница от 2000 ms, така че пълният най-лош случай е 4 × 2000 + 3500 = 11 500 ms; без тази граница нямаше да има най-лош случай, защото сървър, който не отговаря, би оставил програмата да чака по 300 s за всеки опит. При същата повреда програма, която освен това чака и след четвъртия опит, щеше да добави още 2000 ms, за да не направи нищо.
Вариантът без ключ свършва в същата точка, но с друго съобщение:
$ node alta-segura.ts sin-revisor http://127.0.0.1:18081/codigo/200 --sin-llave; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 500 ms y reintento sin llave (podría duplicar)
intento 2: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 1000 ms y reintento sin llave (podría duplicar)
intento 3: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 2000 ms y reintento sin llave (podría duplicar)
intento 4: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
se agotaron los 4 intentos sin una respuesta definitiva; no se sabe si el alta ocurrió
(código de salida: 1)
Редовете с изчакване казват „sin llave (podría duplicar)“ (без ключ (може да създаде дубликат)) при всеки опит, а краят вече не обещава нищо: „no se sabe si el alta ocurrió“ (не се знае дали регистрацията се е случила). Това е същото, което се случи със скрипта от сцената, казано навреме. Без ключ програмата няма как да предложи безопасен повторен опит и не си измисля такъв.
Последният случай е този с клиента без шлюз. Спри и шлюза с Ctrl+C в неговия терминал (в тази серия, също като revisor, той беше спрян по PID, затова изпълнението не го показва) и регистрирай още една услуга:
$ node alta-segura.ts sin-pasarela http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: sin respuesta (ECONNREFUSED)
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: sin respuesta (ECONNREFUSED)
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: sin respuesta (ECONNREFUSED)
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: sin respuesta (ECONNREFUSED)
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave d58f84df-5556-41e5-a63c-5c217e14fb05 es seguro
(código de salida: 1)
Сега опитите не казват 502: казват „sin respuesta (ECONNREFUSED)“ (без отговор). Никой не слуша на порт 18082, така че операционната система отхвърля връзката, преди да е пътувал и един байт от заявката. Това е единственият случай в урока, в който би знаел със сигурност, че заявката не е стигнала, но alta-segura.ts не разчита на това: третира всяка мрежова грешка като 502 (може да е стигнала, а може и да не е, а със същия ключ повторението е безопасно) и опитва отново със същите тавани и същия край. Обърни внимание какво не се появява: стекът от грешки на Node (node:internal/modules/run_main…), с който завършва програма, която не е хванала своя fetch. Програмата хваща грешката, превежда я в ред, който човек разбира, и връща код 1.
А терминалът на шлюза разказва какво е видял:
--- terminal de la pasarela:
pasarela en http://127.0.0.1:18082 → http://127.0.0.1:18080
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
Девет реда „el revisor no respondió“: един за заявката на curl, четири за опитите на sin-revisor с ключ и четири за тези на варианта без ключ. Четирите опита на sin-pasarela не се появяват, защото никога не са стигнали до шлюза. Три различни програми, три начина да се провалиш, и нито една не падна, нито остави клиента без ред, който да каже.
6.5.4 Какво прави с онова, което получава
Дотук alta-segura.ts решаваше по кода: 502, 503, 504 или липса на отговор — повтаря се. Но сървърът казва повече от един код и програмата го слуша на пет места. За да ги видиш, е нужен revisor, който отговаря неща, които истинският обикновено не отговаря, затова този раздел използва два инструмента: режима на поддръжка на revisor (PUT /mantenimiento, който позна в Урок 4) и симулиран сървър, fig06_04.ts, който слуша на порт 18082, където би бил шлюзът, и отговаря онова, което му кажеш.
4xx не се повтаря и излиза с 1. Адрес, който не е http или https, е 422; повторението на същата заявка би дало същия 422. С току-що стартирани шлюз и revisor:
$ node alta-segura.ts invalida ftp://archivos; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 422 {"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"1 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser una dirección http o https","pointer":"#/url"}]}
el servidor respondió 422: repetir lo mismo no lo arregla
(código de salida: 1)
Първият опит получи 502, защото шлюзът изгуби отговора (6.5.1); вторият, със същия ключ, стигна цял: 422 datos-invalidos. Програмата не чака и не опитва трети път. Казва на един ред какво е станало, „el servidor respondió 422: repetir lo mismo no lo arregla“ (сървърът отговори 422: повторението на същото не го оправя), и излиза с 1. 0 тук би казал на скрипта, че регистрацията е направена.
Retry-After командва, със същата горна граница като проверките. Когато revisor е в режим на поддръжка, всеки запис получава 503 с Retry-After: 120:
$ curl -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":true}'
204
$ node alta-segura.ts enmantenimiento http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 503 {"type":"https://revisor.example/problemas/en-mantenimiento","title":"El revisor está en mantenimiento","status":503,"detail":"el revisor está en mantenimiento","instance":"/servicios"}
el servidor pidió esperar 120 s (Retry-After), más de los 5 s que este programa espera; no se reintenta; repetir más tarde con la llave bf9d35dc-9d6d-4ca8-9a92-da8dcd058cd3 es seguro
(código de salida: 1)
$ curl -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":false}'
204
Чети го така: първият PUT включва режима на поддръжка (204), а последният го изключва. Между тях alta-segura.ts получава симулирания 502 и после 503 от режима на поддръжка. Този път не избира изчакване случайно: чете Retry-After: 120, вижда, че надхвърля горната граница от 5 s, и спира със съобщението „el servidor pidió esperar 120 s (Retry-After), más de los 5 s que este programa espera; no se reintenta“ (сървърът поиска да се чака 120 s, повече от 5-те s, които тази програма чака; не се опитва отново), плюс ключа, в случай че някой иска да повтори по-късно. Това е същото правило като в 6.4.3, със същата функция (segundosDeEspera) и същата горна граница.
За да видиш другата страна, е нужен сървър, който иска кратко изчакване. Тук влиза симулираният сървър:
// fig06_04.ts — un servidor de mentira en el 18082 (donde estaría la pasarela) que contesta lo que le digas.
// Uso: node fig06_04.ts <respuesta>... (una respuesta por petición; la última se repite)
// 201 responde 201 con un cuerpo de juguete
// 503:2 responde 503 con «Retry-After: 2» (el número después de los dos puntos son los segundos)
// 409:en-curso responde el 409 de «llave en curso»; 409 a secas es otro 409
// mudo acepta la conexión y no contesta nunca
import { createServer } from "node:http";
const pedidas = process.argv.slice(2);
if (pedidas.length === 0) {
console.error("uso: node fig06_04.ts <respuesta>... (por ejemplo: 503:2 201)");
process.exit(2);
}
let peticiones = 0;
createServer((peticion, respuesta) => {
peticion.resume();
const pedida = pedidas[Math.min(peticiones, pedidas.length - 1)];
peticiones++;
console.log(`petición ${peticiones}: ${pedida}`);
if (pedida === "mudo") return;
const [codigo, extra] = pedida.split(":");
const estado = Number(codigo);
const bien = estado < 300;
const cabeceras: Record<string, string> = { "Content-Type": bien ? "application/json" : "application/problem+json" };
if (!bien && extra !== undefined && extra !== "en-curso") cabeceras["Retry-After"] = extra;
const tipo = estado === 409 && extra === "en-curso" ? "https://revisor.example/problemas/llave-en-curso" : "about:blank";
const cuerpo = bien ? { id: 99 } : { type: tipo, title: "respuesta de mentira", status: estado };
respuesta.writeHead(estado, cabeceras);
respuesta.end(JSON.stringify(cuerpo) + "\n");
}).listen(18082, "127.0.0.1", () => console.log("servidor de mentira en http://127.0.0.1:18082"));
По един отговор за всяка заявка, а последният се повтаря. 503:2 е 503 с Retry-After: 2; 409:en-curso е 409 за ключа, чиято обработка тече (със своя type, който е онова, което гледа alta-segura.ts); mudo приема връзката и не отговаря. Спри шлюза с Ctrl+C (при изпълнението — по PID) и стартирай на негово място симулирания сървър в друг терминал. Първо 503, който иска две секунди, и после 201:
$ node fig06_04.ts 503:2 201
$ node alta-segura.ts paciente http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 503 {"type":"about:blank","title":"respuesta de mentira","status":503}
el servidor pide esperar 2 s (Retry-After); espero 2 s y reintento con la misma llave
intento 2: 201 {"id":99}
(código de salida: 0)
--- terminal del servidor de mentira:
servidor de mentira en http://127.0.0.1:18082
petición 1: 503:2
petición 2: 201
Редът с изчакването промени формата си: „el servidor pide esperar 2 s (Retry-After); espero 2 s y reintento con la misma llave“ (сървърът иска да се чака 2 s; чакам 2 s и опитвам отново със същия ключ). Това вече не е случаен избор между 0 и таван, а е онова, което е поискал сървърът. Вторият опит получава 201 и програмата излиза с 0. Терминалът на симулирания сървър потвърждава, че е получил точно две заявки.
409 за „ключ, чиято обработка тече“ не е окончателен. Това е 409, който видя в 6.6.4: оригиналната заявка още се записва. Клиент, който получи този 409, няма какво да поправя; чака и повтаря. alta-segura.ts го разпознава по type на Problem Details (не по заглавието, нито по детайла) и го третира като всяка временна повреда:
$ node fig06_04.ts 409:en-curso 201
$ node alta-segura.ts en-curso http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 409 {"type":"https://revisor.example/problemas/llave-en-curso","title":"respuesta de mentira","status":409}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 201 {"id":99}
(código de salida: 0)
--- terminal del servidor de mentira:
servidor de mentira en http://127.0.0.1:18082
petición 1: 409:en-curso
petición 2: 201
Същият 409 с друг type (онзи, който revisor използва за повторено име) не се повтаря: повторението не би променило името.
$ node fig06_04.ts 409
$ node alta-segura.ts repetida http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 409 {"type":"about:blank","title":"respuesta de mentira","status":409}
el servidor respondió 409: repetir lo mismo no lo arregla
(código de salida: 1)
--- terminal del servidor de mentira:
servidor de mentira en http://127.0.0.1:18082
petición 1: 409
Една-единствена заявка в терминала на симулирания сървър, изход 1. Два 409, две решения, а онова, което ги разделя, е type. Това е идеята, която Урок 7 превръща в таблица.
Кога се вижда 409 за „обработка, която тече“ с истинския revisor? С границата от 2000 ms по подразбиране — почти никога: на revisor му трябват 500 ms, за да запише, така че когато един опит се прекъсне по време, оригиналната заявка вече е приключила и повторният опит получава записания 201. Появява се само ако границата е по-малка от времето за запис. Беше измерено, като границата се свали до 150 ms с ALTA_LIMITE_MS=150 и регистрацията се повтаряше, докато повторният опит пристигне, преди оригиналната да е приключила, и се налага да се повтори няколко пъти, защото зависи от това кога се пада случайно избраното изчакване. Затова се проверява със симулирания сървър, който отговаря онова, което му се каже.
Сървър, който не отговаря, се прекъсва по време. Всеки опит носи граница (2000 ms по подразбиране). С ALTA_LIMITE_MS=300, за да не се чака толкова при изпълнението:
$ node fig06_04.ts mudo
$ ALTA_LIMITE_MS=300 node alta-segura.ts muda http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: sin respuesta (tiempo agotado: 300 ms)
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: sin respuesta (tiempo agotado: 300 ms)
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: sin respuesta (tiempo agotado: 300 ms)
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: sin respuesta (tiempo agotado: 300 ms)
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave f4e16950-99e9-4504-86b8-c95e2c52cc04 es seguro
(código de salida: 1)
--- terminal del servidor de mentira:
servidor de mentira en http://127.0.0.1:18082
petición 1: mudo
petición 2: mudo
petición 3: mudo
petición 4: mudo
Всеки опит се прекъсва след 300 ms и програмата го отчита като „sin respuesta (tiempo agotado: 300 ms)“ (без отговор (изтекло време: 300 ms)): това е същият случай като 502 или отхвърлената връзка и се повтаря по същия начин. Четири опита, четири заявки в терминала на сървъра и обичайният честен изход. Без границата първият опит би се отказал след около 300 s.
6.6 Idempotency-Key: проект, а не стандарт
6.6.1 Какво е и в какво състояние се намира
Идеята е стара и я използват много големи API: клиентът измисля уникален идентификатор за всяка операция, която иска да направи само веднъж, изпраща го в заглавие, а сървърът помни какво е отговорил на този идентификатор. Ако той пристигне отново, вместо да обработи заявката наново, сървърът повтаря отговора, който вече е дал. Документацията на Stripe го обяснява така: ако възникне грешка във връзката, можеш да повториш заявката без риск да създадеш втори обект или да приложиш промяната два пъти.
Работната група httpapi на IETF се опита да стандартизира това заглавие като Idempotency-Key. Последната версия е draft-ietf-httpapi-idempotency-key-header-07 от 15 октомври 2025 г., на Jayadeba Jena и Sanjay Dalal, и срокът ѝ изтече на 18 април 2026 г., без да бъде публикувана като RFC. Страницата на документа в Datatracker на IETF го отбелязва като „Expired Internet-Draft“: предвиденият му статус беше Standards Track, но от него не излезе никакъв RFC. Казано направо: Idempotency-Key не е стандарт. Това е проект с изтекъл срок, който описва модел, който много хора вече използваха. Самият проект изброява в раздела си за реализации Stripe, Adyen и Yandex със заглавие с такова име и други, които правят същото с друго име (PayPal използва PayPal-Request-Id).
Защо тогава да го използваш? Защото моделът е добър, защото името е онова, което повечето разпознават, и защото проектът описва внимателно трудните случаи. Но твоят API трябва да го документира като собствено поведение, а не като нещо, което HTTP гарантира. Самият проект го изисква: ресурсите, които го използват, трябва да публикуват своята спецификация за идемпотентност (раздел 2.5.2), а общ клиент не може да предполага, че сървърът я спазва (раздел 2.5.1). Заглавие, което сървърът не познава, се пренебрегва безмълвно: ако изпратиш Idempotency-Key към API, който не го реализира, повторният ти опит пак създава дубликат.
6.6.2 Какво предлага проектът
Същността на проекта, раздел по раздел, и какво прави revisor с всяко нещо:
- Синтаксис (2.1). Стойността е String от структурираните полета на HTTP, т.е. е в кавички:
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324".revisorопростява: взема стойността такава, каквато пристига, без да маха кавички. За негоalta-avisos-1и"alta-avisos-1"са два различни ключа (ще го видиш в 6.7.4). Заявките в този урок изпращат ключа без кавички, както правят много API, възникнали преди проекта; ако реализираш модела от нулата, приемай формата от проекта. - Уникалност (2.2). Ключът трябва да е уникален и не бива да се използва повторно за друга заявка с различно тяло. Проектът препоръчва UUID или друг случаен идентификатор.
alta-segura.tsизползваrandomUUID(); заявките сcurlв урока използват четливи имена катоalta-avisos-1, за да можеш да четеш изпълненията, а в истински клиент не би го направил (раздел 6.7.5). - Валидност (2.3). Ресурсът може да направи така, че ключовете да изтичат, и трябва да публикува тази политика. Това е раздел 6.7.1.
- Отпечатък (2.4). Заедно с ключа сървърът може да изчисли отпечатък на тялото (контролна сума на цялото тяло, на някои полета или сравнение поле по поле), за да знае дали повторената заявка наистина е същата.
revisorизползва SHA-256 върху вече прочетения JSON. - Какво да се прави (2.6). Ако ключът е нов, заявката се обслужва нормално. Ако вече е обслужена и заявката е същата, се отговаря онова, което е отговорено първия път. Ако пристигне, докато оригиналната още се обработва, се отговаря с конфликт.
- Грешките (2.7).
422 Unprocessable Content, ако ключът се използва повторно с друго тяло;409 Conflict, ако пристигне, докато оригиналната още се обработва;400, ако една операция изисква ключа, а той липсва. Примерите в проекта отговарят с Problem Details. Проектът добавя правило за клиента: преди да опита отново след една от тези грешки, клиентът трябва да поправи заявката, с изключение на409, който не изисква поправка, а търпение.
В revisor ключът е незадължителен, затова няма 400. Другите два случая са двата нови типа в каталога: llave-reutilizada (422) и llave-en-curso (409). 409 за ключа е друг 409, различен от този за nombre-repetido, и Урок 3 вече го беше предвидил: и двата са конфликти с текущото състояние, но този за ключа се решава с изчакване, а този за името — с повторно прочитане. Затова имат различен type и затова Урок 7 решава по type, а не по кода.
6.6.3 idempotencia.ts
// fig06_01/idempotencia.ts — Idempotency-Key: repetir un POST sin duplicar lo que creó.
// OJO: Idempotency-Key NO es un estándar. Es un borrador de la IETF
// (draft-ietf-httpapi-idempotency-key-header-07, que venció el 18 de abril de 2026 sin publicarse
// como RFC). El revisor lo usa como un patrón que su API documenta, con los códigos que propone
// el borrador: 422 si la llave se reutiliza con otra petición, 409 si la original sigue en curso.
import { createHash } from "node:crypto";
export type Grabada = { codigo: number; cabeceras: Record<string, string>; cuerpo: unknown };
type Registro = { huella: string; grabada: Grabada | null };
// Por credencial y llave. Una API real las guarda en su base y las borra tras un plazo (p. ej. 24 h).
const registros = new Map<string, Registro>();
export function huella(datos: unknown): string {
return createHash("sha256").update(JSON.stringify(datos)).digest("hex");
}
export type Turno =
| { tipo: "nueva" }
| { tipo: "repetir"; grabada: Grabada }
| { tipo: "otra-peticion" }
| { tipo: "en-curso" };
// Decide qué hacer con una llave. Si es nueva, la aparta (en curso) hasta que se grabe la respuesta.
export function tomarTurno(credencial: string, llave: string, huellaPeticion: string): Turno {
const id = `${credencial}:${llave}`;
const registro = registros.get(id);
if (registro === undefined) {
registros.set(id, { huella: huellaPeticion, grabada: null });
return { tipo: "nueva" };
}
if (registro.huella !== huellaPeticion) return { tipo: "otra-peticion" };
if (registro.grabada === null) return { tipo: "en-curso" };
return { tipo: "repetir", grabada: registro.grabada };
}
export function grabar(credencial: string, llave: string, grabada: Grabada): void {
const registro = registros.get(`${credencial}:${llave}`);
if (registro !== undefined) registro.grabada = grabada;
}
// Si la operación falla sin crear nada, la llave se libera para que el reintento pueda intentarlo.
export function soltar(credencial: string, llave: string): void {
registros.delete(`${credencial}:${llave}`);
}
Четири функции и един регистър. huella обобщава тялото; tomarTurno е онази, която решава: търси ключа в рамките на удостоверението (${credencial}:${llave}) и ако той не съществува, го резервира с отпечатъка му и без записан отговор (grabada: null), което означава „обработката тече“. Ако съществува с друг отпечатък, това е otra-peticion; ако съществува без записан отговор — en-curso; ако съществува с отговор — repetir. grabar записва отговора, когато регистрацията завърши успешно. soltar изтрива записа, когато регистрацията се провали, без да създаде нищо, за да може поправен повторен опит да мине.
А регистрацията в servicios.ts я използва така (фрагмент; пълният файл е в 6.8.1):
const cabecera = peticion.headers["idempotency-key"];
const llave = typeof cabecera === "string" && cabecera !== "" ? cabecera : undefined;
if (llave !== undefined) {
const turno = tomarTurno(credencial.nombre, llave, huella(datos));
if (turno.tipo === "repetir") {
console.log(`idempotencia: la llave ${llave} ya se atendió; repito su respuesta`);
enviarJson(respuesta, turno.grabada.codigo, turno.grabada.cuerpo, turno.grabada.cabeceras);
return;
}
if (turno.tipo === "otra-peticion") {
fallar(respuesta, "llave-reutilizada", `la llave ${llave} ya se usó con un cuerpo distinto`);
return;
}
if (turno.tipo === "en-curso") {
fallar(respuesta, "llave-en-curso", `la petición original con la llave ${llave} todavía no termina`);
return;
}
}
Къде е този блок, е толкова важно, колкото и какво прави. Стои след leerJson, така че тяло, което не е JSON, което надхвърля 16 KiB или което не е обект, се отхвърля, преди да се погледне ключът, както в Урок 5: не е възможен отпечатък на нещо, което не е могло да се прочете. И стои преди validar, проверката на дестинацията и търсенето на името, защото ключът трябва да бъде резервиран, преди да започне работата, която би могла да се удвои. Ако някое от тези три неща се провали, регистрацията извиква soltar. Ако всичко мине добре, регистрацията сглобява отговора, записва го и го изпраща.
За да може да се види 409 за „обработка, която тече“, регистрацията чака симулирани 500 ms преди записа (GUARDAR_MS). В истински API това изчакване е базата данни, друг API или опашка: времето, през което ключът е резервиран, а отговорът още не съществува.
6.6.4 Един и същ ключ, три изхода
С трите програми, току-що стартирани, изпрати една и съща регистрация два пъти със същия ключ:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-0cabc364"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 22:26:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-0cabc364"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 22:26:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
Два еднакви отговора, байт по байт: същият 201, същият Location: /servicios/7, същият ETag. Вторият POST не създаде нищо: терминалът на revisor го казва, idempotencia: la llave alta-avisos-1 ya se atendió; repito su respuesta (ключът alta-avisos-1 вече е обслужен; повтарям отговора му). Клиентът, който опитва отново, получава онова, което би получил първия път, и не трябва да тълкува никакъв 409.
Сега същият ключ с друго тяло, друго име:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 228
Date: Wed, 21 Oct 2026 22:26:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/llave-reutilizada","title":"Esa Idempotency-Key ya se usó con otra petición","status":422,"detail":"la llave alta-avisos-1 ya se usó con un cuerpo distinto","instance":"/servicios"}
422, llave-reutilizada. revisor не повтаря отговора за avisos (това би накарало клиента да повярва, че correo е създадена), нито създава correo (ключът вече има собственик). Отговаря, че клиентът е използвал ключа неправилно, а проектът изисква от клиента да поправи заявката, преди да опита отново: нов ключ за нова операция.
И две заявки със същия ключ почти едновременно. Първата се изпраща във фонов режим, а втората 100 ms по-късно, докато първата още е в 500-те ms на записа:
$ ( curl -o /dev/null -w 'primera: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-correo-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}' & sleep 0.1; curl -w '\nsegunda: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-correo-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}'; wait )
{"type":"https://revisor.example/problemas/llave-en-curso","title":"Esa Idempotency-Key sigue en curso","status":409,"detail":"la petición original con la llave alta-correo-1 todavía no termina","instance":"/servicios"}
segunda: 409
primera: 201
Втората получи 409 llave-en-curso и завърши първа; първата завърши след нея с 201. Ако revisor не резервираше ключа в началото, втората нямаше да получи llave-en-curso: щеше да изчака своите 500 ms, да намери correo, която първата току-що е създала, и да отговори 409 nombre-repetido. (Беше измерено: две регистрации без ключ със същото име, втората 100 ms по-късно, дадоха 201 и 409 и остана една-единствена correo; в revisor търсенето на името и създаването на услугата стават заедно, без никакъв await между тях.) Това е съмнението от сцената: 409, който не казва дали услугата е твоя. А при POST без уникално поле, като искането на проверка, и двете заявки щяха да създадат нещо. Резервирането на ключа без никакъв await между проверката и записа (tomarTurno) превръща това съмнение в ясен отговор: „оригиналната ти заявка още се обработва“. Клиент, който получи този 409, няма какво да поправя: чака малко и повтаря и ще получи записания 201. alta-segura.ts прави точно това (6.5.4).
6.6.5 Клиентът, който опитва отново със същия ключ
Сега вече — сцената с ключ. В същата серия:
$ node alta-segura.ts agenda http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 201 {"id":9,"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
(código de salida: 0)
Първият опит получи 502: revisor създаде agenda (идентификатор 9), а шлюзът изхвърли отговора. Вторият опит носеше същия ключ, revisor го разпозна и повтори своя 201 с тялото на agenda. Този път шлюзът го пропусна, защото вече беше виждал този ключ. Клиентът получи отговора, който се беше изгубил, и не трябваше да гадае нищо.
Списъкът на екипа и двата терминала потвърждават, че нищо не е удвоено:
$ curl http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/lento/1500","limite_ms":2000},{"id":5,"nombre":"reportes","url":"http://127.0.0.1:18081/lento/3000","limite_ms":1000},{"id":6,"nombre":"facturacion","url":"http://127.0.0.1:18081/basura","limite_ms":2000},{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":8,"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":9,"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":10,"nombre":"nomina2","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}]}
--- terminal del revisor:
revisor en http://127.0.0.1:18080
POST /servicios
POST /servicios
idempotencia: la llave alta-avisos-1 ya se atendió; repito su respuesta
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
idempotencia: la llave bf0d47f7-f895-4945-8e27-2215869c5fc8 ya se atendió; repito su respuesta
POST /servicios
POST /servicios
GET /servicios
--- terminal de la pasarela:
pasarela en http://127.0.0.1:18082 → http://127.0.0.1:18080
pasarela: el revisor respondió 201, pero la respuesta se pierde
pasarela: el revisor respondió 201, pero la respuesta se pierde
По една услуга за всяко име: avisos (7), correo (8), agenda (9) и nomina2 (10, тази от раздел 6.5.2, изпълнена в същата серия). В терминала на revisor има два реда за идемпотентност: този за alta-avisos-1 и този за ключа, който измисли alta-segura.ts. В терминала на шлюза — два изгубени отговора, този за agenda и този за nomina2, и двата от 201. Записът се е случил и в двата случая; разликата е, че с ключ клиентът разбра.
6.7 Границите на ключа
Дотук ключът изглежда магически. Не е, а да обещаваш „повтаряй, без да удвояваш нищо“, без да кажеш къде свършва обещанието, е начин да лъжеш. Ето неговите граници, всяка с изпълнение. Всички са от нова серия, с трите програми, току-що стартирани.
6.7.1 Колко трае
В revisor един ключ трае колкото трае процесът: регистърът живее в Map в паметта, без изтичане, и се губи при рестартиране. Това е минимумът за обучение и има два проблема в истински API: паметта расте с всяка регистрация, а рестартирането забравя всички ключове (в края на този раздел ще го видиш).
Проектът оставя продължителността на всеки API, с едно условие: да я публикува (раздел 2.3). Stripe например документира, че може да изтрива ключовете, когато са поне на 24 часа, и че ако ключ бъде използван повторно, след като е изтрит, заявката се третира като нова. Това е границата, която трябва да разбереш: след като ключът изтече, повторният опит вече не е защитен. Ако клиентът ти опитва отново в продължение на часове (опашка, която засяда, задача, която се възобновява на следващия ден), той трябва да опитва в срока на валидност или да приеме, че може да създаде дубликат. Упражнение 3 те кара да добавиш срок на валидност към idempotencia.ts и да видиш какво става, когато той изтече.
6.7.2 Какво става, ако тялото се промени
„Същото тяло“ зависи от това как се изчислява отпечатъкът. revisor го изчислява върху вече прочетения JSON, с JSON.stringify(datos). Това има последствия, които е добре да видиш:
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/7
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{ "nombre" : "avisos" , "url" : "http://127.0.0.1:18081/codigo/200" }'
201 /servicios/7
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"url":"http://127.0.0.1:18081/codigo/200","nombre":"avisos"}'
{"type":"https://revisor.example/problemas/llave-reutilizada","title":"Esa Idempotency-Key ya se usó con otra petición","status":422,"detail":"la llave alta-avisos-1 ya se usó con un cuerpo distinto","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}'
{"type":"https://revisor.example/problemas/llave-reutilizada","title":"Esa Idempotency-Key ya se usó con otra petición","status":422,"detail":"la llave alta-avisos-1 ya se usó con un cuerpo distinto","instance":"/servicios"}
[422]
Първият POST създава avisos. Вторият носи същите данни с интервали навсякъде и revisor повтаря 201: интервалите се губят при прочитането на JSON, така че отпечатъкът е същият. Третият носи същите две полета в друг ред и revisor отговаря 422: JSON.stringify спазва реда, в който полетата са пристигнали, така че за отпечатъка това е друго тяло. А четвъртият добавя "limite_ms":2000, което е точно стойността по подразбиране: услугата, която би се получила, е еднаква, и въпреки това е 422.
Грешно ли е? Това е решение, и то консервативното. Проектът приема няколко вида отпечатък (2.4) и всички имат една и съща дилема: строг отпечатък отхвърля заявки, които „означават“ същото, а свободен отпечатък приема заявки, които не са същите. revisor предпочита да отхвърли: 422 казва на клиента, че нещо се е променило, и клиентът може да го поправи; да приемеш различно тяло, сякаш е същото, крие грешката (това е вторият дефект от „Грешката, която ще видиш“). Следствието за клиента е просто: повторният опит изпраща същите байтове като оригинала, а не „еквивалентно тяло“. alta-segura.ts го спазва, защото сглобява тялото веднъж за всеки опит със същите данни и в същия ред.
6.7.3 Какво става, ако регистрацията се провали
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-agenda-1' -d '{"nombre":"agenda","url":"ftp://agenda"}'
{"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"1 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser una dirección http o https","pointer":"#/url"}]}
[422]
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-agenda-1' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/8
Първият POST с alta-agenda-1 носи адрес ftp и получава 422 datos-invalidos. Вторият, със същия ключ и поправения адрес, създава услугата. Ако revisor беше записал 422, поправеният повторен опит щеше да получи отново 422 (или 422 за повторно използван ключ, защото тялото се е променило): клиентът нямаше да може да поправи грешката си, без да измисли друг ключ.
Тук revisor нарочно се отклонява от буквата на проекта. Раздел 2.6 казва, че ако заявката се повтори, когато оригиналната вече е приключила, се отговаря с резултата от предишната операция, „success or an error“. revisor записва само успехите и освобождава ключа, когато регистрацията се провали, без да създаде нищо: невъзможни данни, забранена дестинация или повторено име. Причината е, че тези грешки нямат ефект, който да се пази; няма какво да се удвои. Stripe отива по-далеч от revisor в другата посока: в своя API v1 записва кода и тялото на първата заявка веднага щом крайната точка (endpoint) започне да се изпълнява, включително грешките 500, и оставя настрана само два случая: когато параметрите не минават валидацията и когато заявката се сблъска с друга, която се изпълнява в момента; тези две могат да се повтарят. Съвпадението с revisor е в тези два случая; разликата е, че revisor не записва и грешките, които възникват след това, и може да освободи ключа, защото трите му грешки възникват, преди да е създадено каквото и да е. Ако твоят API има операции, които се провалят наполовина (взе парите, но не записа разписката), тази политика вече не стига: трябва да се запише грешката или, още по-добре, да се довърши незавършената работа.
6.7.4 Какво ключът не покрива
Записаният отговор не е текущото състояние. В същата серия изведи от поддръжка услуга 7 и повтори оригиналната регистрация на avisos с нейния ключ:
$ curl -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:18080/servicios/7 -H 'Authorization: Bearer escritura-a'
204
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-5f8dc707"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 22:25:53 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/7 -H 'Authorization: Bearer escritura-a'
410
revisor вярно повтори своя 201 с Location: /servicios/7, а /servicios/7 отговаря 410. Това не е грешка: ключът обещава същия отговор, а не че ресурсът ще продължи да е там. Клиент, който опитва отново и получи записания отговор, знае, че операцията му се е случила веднъж; какво е станало после с ресурса, е друг въпрос и се отговаря, като се прочете ресурсът.
Ключът е на онзи, който го изпраща. escritura-b използва същия ключ alta-agenda-1 със същото тяло:
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-b' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-agenda-1' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/9
201, нова услуга, 9, на екип b. За revisor това не е същият ключ, защото регистърът е за всяко удостоверение (${credencial}:${llave}). Това е, което изисква разделът за сигурност на проекта, а го обяснява каретата 6.7.5.
Точният текст на ключа. Същият ключ в кавички, както го пише проектът:
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: "alta-agenda-1"' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200"}'
409
За revisor "alta-agenda-1" с кавички е друг ключ, така че регистрацията беше обработена като нова и я спря мрежата на повтореното име (409, без Location). Клиент, който следва проекта буквално, и сървър, който не маха кавичките, не се разбират; затова проектът изисква форматът на ключа да се публикува и да се валидира.
Рестартиране. revisor се спира и стартира отново и се повтаря регистрацията на agenda с ключа, който вече беше сработил:
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-agenda-1' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/7
--- terminal del revisor:
revisor en http://127.0.0.1:18080
POST /servicios
POST /servicios
idempotencia: la llave alta-avisos-1 ya se atendió; repito su respuesta
POST /servicios
POST /servicios
POST /servicios
POST /servicios
DELETE /servicios/7
POST /servicios
idempotencia: la llave alta-avisos-1 ya se atendió; repito su respuesta
GET /servicios/7
POST /servicios
POST /servicios
revisor en http://127.0.0.1:18080
POST /servicios
201 с идентификатор 7: току-що стартиран revisor не помни нито ключа, нито услугата. В този курс това не вреди, защото всичко се е изгубило заедно; в истински API, който пази услугите в база данни, а ключовете — в паметта, рестартирането би забравило ключовете и би запазило услугите, а повторният опит би създал дубликат. Ключовете се пазят там, където се пази онова, което защитават, и ако е възможно — в същата транзакция.
Има и две граници, които не се нуждаят от изпълнение. Ключът защитава само онова, което прави revisor: ако регистрацията изпращаше приветствен имейл или извикваше друг API, ключът на revisor не пречи този имейл или това извикване да се повторят, освен ако и те не бъдат защитени. А ключът съществува само в POST /servicios. Искането на проверка, POST /servicios/{id}/revisiones, няма ключ в този курс: две заявки създават две проверки, както видя в 6.2.2. PUT и DELETE нямат нужда от него, защото вече са идемпотентни; Stripe казва същото за своите GET и DELETE: изпращането на ключ там няма ефект.
6.7.5 Каре за сигурност: ключът е на онзи, който го изпраща
Таблицата с ключове на практика е кеш на отговори: който представи ключа, получава записания отговор. Ако revisor търсеше само ключа, без удостоверението, всеки, който познае ключа на друг екип, щеше да получи 201 на този екип, с идентификатора, името и адреса на услугата. Проектът предупреждава за това в раздела си за сигурност: реализация, която приема ключове с ниска ентропия, позволява на нападател да познае ключовете на други клиенти и да прочете записаните им отговори. И предлага две защити. revisor има първата: съставен ключ, който комбинира ключа на клиента с нещо, което само сървърът знае за клиента (тук — удостоверението, с което се е удостоверил). Втората — фиксиран и публикуван формат на ключа, който се валидира, преди да се обработи каквото и да е — я има наполовина: изисква само ключът да не е празен. Истински API би изисквал UUID.
Още две неща. Четливите ключове в заявките на този урок, като alta-avisos-1, са, за да можеш да четеш изпълненията: истинският клиент използва случаен UUID, както alta-segura.ts. А ключът не бива да съдържа лични данни: документацията на Stripe изисква да не се използват имейли или идентификатори на хора, защото ключът се озовава в дневници. Същото е и в revisor: терминалът записва ключа (idempotencia: la llave … ya se atendió), никога удостоверението.
6.8 Пълният revisor
6.8.1 Файловете, които се променят
Ако имаш папката от Урок 5, копирай я като ~/curso-http/fig06_01/. Вече видя изцяло idempotencia.ts (6.6.3), pasarela.ts (6.5.1) и alta-segura.ts (6.5.2). Ето другите четири, които се променят.
problemas.ts: два нови типа в каталога, llave-en-curso и llave-reutilizada.
// fig06_01/problemas.ts — el manejador central de errores: cada error sale como Problem Details (RFC 9457).
import type { ServerResponse } from "node:http";
import { escribirCabecera, type Cabeceras } from "./respuestas.ts";
const BASE = "https://revisor.example/problemas/";
type Tipo = { type: string; title: string; status: number };
// El catálogo del curso. El type es el identificador estable: no cambia de una ocurrencia a otra
// ni de un idioma a otro. El title es el nombre corto del tipo, y es lo que se traduce.
// Con about:blank, el title es la frase del código (RFC 9457, sección 4.2.1).
export const CATALOGO = {
"ruta-inexistente": { type: "about:blank", title: "Not Found", status: 404 },
"metodo-no-admitido": { type: "about:blank", title: "Method Not Allowed", status: 405 },
"tipo-no-admitido": { type: "about:blank", title: "Unsupported Media Type", status: 415 },
"cuerpo-demasiado-grande": { type: "about:blank", title: "Content Too Large", status: 413 },
"cuerpo-ilegible": { type: BASE + "cuerpo-ilegible", title: "El cuerpo no se puede leer", status: 400 },
"credencial-invalida": { type: BASE + "credencial-invalida", title: "Falta una credencial válida", status: 401 },
"permiso-insuficiente": { type: BASE + "permiso-insuficiente", title: "La credencial no tiene permiso", status: 403 },
"servicio-inexistente": { type: BASE + "servicio-inexistente", title: "El servicio no existe", status: 404 },
"revision-inexistente": { type: BASE + "revision-inexistente", title: "La revisión no existe", status: 404 },
"nombre-repetido": { type: BASE + "nombre-repetido", title: "Ya existe un servicio con ese nombre", status: 409 },
"servicio-retirado": { type: BASE + "servicio-retirado", title: "El servicio se retiró", status: 410 },
"version-retirada": { type: BASE + "version-retirada", title: "Esa versión de la API se retiró", status: 410 },
"etiqueta-vencida": { type: BASE + "etiqueta-vencida", title: "Tu etiqueta ya no es la vigente", status: 412 },
"destino-prohibido": { type: BASE + "destino-prohibido", title: "El revisor no se conecta a ese destino", status: 422 },
"datos-invalidos": { type: BASE + "datos-invalidos", title: "Los datos no se pueden cumplir", status: 422 },
"llave-en-curso": { type: BASE + "llave-en-curso", title: "Esa Idempotency-Key sigue en curso", status: 409 },
"llave-reutilizada": { type: BASE + "llave-reutilizada", title: "Esa Idempotency-Key ya se usó con otra petición", status: 422 },
"falta-condicion": { type: BASE + "falta-condicion", title: "Falta la condición If-Match", status: 428 },
"demasiadas-revisiones": { type: BASE + "demasiadas-revisiones", title: "Demasiadas revisiones seguidas", status: 429 },
"demasiadas-consultas": { type: BASE + "demasiadas-consultas", title: "Demasiadas consultas en vivo seguidas", status: 429 },
"error-interno": { type: BASE + "error-interno", title: "Error interno del revisor", status: 500 },
"respuesta-invalida": { type: BASE + "respuesta-invalida", title: "El servicio no dio una respuesta HTTP válida", status: 502 },
"en-mantenimiento": { type: BASE + "en-mantenimiento", title: "El revisor está en mantenimiento", status: 503 },
"servicio-lento": { type: BASE + "servicio-lento", title: "El servicio no respondió a tiempo", status: 504 },
} satisfies Record<string, Tipo>;
export type Clave = keyof typeof CATALOGO;
export type Opciones = {
cabeceras?: Cabeceras;
extensiones?: Record<string, unknown>;
instance?: string;
};
// Los nombres de los cinco miembros estándar no se pueden usar como extensión.
const RESERVADOS = new Set(["type", "title", "status", "detail", "instance"]);
export function fallar(respuesta: ServerResponse, clave: Clave, detail: string, opciones: Opciones = {}): void {
const { type, title, status } = CATALOGO[clave];
const extensiones = opciones.extensiones ?? {};
for (const nombre of Object.keys(extensiones)) {
if (RESERVADOS.has(nombre)) throw new Error(`la extensión ${nombre} usa un nombre reservado`);
}
// instance: por omisión, la ruta que se pidió (sin la consulta).
const instance = opciones.instance ?? new URL(respuesta.req.url ?? "/", "http://revisor").pathname;
const cuerpo = JSON.stringify({ type, title, status, detail, instance, ...extensiones }) + "\n";
escribirCabecera(respuesta, status, {
...opciones.cabeceras,
"Content-Type": "application/problem+json",
"Content-Language": "es",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
cliente.ts: clasificar излиза от revisar, за да може revisarConReintentos да я използва с последната заявка, а накрая идват отлагането с jitter и повторните опити.
// fig06_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 function clasificar(consulta: Consulta, limiteMs: number): Resultado {
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) };
}
}
}
export async function revisar(url: string, limiteMs: number): Promise<Resultado> {
return clasificar(await consultar(url, limiteMs), limiteMs);
}
// ---------- Reintentos (lección 6) ----------
// Retroceso exponencial con jitter completo: espera un tiempo al azar entre 0 y base × 2^intento,
// sin pasar del tope. El azar evita que mil clientes reintenten en el mismo instante.
export function esperaConJitter(intento: number, baseMs: number, topeMs: number): number {
return Math.round(Math.random() * Math.min(topeMs, baseMs * 2 ** intento));
}
const INTENTOS = 3;
const BASE_MS = 250;
const TOPE_MS = 2000;
// Si el servicio pide esperar más que esto, no se insiste: se informa lo que dijo.
const TOPE_RETRY_AFTER_S = 5;
// Solo se reintenta lo que puede mejorar solo: sin respuesta, 429, 502, 503 y 504.
// Revisar es un GET, seguro e idempotente: repetirlo no cambia nada del otro lado.
function vale(consulta: Consulta): boolean {
if (consulta.tipo === "destino-prohibido") return false;
return consulta.tipo !== "respuesta" || [429, 502, 503, 504].includes(consulta.codigo);
}
export async function revisarConReintentos(url: string, limiteMs: number): Promise<Resultado> {
let consulta = await consultar(url, limiteMs);
for (let intento = 1; intento < INTENTOS && vale(consulta); intento++) {
const pedida = consulta.tipo === "respuesta" ? consulta.reintentarEn : null;
if (pedida !== null && pedida > TOPE_RETRY_AFTER_S) break;
const espera = pedida !== null ? pedida * 1000 : esperaConJitter(intento, BASE_MS, TOPE_MS);
await new Promise((listo) => setTimeout(listo, espera));
consulta = await consultar(url, limiteMs);
}
return clasificar(consulta, limiteMs);
}
servicios.ts: регистрацията чете Idempotency-Key, резервира ключа, освобождава го, ако се провали, и записва отговора, ако мине успешно; проверките използват revisarConReintentos.
// fig06_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, revisarConReintentos } from "./cliente.ts";
import { grabar, huella, soltar, tomarTurno } from "./idempotencia.ts";
import { setTimeout as esperar } from "node:timers/promises";
import { permitir } from "./limitador.ts";
import { fallar } from "./problemas.ts";
import { CuerpoDemasiadoGrande, enviarJson, enviarVacio, 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;
// Guardar un alta «tarda»: así se ve qué pasa si llega un reintento mientras la original sigue en curso.
const GUARDAR_MS = 500;
// 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, "servicio-retirado", `el servicio ${id} se retiró y no va a volver`);
} else {
fallar(respuesta, "servicio-inexistente", `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, "tipo-no-admitido", `el cuerpo debe ser application/json, no ${tipo === "" ? "(sin tipo)" : tipo}`, {
cabeceras: { 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, "cuerpo-demasiado-grande", `el cuerpo pasa de ${TOPE_CUERPO} bytes`, {
cabeceras: { Connection: "close" },
});
return undefined;
}
// si no, cae al 400 de abajo
}
fallar(respuesta, "cuerpo-ilegible", "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 {
// Varios problemas del mismo tipo en una respuesta: la extensión errors, como en el RFC 9457.
fallar(respuesta, "datos-invalidos", `${fallas.length} dato(s) no se pueden cumplir`, {
extensiones: { errors: fallas.map((f) => ({ detail: f.detalle, pointer: `#/${f.campo}` })) },
});
}
// 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, "destino-prohibido", `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;
// Con Idempotency-Key, la misma petición repetida recibe la misma respuesta y no crea otro servicio.
const cabecera = peticion.headers["idempotency-key"];
const llave = typeof cabecera === "string" && cabecera !== "" ? cabecera : undefined;
if (llave !== undefined) {
const turno = tomarTurno(credencial.nombre, llave, huella(datos));
if (turno.tipo === "repetir") {
console.log(`idempotencia: la llave ${llave} ya se atendió; repito su respuesta`);
enviarJson(respuesta, turno.grabada.codigo, turno.grabada.cuerpo, turno.grabada.cabeceras);
return;
}
if (turno.tipo === "otra-peticion") {
fallar(respuesta, "llave-reutilizada", `la llave ${llave} ya se usó con un cuerpo distinto`);
return;
}
if (turno.tipo === "en-curso") {
fallar(respuesta, "llave-en-curso", `la petición original con la llave ${llave} todavía no termina`);
return;
}
}
const validos = validar(datos);
if (Array.isArray(validos)) {
if (llave !== undefined) soltar(credencial.nombre, llave);
rechazarDatos(respuesta, validos);
return;
}
if (!(await destinoPermitido(validos.url, respuesta))) {
if (llave !== undefined) soltar(credencial.nombre, llave);
return;
}
await esperar(GUARDAR_MS);
const existente = buscarPorNombre(credencial.equipo, validos.nombre);
if (existente !== undefined) {
if (llave !== undefined) soltar(credencial.nombre, llave);
fallar(respuesta, "nombre-repetido", `ya existe un servicio llamado ${validos.nombre}`, {
extensiones: { existente: `/servicios/${existente.id}` },
});
return;
}
const servicio = crearServicio({ ...validos, equipo: credencial.equipo });
const grabada = {
codigo: 201,
cabeceras: { Location: `/servicios/${servicio.id}`, ETag: etiqueta(servicio) },
cuerpo: representar(servicio),
};
if (llave !== undefined) grabar(credencial.nombre, llave, grabada);
enviarJson(respuesta, grabada.codigo, grabada.cuerpo, grabada.cabeceras);
}
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, "falta-condicion", "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, "etiqueta-vencida", 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, "nombre-repetido", `ya existe un servicio llamado ${validos.nombre}`, {
extensiones: { existente: `/servicios/${homonimo.id}` },
});
return;
}
const cambiado = cambiarServicio(servicio.id, versionLeida, validos);
if (cambiado === "conflicto") {
fallar(respuesta, "etiqueta-vencida", "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, "servicio-retirado", `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, "demasiadas-revisiones", `ya pediste ${REVISIONES_POR_VENTANA} revisiones en el último minuto`, {
cabeceras: { "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 revisarConReintentos(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, "revision-inexistente", `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, "demasiadas-consultas", `ya pediste ${CONSULTAS_POR_VENTANA} consultas en vivo en el último minuto`, {
cabeceras: { "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, "servicio-lento", `el servicio ${servicio.id} no respondió en ${servicio.limiteMs} ms`);
return;
case "sin-conexion":
fallar(respuesta, "respuesta-invalida", `no se pudo conectar con el servicio ${servicio.id}`);
return;
case "destino-prohibido":
fallar(respuesta, "destino-prohibido", `destino prohibido: ${consulta.motivo}`);
return;
case "respuesta-invalida":
fallar(respuesta, "respuesta-invalida", `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,
});
}
respuestas.ts: 413 вече не губи надпреварата срещу клиент, който продължава да изпраща. В предишните уроци, когато тялото надхвърлеше горната граница, revisor отговаряше 413 с Connection: close, а Node унищожаваше връзката веднага щом приключеше с отговора. Ако клиентът още изпращаше, на онова, което пристигаше по вече затворената връзка, операционната система отговаряше с RST, а този RST може да изтрие 413, преди клиентът да го прочете (RFC 9112, раздел 9.6). Клиентът виждаше EPIPE или ECONNRESET вместо 413. Същият раздел препоръчва затваряне на етапи: да се отговори, да се затвори само записът (полузатворена връзка), да се продължи четенето известно време и чак тогава да се затвори напълно. Точно това прави cerrarConCalma: Node, когато завършва отговор с Connection: close, извиква socket.destroySoon(), който затваря и унищожава; за тази връзка този метод се заменя с такъв, който затваря само записа (socket.end()), четенето продължава, като пристигащото се изхвърля (peticion.resume()), а таймер от 2 секунди унищожава връзката, ако клиентът не я затвори преди това. 413 излиза веднага, без да се чака клиентът да приключи с изпращането.
// 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);
});
}
Беше измерено изолирано, срещу току-що стартиран revisor, с 400 последователни заявки от по 20 000 байта, изпратени на части (20 парчета по 1000 байта, с fetch на Node): без затварянето на етапи 380 дадоха 413, а 20 се провалиха (17 с EPIPE и 3 с ECONNRESET); с него — 400 от 400. С големи тела, от 3 MiB и от 20 MiB, изпратени на части, 100 от 100 дадоха 413. С Content-Length от 20 000 байта и с curl -T - дадоха 400 от 400 във всички версии: надпреварата съществуваше, но се проявява най-вече когато клиентът изпраща на парчета. Клиент, който не затваря своята страна, не задържа връзката: revisor я унищожава след 2 секунди (връзката минава през FIN_WAIT_2 и след 3,5 s вече не съществува). Пълният файл е в 6.8.2.
6.8.2 Файловете, които не се променят
Останалите файлове са тези от Урок 5, без нито една промяна, с изключение на два, които са дадени тук изцяло: respuestas.ts, който се промени в четенето на тялото (6.8.1), и laboratorio.ts, чийто стенд вече не изпраща тяло при 205 (отговорът 205 няма съдържание, RFC 9110, раздел 15.3.6; излиза с Content-Length: 0 и без Transfer-Encoding, защото 205 не се рамкира сам, RFC 9112, раздел 6.3). Нито едно изпълнение в този урок не иска 205. Дадени са изцяло, за да може revisor да се сглоби от този урок:
// fig06_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" });
// fig06_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"';
}
// fig06_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 };
}
// fig06_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));
}
// fig06_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`);
});
// fig06_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) };
}
// fig06_01/operacion.ts — el modo mantenimiento: lo enciende y lo apaga quien opera el revisor.
import type { ServerResponse } from "node:http";
import { fallar } from "./problemas.ts";
import { enviarVacio } 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, "en-mantenimiento", "el revisor está en mantenimiento", { cabeceras: { "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, "permiso-insuficiente", "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);
}
// fig06_01/respuestas.ts — las respuestas de éxito del revisor y la lectura del cuerpo. Los errores, en problemas.ts.
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" };
export 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();
}
// 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);
});
}
// fig06_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 { fallar } from "./problemas.ts";
import { enviarVacio } 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, "ruta-inexistente", "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, "metodo-no-admitido", `${peticion.method} no se admite en ${url.pathname}`, {
cabeceras: { Allow: permitidos(ruta) },
});
return;
}
// 3. ¿Quién eres?
const identificacion = identificar(peticion);
if (identificacion.tipo !== "valida") {
const detalle = motivo(identificacion);
fallar(respuesta, "credencial-invalida", detalle, { cabeceras: { "WWW-Authenticate": reto(identificacion) } });
return;
}
// 4. ¿Puedes?
if (!puede(identificacion.credencial, metodo)) {
fallar(respuesta, "permiso-insuficiente", "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 });
}
// fig06_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 "./problemas.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) {
// instance identifica esta ocurrencia; con ella, quien opera encuentra la pila en su terminal.
fallar(respuesta, "error-interno", "ocurrió un error inesperado; si se repite, reporta la referencia", {
instance: `urn:uuid:${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}`);
});
// fig06_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 { fallar } from "./problemas.ts";
import { enviarJson, 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, "version-retirada", "la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios");
return true;
}
{
"type": "module"
}
6.8.3 Пълното изпълнение
С трите програми, току-що стартирани в ~/curso-http/fig06_01/, това са заявките от урока по ред, събрани заедно: същият ключ два пъти, същият ключ с друго тяло, двете едновременно, клиентът с ключ и без ключ през шлюза, списъкът и една проверка на pagos:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-68f9abf8"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 23:34:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-68f9abf8"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 23:34:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 228
Date: Wed, 21 Oct 2026 23:34:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/llave-reutilizada","title":"Esa Idempotency-Key ya se usó con otra petición","status":422,"detail":"la llave alta-avisos-1 ya se usó con un cuerpo distinto","instance":"/servicios"}
$ ( curl -o /dev/null -w 'primera: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-correo-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}' & sleep 0.1; curl -w '\nsegunda: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-correo-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}'; wait )
{"type":"https://revisor.example/problemas/llave-en-curso","title":"Esa Idempotency-Key sigue en curso","status":409,"detail":"la petición original con la llave alta-correo-1 todavía no termina","instance":"/servicios"}
segunda: 409
primera: 201
$ node alta-segura.ts agenda http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 201 {"id":9,"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
(código de salida: 0)
$ node alta-segura.ts nomina2 http://127.0.0.1:18081/codigo/200 --sin-llave; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento sin llave (podría duplicar)
intento 2: 409 {"type":"https://revisor.example/problemas/nombre-repetido","title":"Ya existe un servicio con ese nombre","status":409,"detail":"ya existe un servicio llamado nomina2","instance":"/servicios","existente":"/servicios/10"}
el servidor respondió 409: repetir lo mismo no lo arregla; sin llave no se sabe si lo que ya existe lo creó un intento anterior
(código de salida: 1)
$ curl http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/lento/1500","limite_ms":2000},{"id":5,"nombre":"reportes","url":"http://127.0.0.1:18081/lento/3000","limite_ms":1000},{"id":6,"nombre":"facturacion","url":"http://127.0.0.1:18081/basura","limite_ms":2000},{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":8,"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":9,"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":10,"nombre":"nomina2","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}]}
$ 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
$ sleep 2
$ curl http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer escritura-a'
{"id":1,"servicio":2,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":503,"ms":17,"detalle":"503 (reintentar en 120 s)"}}
Проверката на pagos е същата като в Урок 4, но сега с причина: мина през revisarConReintentos, услугата поиска 120 s и понеже това надхвърля горната граница от 5 s, revisor не настоя и запази предупреждението в детайла.
6.8.4 Останалото продължава да работи
Отказите заради SSRF, условните заявки, изгубената актуализация с два едновременни PUT, редът на PUT, схемата на удостоверението, методът с име от Object.prototype, горната граница на тялото, тялото, което не е обект, диапазоните за SSRF, ограничението на заявките на живо за всяко удостоверение, 502 от услуга, която прекъсва връзката, стриктното разчитане на Retry-After и етикетът след рестартиране: нищо от това не се е променило. Това е същото изпълнение, продължено. Почти накрая се спира revisor, а после и шлюзът ($REV и $PAS са PID-овете, които е запазил скриптът, стартирал програмите; ти би ги спрял с Ctrl+C). esperar_puerto е функция от този скрипт: чака до 10 s портът да започне да приема връзки, вместо да спи фиксирано време след стартиране на програма, за да се повторят случаите от 6.5.3 (шлюзът, който отговаря 502, и alta-segura.ts с изчерпаните опити, с шлюз и без шлюз); двете програми се стартират отново, а между предпоследната и последната заявка revisor се рестартира още веднъж:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"metadatos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 230
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 169.254.169.254:80: dirección privada o reservada","instance":"/servicios"}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"yo-mismo","url":"http://localhost:18080/mantenimiento"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 235
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: localhost resuelve a una dirección privada o reservada","instance":"/servicios"}
$ ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'); echo "ETAG=$ETAG"
ETAG="1-1-68f9abf8"
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H "If-None-Match: W/$ETAG"
HTTP/1.1 304 Not Modified
ETag: "1-1-68f9abf8"
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H "If-None-Match: \"9-9\", $ETAG"
HTTP/1.1 304 Not Modified
ETag: "1-1-68f9abf8"
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H 'If-None-Match: *'
HTTP/1.1 304 Not Modified
ETag: "1-1-68f9abf8"
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H 'If-None-Match: "9-9", W/"8-8"'
HTTP/1.1 200 OK
ETag: "1-1-68f9abf8"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: W/$ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
Content-Language: es
Content-Length: 263
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/etiqueta-vencida","title":"Tu etiqueta ya no es la vigente","status":412,"detail":"If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó","instance":"/servicios/1"}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: \"9-9\", $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-2-68f9abf8"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'If-Match: *' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-3-68f9abf8"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 23:34:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-3-68f9abf8"
$ ( sleep 1; printf '%s' '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1000}' ) | curl -w 'lenta: %{http_code}\n' -X PUT -T - http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" & LENTA=$!; sleep 0.3; curl -o /dev/null -w 'rápida: %{http_code}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}'; wait $LENTA
rápida: 200
{"type":"https://revisor.example/problemas/etiqueta-vencida","title":"Tu etiqueta ya no es la vigente","status":412,"detail":"el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo","instance":"/servicios/1"}
lenta: 412
$ curl http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}
$ curl -o /dev/null -w 'text/plain con etiqueta vieja: %{http_code}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -H 'If-Match: "1-0-0"' -d 'catalogo'
text/plain con etiqueta vieja: 415
$ curl -o /dev/null -w 'text/plain sin If-Match: %{http_code}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'catalogo'
text/plain sin If-Match: 415
$ ID=$(curl -o /dev/null -w '%header{location}' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"efimero","url":"http://127.0.0.1:18081/codigo/200"}'); ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080$ID -H 'Authorization: Bearer escritura-a'); echo "ID=$ID ETAG=$ETAG"
ID=/servicios/11 ETAG="11-1-68f9abf8"
$ ( sleep 1; printf '%s' '{"nombre":"efimero","url":"http://127.0.0.1:18081/codigo/200"}' ) | curl -w ' <- PUT lento: %{http_code}\n' -X PUT -T - http://127.0.0.1:18080$ID -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" & LENTO=$!; sleep 0.3; curl -o /dev/null -w 'DELETE en el hueco: %{http_code}\n' -X DELETE http://127.0.0.1:18080$ID -H 'Authorization: Bearer escritura-a'; wait $LENTO
DELETE en el hueco: 204
{"type":"https://revisor.example/problemas/servicio-retirado","title":"El servicio se retiró","status":410,"detail":"el servicio 11 se retiró y no va a volver","instance":"/servicios/11"}
<- PUT lento: 410
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 800
Date: Wed, 21 Oct 2026 23:34:46 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/lento/1500","limite_ms":2000},{"id":5,"nombre":"reportes","url":"http://127.0.0.1:18081/lento/3000","limite_ms":1000},{"id":6,"nombre":"facturacion","url":"http://127.0.0.1:18081/basura","limite_ms":2000},{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":8,"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":9,"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":10,"nombre":"nomina2","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}]}
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Basic bGVjdHVyYS1hOg=='
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: application/problem+json
Content-Language: es
Content-Length: 185
Date: Wed, 21 Oct 2026 23:34:46 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/credencial-invalida","title":"Falta una credencial válida","status":401,"detail":"solo se admite el esquema Bearer","instance":"/servicios"}
$ curl -i -X constructor http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 400 Bad Request
Connection: close
$ head -c 20000 /dev/zero | tr '\0' 'a' | curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
HTTP/1.1 413 Content Too Large
Connection: close
Content-Type: application/problem+json
Content-Language: es
Content-Length: 129
Date: Wed, 21 Oct 2026 23:34:46 GMT
{"type":"about:blank","title":"Content Too Large","status":413,"detail":"el cuerpo pasa de 16384 bytes","instance":"/servicios"}
$ head -c 20000 /dev/zero | tr '\0' 'a' | curl -o /dev/null -w 'por partes: %{http_code}\n' -X POST -T - http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json'
por partes: 413
$ head -c 16384 /dev/zero | tr '\0' 'a' | curl -o /dev/null -w 'en el tope: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
en el tope: 400
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d 'null'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Content-Language: es
Content-Length: 177
Date: Wed, 21 Oct 2026 23:34:46 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/cuerpo-ilegible","title":"El cuerpo no se puede leer","status":400,"detail":"el cuerpo no es un objeto JSON","instance":"/servicios"}
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-1","url":"http://100.100.100.200/latest/meta-data/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 100.100.100.200:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-2","url":"http://[64:ff9b::a9fe:a9fe]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [64:ff9b::a9fe:a9fe]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-3","url":"http://224.0.0.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 224.0.0.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-4","url":"http://[ff02::1]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [ff02::1]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-5","url":"http://[2002:a9fe:a9fe::]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [2002:a9fe:a9fe::]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-6","url":"http://[::7f00:1]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [::7f00:1]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-7","url":"http://[64:ff9b:1::a9fe:a9fe]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [64:ff9b:1::a9fe:a9fe]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-8","url":"http://240.0.0.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 240.0.0.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-9","url":"http://198.18.0.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 198.18.0.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-10","url":"http://192.0.2.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 192.0.2.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-11","url":"http://[2001:db8::1]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [2001:db8::1]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-12","url":"http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [2001:0:4136:e378:8000:63bf:3fff:fdd2]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ for i in 1 2 3 4; do curl -o /dev/null -w '%{http_code} %header{retry-after}\n' http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'; done
200
200
200
429 60
$ for i in 1 2 3 4 5 6; do curl -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$i/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
404 404 202 429 429 429
$ ID=$(curl -o /dev/null -w '%header{location}' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"cortado","url":"http://127.0.0.1:18081/cortar"}'); echo "ID=$ID"
ID=/servicios/12
$ curl -i http://127.0.0.1:18080$ID/salud -H 'Authorization: Bearer admin'
HTTP/1.1 502 Bad Gateway
Content-Type: application/problem+json
Content-Language: es
Content-Length: 280
Date: Wed, 21 Oct 2026 23:34:47 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/respuesta-invalida","title":"El servicio no dio una respuesta HTTP válida","status":502,"detail":"el servicio 12 no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP","instance":"/servicios/12/salud"}
$ TZ=America/Mexico_City node --input-type=module -e 'import { segundosDeEspera } from "./cliente.ts"; for (const v of ["120", "1.5", "-1", "12 30"]) console.log(JSON.stringify(v), "->", segundosDeEspera(v)); const imf = segundosDeEspera("Fri, 01 Jan 2100 00:00:00 GMT"), asc = segundosDeEspera("Fri Jan 1 00:00:00 2100"); console.log("asctime menos IMF-fixdate (misma fecha):", asc - imf, "s")'
"120" -> 120
"1.5" -> null
"-1" -> null
"12 30" -> null
asctime menos IMF-fixdate (misma fecha): 0 s
$ kill $REV; wait $REV 2>/dev/null
$ curl -i -X POST http://127.0.0.1:18082/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"sin-revisor","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 502 Bad Gateway
Content-Type: application/problem+json
Content-Length: 115
Date: Wed, 21 Oct 2026 23:34:47 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
$ node alta-segura.ts sin-revisor http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió respuesta del revisor"}
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave 482212c7-c00e-499c-b66e-50f774613a5b es seguro
(código de salida: 1)
$ kill $PAS; wait $PAS 2>/dev/null
$ node alta-segura.ts sin-pasarela http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: sin respuesta (ECONNREFUSED)
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: sin respuesta (ECONNREFUSED)
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: sin respuesta (ECONNREFUSED)
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: sin respuesta (ECONNREFUSED)
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave bb5d6748-d1cc-4a88-b665-48721b90a18c es seguro
(código de salida: 1)
$ node -e 'require("node:net").createServer(() => {}).listen(18082, "127.0.0.1")' & MUDO=$!; esperar_puerto 18082
$ ALTA_LIMITE_MS=300 node alta-segura.ts mudo http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: sin respuesta (tiempo agotado: 300 ms)
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: sin respuesta (tiempo agotado: 300 ms)
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: sin respuesta (tiempo agotado: 300 ms)
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: sin respuesta (tiempo agotado: 300 ms)
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave 2d5d719b-8c6b-49a0-b118-494720b1831c es seguro
(código de salida: 1)
$ kill $MUDO; wait $MUDO 2>/dev/null
$ node --input-type=module -e 'import { createServer } from "node:http"; let n = 0; createServer((q, r) => { q.resume(); if (++n === 1) { r.writeHead(503, { "Retry-After": "2", "Content-Type": "application/problem+json" }); r.end("{\"type\":\"about:blank\",\"title\":\"Service Unavailable\",\"status\":503}\n"); } else { r.writeHead(201, { "Content-Type": "application/json" }); r.end("{\"id\":99}\n"); } }).listen(18082, "127.0.0.1")' & PIDE=$!; esperar_puerto 18082
$ node alta-segura.ts paciente http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 503 {"type":"about:blank","title":"Service Unavailable","status":503}
el servidor pide esperar 2 s (Retry-After); espero 2 s y reintento con la misma llave
intento 2: 201 {"id":99}
(código de salida: 0)
$ kill $PIDE; wait $PIDE 2>/dev/null
$ node --input-type=module -e 'import { createServer } from "node:http"; let n = 0; createServer((q, r) => { q.resume(); if (++n === 1) { r.writeHead(409, { "Content-Type": "application/problem+json" }); r.end("{\"type\":\"https://revisor.example/problemas/llave-en-curso\",\"title\":\"Esa Idempotency-Key sigue en curso\",\"status\":409}\n"); } else { r.writeHead(201, { "Content-Type": "application/json" }); r.end("{\"id\":99}\n"); } }).listen(18082, "127.0.0.1")' & CURSO=$!; esperar_puerto 18082
$ node alta-segura.ts en-curso http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 409 {"type":"https://revisor.example/problemas/llave-en-curso","title":"Esa Idempotency-Key sigue en curso","status":409}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 201 {"id":99}
(código de salida: 0)
$ kill $CURSO; wait $CURSO 2>/dev/null
$ node pasarela.ts >> pasarela.log 2>&1 & PAS=$!; node servidor.ts >> servidor.log 2>&1 & REV=$!; esperar_puerto 18082; esperar_puerto 18080
$ curl -i -X POST http://127.0.0.1:18082/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"por-pasarela","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 92
location: /servicios/7
etag: "7-1-7b8bb8f3"
Date: Wed, 21 Oct 2026 23:34:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"por-pasarela","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ node alta-segura.ts invalida ftp://archivos; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 422 {"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"1 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser una dirección http o https","pointer":"#/url"}]}
el servidor respondió 422: repetir lo mismo no lo arregla
(código de salida: 1)
$ curl -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":true}'
204
$ node alta-segura.ts enmantenimiento http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 503 {"type":"https://revisor.example/problemas/en-mantenimiento","title":"El revisor está en mantenimiento","status":503,"detail":"el revisor está en mantenimiento","instance":"/servicios"}
el servidor pidió esperar 120 s (Retry-After), más de los 5 s que este programa espera; no se reintenta; repetir más tarde con la llave ffdfd2cf-5288-4266-8637-68d792db78f5 es seguro
(código de salida: 1)
$ curl -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":false}'
204
$ ANTES=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'); echo "ANTES=$ANTES"
ANTES="3-1-7b8bb8f3"
$ kill $REV; wait $REV 2>/dev/null; node servidor.ts >> servidor.log 2>&1 & REV=$!; esperar_puerto 18080
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b' -H "If-None-Match: $ANTES"
HTTP/1.1 200 OK
ETag: "3-1-223a2357"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 23:34:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
И двата терминала от пълното изпълнение. Този на revisor записва метода и маршрута на всяка заявка, двата реда за идемпотентност и двете стартирания след първото (по едно за всяко рестартиране, които виждаш като второ и трето „revisor en…“); този на шлюза — двата отговора, които е изгубил, петте „el revisor no respondió“ (curl и четирите опита на sin-revisor) и второто му стартиране:
--- terminal del revisor (la carpeta de la máquina se escribe <carpeta>):
revisor en http://127.0.0.1:18080
POST /servicios
POST /servicios
idempotencia: la llave alta-avisos-1 ya se atendió; repito su respuesta
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
idempotencia: la llave 00baad42-25bb-4f27-adbd-fa28abaf50fb ya se atendió; repito su respuesta
POST /servicios
POST /servicios
GET /servicios
POST /servicios/2/revisiones
GET /revisiones/1
POST /servicios
POST /servicios
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /servicios/1
PUT /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /servicios/1
POST /servicios
GET /servicios/11
PUT /servicios/11
DELETE /servicios/11
GET /servicios
GET /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios/1
POST /servicios
GET /servicios/1
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios/1/salud
GET /servicios/1/salud
GET /servicios/1/salud
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
GET /servicios/12/salud
revisor en http://127.0.0.1:18080
POST /servicios
POST /servicios
POST /servicios
PUT /mantenimiento
operación: mantenimiento encendido
POST /servicios
POST /servicios
PUT /mantenimiento
operación: mantenimiento apagado
GET /servicios/3
revisor en http://127.0.0.1:18080
GET /servicios/3
--- terminal de la pasarela:
pasarela en http://127.0.0.1:18082 → http://127.0.0.1:18080
pasarela: el revisor respondió 201, pero la respuesta se pierde
pasarela: el revisor respondió 201, pero la respuesta se pierde
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela: el revisor no respondió
pasarela en http://127.0.0.1:18082 → http://127.0.0.1:18080
pasarela: el revisor respondió 422, pero la respuesta se pierde
pasarela: el revisor respondió 503, pero la respuesta se pierde
6.9 Проверка с програма
Както в Урок 5, онова, което може да се провери ръчно, се проверява с програма. fig06_03.ts прави пет проби срещу регистрацията, всяка с нови имена и ключове, за да може да се изпълнява колкото пъти искаш, без да се рестартира revisor: същият ключ два пъти оставя една-единствена услуга; същият ключ с друго тяло дава 422 llave-reutilizada и не създава нищо; същият ключ, докато оригиналната заявка още се обработва, дава 409 llave-en-curso; регистрация, която се провали, освобождава ключа; и два различни ключа със същото тяло се сблъскват с името. Последната не проверява ключа, а проверява неговата граница: ключът не превръща две намерения в едно.
// fig06_03.ts — comprueba que el alta del revisor respete Idempotency-Key como propone el borrador de la IETF.
// Uso: node fig06_03.ts [base] (por omisión, http://127.0.0.1:18080)
// Cada corrida usa nombres y llaves nuevos, así que se puede repetir sin reiniciar el revisor.
import { randomUUID } from "node:crypto";
const BASE = process.argv[2] ?? "http://127.0.0.1:18080";
const URL_LAB = "http://127.0.0.1:18081/codigo/200";
const PROBLEMAS = "https://revisor.example/problemas/";
const marca = randomUUID().slice(0, 8);
type Respuesta = { codigo: number; location: string | null; cuerpo: Record<string, unknown> };
async function alta(llave: string, datos: Record<string, unknown>): Promise<Respuesta> {
const respuesta = await fetch(BASE + "/servicios", {
method: "POST",
headers: { Authorization: "Bearer escritura-a", "Content-Type": "application/json", "Idempotency-Key": llave },
body: JSON.stringify(datos),
});
return { codigo: respuesta.status, location: respuesta.headers.get("location"), cuerpo: await respuesta.json() };
}
async function cuantos(nombre: string): Promise<number> {
const respuesta = await fetch(BASE + "/servicios", { headers: { Authorization: "Bearer escritura-a" } });
const { servicios } = (await respuesta.json()) as { servicios: { nombre: string }[] };
return servicios.filter((s) => s.nombre === nombre).length;
}
type Caso = { nombre: string; probar: () => Promise<string | null> };
const CASOS: Caso[] = [
{
nombre: "misma llave y mismo cuerpo: la misma respuesta, un solo servicio",
probar: async () => {
const datos = { nombre: `uno-${marca}`, url: URL_LAB };
const primera = await alta(`k1-${marca}`, datos);
const segunda = await alta(`k1-${marca}`, datos);
if (primera.codigo !== 201) return `la primera dio ${primera.codigo}, se esperaba 201`;
if (segunda.codigo !== 201 || segunda.location !== primera.location) {
return `la segunda dio ${segunda.codigo} ${segunda.location}, se esperaba 201 ${primera.location}`;
}
const n = await cuantos(datos.nombre);
return n === 1 ? null : `hay ${n} servicios con ese nombre, se esperaba 1`;
},
},
{
nombre: "misma llave con otro cuerpo: 422 llave-reutilizada, y no crea nada",
probar: async () => {
await alta(`k2-${marca}`, { nombre: `dos-${marca}`, url: URL_LAB });
const otra = await alta(`k2-${marca}`, { nombre: `otro-${marca}`, url: URL_LAB });
if (otra.codigo !== 422 || otra.cuerpo.type !== PROBLEMAS + "llave-reutilizada") {
return `dio ${otra.codigo} ${String(otra.cuerpo.type ?? "")}, se esperaba 422 llave-reutilizada`;
}
const n = await cuantos(`otro-${marca}`);
return n === 0 ? null : `se creó el servicio del segundo cuerpo (${n})`;
},
},
{
nombre: "misma llave mientras la original sigue en curso: 409 llave-en-curso",
probar: async () => {
const datos = { nombre: `tres-${marca}`, url: URL_LAB };
const original = alta(`k3-${marca}`, datos);
await new Promise((listo) => setTimeout(listo, 100));
const durante = await alta(`k3-${marca}`, datos);
const primera = await original;
if (durante.codigo !== 409 || durante.cuerpo.type !== PROBLEMAS + "llave-en-curso") {
return `la de en medio dio ${durante.codigo} ${String(durante.cuerpo.type ?? "")}, se esperaba 409 llave-en-curso`;
}
return primera.codigo === 201 ? null : `la original dio ${primera.codigo}, se esperaba 201`;
},
},
{
nombre: "un alta que falla libera la llave: corregida, con la misma llave, 201",
probar: async () => {
const mala = await alta(`k4-${marca}`, { nombre: `cuatro-${marca}`, url: "ftp://archivos" });
if (mala.codigo !== 422) return `el alta mala dio ${mala.codigo}, se esperaba 422`;
const buena = await alta(`k4-${marca}`, { nombre: `cuatro-${marca}`, url: URL_LAB });
return buena.codigo === 201 ? null : `el alta corregida dio ${buena.codigo}, se esperaba 201`;
},
},
{
nombre: "dos llaves distintas con el mismo cuerpo: la segunda choca con el nombre (409 nombre-repetido)",
probar: async () => {
const datos = { nombre: `cinco-${marca}`, url: URL_LAB };
await alta(`k5a-${marca}`, datos);
const otra = await alta(`k5b-${marca}`, datos);
if (otra.codigo !== 409 || otra.cuerpo.type !== PROBLEMAS + "nombre-repetido") {
return `dio ${otra.codigo} ${String(otra.cuerpo.type ?? "")}, se esperaba 409 nombre-repetido`;
}
const n = await cuantos(datos.nombre);
return n === 1 ? null : `hay ${n} servicios con ese nombre, se esperaba 1`;
},
},
];
let bien = 0;
for (const caso of CASOS) {
const falla = await caso.probar();
if (falla === null) bien++;
console.log(`${falla === null ? "ok " : "FALLA"} ${caso.nombre}`);
if (falla !== null) console.log(` ${falla}`);
}
console.log();
console.log(`${bien} de ${CASOS.length} comprobaciones de idempotencia pasaron`);
process.exitCode = bien === CASOS.length ? 0 : 1;
$ node fig06_03.ts
ok misma llave y mismo cuerpo: la misma respuesta, un solo servicio
ok misma llave con otro cuerpo: 422 llave-reutilizada, y no crea nada
ok misma llave mientras la original sigue en curso: 409 llave-en-curso
ok un alta que falla libera la llave: corregida, con la misma llave, 201
ok dos llaves distintas con el mismo cuerpo: la segunda choca con el nombre (409 nombre-repetido)
5 de 5 comprobaciones de idempotencia pasaron
Пет от пет и програмата излиза с код 0 (или с 1, ако нещо се провали), така че може да се използва в скрипт. Това е още едно парче от матрицата на Урок 8, която проверява същото заедно с 23-те кода.
6.10 Проверка на типовете с tsc (по избор)
От папката на revisor, с командата от раздел 0.9 на Урок 0 плюс --allowImportingTsExtensions:
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
Не отпечатва нищо: няма грешки. fig06_02.ts, fig06_03.ts и fig06_04.ts минават същата команда от горната папка, както и двете счупени версии от следващия раздел и решението на упражнение 3, всяка поставена върху копие на revisor.
Грешката, която ще видиш
Ключът, който се сменя при всеки опит
Най-честата грешка с Idempotency-Key е ключът да се генерира на грешното място: в цикъла на повторните опити, до заявката, вместо извън него, до намерението. Изглежда естествено, защото „всяка заявка носи своя ключ“. За да я предизвикаш, копирай fig06_01/ като rota/ и накарай rota/alta-segura.ts да измисля ключа отново при всяко завъртане на for (пълната версия е в defectos/llave-por-intento/alta-segura.ts). Ключът се променя от const на let и получава нова стойност в началото на всеки опит:
// DEFECTO A PROPÓSITO (lección 6): la llave se vuelve a inventar en cada intento, no una por intención.
let llave = opcion === "--sin-llave" ? undefined : randomUUID();
for (let intento = 1; intento <= INTENTOS && !terminado; intento++) {
llave = opcion === "--sin-llave" ? undefined : randomUUID();
const resultado = await intentar();
Стартирай лабораторията, revisor и шлюза от rota/ и регистрирай agenda:
$ node alta-segura.ts agenda http://127.0.0.1:18081/codigo/200; echo "(código de salida: $?)"
intento 1: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 500 ms y reintento con la misma llave
intento 2: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 1000 ms y reintento con la misma llave
intento 3: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
espero entre 0 y 2000 ms y reintento con la misma llave
intento 4: 502 {"type":"about:blank","title":"Bad Gateway","status":502,"detail":"la pasarela no recibió una respuesta completa del revisor"}
se agotaron los 4 intentos sin una respuesta definitiva; repetir más tarde con la llave 27448039-e4ad-4701-8fd0-597a17c28f2a es seguro
(código de salida: 1)
$ curl http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/lento/1500","limite_ms":2000},{"id":5,"nombre":"reportes","url":"http://127.0.0.1:18081/lento/3000","limite_ms":1000},{"id":6,"nombre":"facturacion","url":"http://127.0.0.1:18081/basura","limite_ms":2000},{"id":7,"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}]}
--- terminal del revisor:
revisor en http://127.0.0.1:18080
POST /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios
--- terminal de la pasarela:
pasarela en http://127.0.0.1:18082 → http://127.0.0.1:18080
pasarela: el revisor respondió 201, pero la respuesta se pierde
pasarela: el revisor respondió 409, pero la respuesta se pierde
pasarela: el revisor respondió 409, pero la respuesta se pierde
pasarela: el revisor respondió 409, pero la respuesta se pierde
Четири 502 и клиентът се отказва, убеден, че се е провалил. Списъкът казва друго: agenda съществува, с идентификатор 7. А терминалите разказват историята: revisor е получил четири регистрации; първата е създала agenda, а другите три са се сблъскали с името (409); шлюзът е изхвърлил и четирите отговора. Защо и четирите, щом шлюзът губи само първия отговор на всяка заявка? Защото разпознава „същата заявка“ по нейния ключ, а всеки опит носеше нов: за него това бяха четири различни заявки, всяка за първи път. За revisor също: нито един ключ не се е повторил, така че никога не е имало какво да се повтаря. Един ключ за всеки опит е същото като да нямаш ключ, с едно утежняващо обстоятелство: кодът изглежда защитен.
Обърни внимание и какво казва програмата за себе си. Всяко изчакване обявява „reintento con la misma llave“ (опитвам отново със същия ключ), а това е невярно: текстът е сглобен в началото, когато е имало само един ключ. А последният ред, „repetir más tarde con la llave … es seguro“, назовава ключа на четвъртия опит, който не е онзи, създал agenda (това беше ключът на първия, а revisor не го видя повече). Който последва този съвет, не е защитен от нищо. С един ключ за всеки опит верните съобщения на правилната версия се превръщат в лъжи, които никой не забелязва, защото изходът изглежда грижливо направен: изходът на една програма е толкова надежден, колкото и логиката, която го произвежда, а тук кодът и текстът си противоречат.
Тук мрежата на името спаси revisor от дубликат. POST без уникално поле, като искането на проверка, щеше да създаде четири. Спри програмите от rota/, когато приключиш.
Отпечатъкът, който никой не сравнява
Втората грешка е от страна на сървъра: да пазиш ключа и да забравиш отпечатъка. Изкушаващо е, защото „щом ключът е уникален, тялото трябва да е същото“. Копирай отново fig06_01/ като rota2/ и изтрий в rota2/idempotencia.ts сравнението на отпечатъка (пълната версия е в defectos/sin-huella/idempotencia.ts):
// DEFECTO A PROPÓSITO (lección 6): no se compara la huella; cualquier cuerpo con esa llave «es el mismo».
Стартирай трите от rota2/ и изпрати двете регистрации от 6.6.4, тази на avisos и тази на correo, със същия ключ:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-9d9d764a"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 22:26:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: alta-avisos-1' -d '{"nombre":"correo","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/7
ETag: "7-1-9d9d764a"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 22:26:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/lento/1500","limite_ms":2000},{"id":5,"nombre":"reportes","url":"http://127.0.0.1:18081/lento/3000","limite_ms":1000},{"id":6,"nombre":"facturacion","url":"http://127.0.0.1:18081/basura","limite_ms":2000},{"id":7,"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}]}
Регистрацията на correo получи 201 Created с тялото на avisos. Клиент, който гледа само кода, вярва, че correo съществува, а списъкът казва, че не. Това е по-лошо от грешка, защото не се вижда: повредата се проявява дни по-късно, когато някой потърси correo в таблото. Проверяващата програма я намира:
$ node fig06_03.ts
ok misma llave y mismo cuerpo: la misma respuesta, un solo servicio
FALLA misma llave con otro cuerpo: 422 llave-reutilizada, y no crea nada
dio 201 , se esperaba 422 llave-reutilizada
ok misma llave mientras la original sigue en curso: 409 llave-en-curso
ok un alta que falla libera la llave: corregida, con la misma llave, 201
ok dos llaves distintas con el mismo cuerpo: la segunda choca con el nombre (409 nombre-repetido)
4 de 5 comprobaciones de idempotencia pasaron
Четири от пет: единствената проба, която се проваля, е тази, която изпраща друго тяло със същия ключ. Поправката е тази от fig06_01/: да се сравнява отпечатъкът и да се отговаря 422, когато не съвпада. Спри програмите от rota2/, когато приключиш.
Честите грешки
Да повтаряш всичко. 400, 401, 404 или 422 не се оправят с времето: повторението им само хаби ресурси. Двата въпроса от 6.3.1 стоят преди всеки повторен опит.
Да повтаряш, без да чакаш, или като чакаш винаги едно и също. Без изчакване повторният опит пристига, докато сървърът още е недостъпен. С фиксирано изчакване и без случайност всички клиенти пристигат заедно. Експоненциално отлагане с jitter, с горна граница и максимален брой опити.
Да затвориш рязко след 413. Ако сървърът затвори връзката, докато клиентът още изпраща, RST може да изтрие 413 и клиентът вижда мрежова грешка вместо отговора. Затваря се на етапи: отговаря се, затваря се записът, четенето продължава известно време и после връзката се затваря напълно (RFC 9112, раздел 9.6).
Да чакаш без граница. fetch без максимално време оставя клиента да виси с минути при сървър, който приема връзката и не отговаря. Всеки опит носи граница и ако бъде прекъснат, се третира като „без отговор“.
Да излезеш с успех, когато успех няма. Програма, която завършва с 0 след 422 или 409, казва на онзи, който я извиква, че регистрацията е направена. 0 е само за регистрация, която е направена.
Да чакаш след последния опит. Времето се хаби за нищо и клиентът разбира по-късно, че се е провалил. А да завършиш, без да кажеш, че опитите са се изчерпали (или да завършиш с успех), оставя скрипта, който те извиква, без да знае какво е останало незавършено: при изчерпването им — съобщение и изходен код, различен от нула.
Да повтаряш на всеки слой. Ако браузърът, шлюзът, услугата и библиотеката за базата данни повтарят по три пъти всеки, една повреда се умножава десетки пъти. Повторни опити се правят само на едно място.
Да пренебрегваш Retry-After или да го приемаш като предложение. Ако сървърът иска 120 s, а клиентът опита отново след 2, следващият 429 е заслужен. Ако изчакването, което иска, е по-дълго, отколкото клиентът може да чака, честното е да не опитва отново и да го каже, както прави revisor.
Да повтаряш POST без ключ. Това е сцената от началото. Ако сървърът не предлага ключ, клиентът съгласува (прочита отново състоянието), преди да повтори.
Да генерираш ключа при всеки опит. Това е „Ключът, който се сменя при всеки опит“: кодът изглежда защитен, а не е.
Да пазиш ключа без отпечатъка. Това е „Отпечатъкът, който никой не сравнява“: различно тяло получава успех, който не е негов.
Да пазиш ключа без удостоверението. Таблицата с ключове се превръща в кеш, който всеки може да прочете, като познава ключове (6.7.5).
Да обещаваш „без дубликати“, без да кажеш докога. Ключът изтича, губи се при рестартиране, ако живее на друго място, а не там, където е онова, което защитава, не покрива външните ефекти и не покрива другите маршрути. Ако документацията на твоя API не казва това, клиентите ти ще предполагат, че ключът защитава повече, отколкото защитава.
Да представяш Idempotency-Key като стандарт. Това е проект с изтекъл срок. Използвай го и го опиши в документацията си като онова, което е.
Упражнения
И четирите се проверяват сами: всяко завършва с изпълнение, което ти казва дали си познал. Запиши прогнозата си в дневника, преди да изпълниш каквото и да е. За 1 и 4 ти трябват лабораторията, revisor и шлюзът от 6.8, току-що стартирани.
Упражнение 1 — Може ли да се повтори?
За всяка от тези шест операции предвиди дали е идемпотентна и какво отговаря втория път. После изпълни всяка от тях два пъти и сравни състоянието с очакваното:
GET /servicios/1PUT /servicios/1сIf-Match: *и{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}PUT /mantenimientoсadminи{"activo":false}POST /servicios/1/revisionesDELETE /servicios/1POST /serviciosсIdempotency-Key: ejercicio-1и{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}
Подсказка: за всяка от тях отпечатай кода и, ако е приложимо, ETag или Location с curl -o /dev/null -w '%{http_code} %header{etag}\n'.
Упражнение 2 — Изчакванията на alta-segura.ts
Без да изпълняваш нищо, отговори: ако четирите опита на alta-segura.ts получат 502, колко пъти чака? какъв е таванът на всяко изчакване? за колко време най-много може да се откаже (без да броиш времето на заявките)? какво отпечатва накрая и с какъв код излиза? Предвиди и какво се променя, ако регистрацията е с --sin-llave. После сравни с изпълненията от 6.5.3. Накрая кажи с едно изречение защо if (intento === INTENTOS) break; стои преди изчакването, а не след него. За финал предвиди изходния код и броя заявки, които вижда fig06_04.ts, при всеки от тези три отговора и провери с 6.5.4: 422, 503:1 201 и 409:en-curso 201.
Упражнение 3 — Ключът, който изтича
Копирай fig06_01/ като ej3/ и добави в ej3/idempotencia.ts срок на валидност: пази във всеки запис кога е бил резервиран ключът и ако са минали повече от VIGENCIA_MS милисекунди, третирай ключа като нов. Използвай 5000 ms по подразбиране, за да можеш да го видиш как изтича, и позволи стойността да се променя при стартиране с променливата на средата REVISOR_VIGENCIA_MS.
За да разбереш дали си познал, стартирай трите програми от ej3/ и направи два експеримента. Първо изпрати регистрацията на avisos с Idempotency-Key: vence-1 два пъти подред, изчакай шест секунди и я изпрати трети път: предвиди кода на всяка. После регистрирай agenda с Idempotency-Key: vence-2, изведи я от поддръжка, изчакай шест секунди и повтори регистрацията със същия ключ: предвиди кода и идентификатора.
Упражнение 4 — Колко пъти извиква?
Регистрирай три услуги от лабораторията, e-504 (/codigo/504), e-cortar (/cortar) и e-500 (/codigo/500), и поискай по една проверка за всяка от тях с escritura-a, плюс една за reportes (услуга 5, /lento/3000 с граница от 1000 ms) с admin. Преди да погледнеш терминала на лабораторията, предвиди колко пъти се появява всеки маршрут и какъв detalle има всяка проверка. Изчакай осем секунди, преди да поискаш резултатите: защо толкова?
Решения
Решение 1
$ for i in 1 2; do curl -o /dev/null -w 'GET: %{http_code} %header{etag}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'; done
GET: 200 "1-1-7552bbf0"
GET: 200 "1-1-7552bbf0"
$ for i in 1 2; do curl -o /dev/null -w 'PUT con *: %{http_code} %header{etag}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'If-Match: *' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'; done
PUT con *: 200 "1-2-7552bbf0"
PUT con *: 200 "1-3-7552bbf0"
$ curl http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ for i in 1 2; do curl -o /dev/null -w 'PUT /mantenimiento: %{http_code}\n' -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":false}'; done
PUT /mantenimiento: 204
PUT /mantenimiento: 204
$ for i in 1 2; do curl -o /dev/null -w 'POST revisión: %{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer escritura-a'; done
POST revisión: 202 /revisiones/1
POST revisión: 202 /revisiones/2
$ for i in 1 2; do curl -o /dev/null -w 'DELETE: %{http_code}\n' -X DELETE http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'; done
DELETE: 204
DELETE: 410
$ for i in 1 2; do curl -o /dev/null -w 'POST con llave: %{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: ejercicio-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'; done
POST con llave: 201 /servicios/7
POST con llave: 201 /servicios/7
GET: безопасен и идемпотентен.200и двата пъти, същиятETag: нищо не се е променило.PUTс*: идемпотентен.200и двата пъти, а съдържанието остава същото ("limite_ms":1500), ноETagсе смени от"1-2-…"на"1-3-…":revisorвдига версията при всеки запис. Това казва 6.2.3: поисканият ефект е същият, а сървърът може да води собствена история.PUT /mantenimiento: идемпотентен.204и двата пъти; режимът остава изключен.POSTза проверка: не е идемпотентен. Две проверки,/revisiones/1и/revisiones/2.DELETE: идемпотентен.204и410; услугата остава изведена от поддръжка и в двата случая.POSTс ключ: методът не е идемпотентен, но ключът прави така, че повторението на тази заявка да има същия ефект като еднократното ѝ изпълнение, докато ключът е валиден.201и двата пъти, същиятLocation.
Ако си сбъркал при 2, това е най-честият капан: еднакъв отговор не доказва нищо, а различен ETag не нарушава идемпотентността.
Решение 2
Чака три пъти, а не четири: между един опит и следващия има три паузи, а след четвъртия няма какво да се чака. Таваните са 250 × 2^n с горна граница 2000, за n, равно на 1, 2 и 3: 500, 1000 и 2000 ms. Най-много се отказва след 3,5 секунди, като се съберат таваните; с границата от 2000 ms за всяка заявка — общо 11,5 секунди.
Накрая отпечатва „se agotaron los 4 intentos sin una respuesta definitiva“ и излиза с код 1. С ключ добавя „repetir más tarde con la llave … es seguro“; с --sin-llave вместо това — „no se sabe si el alta ocurrió“, защото без ключ няма повторен опит, който да може да се предложи като безопасен. А изчакванията сменят текста си, от „con la misma llave“ (със същия ключ) на „sin llave (podría duplicar)“ (без ключ (може да създаде дубликат)), но не и продължителността си: jitter не зависи от ключа. Двата изхода са в agotados.salida.txt, който е показан в 6.5.3.
Редът стои преди изчакването, защото онзи, който решава дали да заспи, трябва да знае дали има за какво да се събуди. Ако беше след него, програмата щеше да спи до 2000 ms повече, за да открие, че вече няма опити, а скрипт с четиринайсет неуспешни регистрации щеше да загуби по този начин до 28 секунди. revisarConReintentos решава същото по друг начин: изчакването му е в тялото на for, преди следващата заявка, а това тяло не се изпълнява, когато intento < INTENTOS престане да е вярно.
Трите отговора накрая, със симулирания сървър: 422 е 4xx, който повторението не оправя, така че една заявка и код 1. 503:1 201 иска да се изчака една секунда (по-малко от горната граница от 5 s): две заявки, втората секунда по-късно, и код 0. 409:en-curso 201 е 409, който се повтаря, със случайно избрано изчакване: две заявки и код 0. Ако си отговорил, че 422 се повтаря или че 409:en-curso е окончателен, прегледай отново таблицата от 6.3.2 и type на 409.
Решение 3
Пълната версия е в ejercicios/ej3/idempotencia.ts:
// ejercicios/ej3/idempotencia.ts — solución del ejercicio 3: Idempotency-Key con una vigencia publicada.
// OJO: Idempotency-Key NO es un estándar. Es un borrador de la IETF
// (draft-ietf-httpapi-idempotency-key-header-07, que venció el 18 de abril de 2026 sin publicarse
// como RFC). El revisor lo usa como un patrón que su API documenta, con los códigos que propone
// el borrador: 422 si la llave se reutiliza con otra petición, 409 si la original sigue en curso.
import { createHash } from "node:crypto";
export type Grabada = { codigo: number; cabeceras: Record<string, string>; cuerpo: unknown };
type Registro = { huella: string; grabada: Grabada | null; desde: number };
// Por credencial y llave. Una API real las guarda en su base y las borra tras un plazo (p. ej. 24 h).
const registros = new Map<string, Registro>();
// Cuánto vive una llave después de su primer uso. Corta a propósito, para verla vencer en el ejercicio;
// se puede cambiar al arrancar: REVISOR_VIGENCIA_MS=86400000 node servidor.ts (24 horas).
const VIGENCIA_MS = Number(process.env.REVISOR_VIGENCIA_MS ?? 5000);
export function huella(datos: unknown): string {
return createHash("sha256").update(JSON.stringify(datos)).digest("hex");
}
export type Turno =
| { tipo: "nueva" }
| { tipo: "repetir"; grabada: Grabada }
| { tipo: "otra-peticion" }
| { tipo: "en-curso" };
// Decide qué hacer con una llave. Si es nueva, la aparta (en curso) hasta que se grabe la respuesta.
export function tomarTurno(credencial: string, llave: string, huellaPeticion: string): Turno {
const id = `${credencial}:${llave}`;
const registro = registros.get(id);
// Una llave vencida se olvida: la petición se atiende como nueva, sin repetir nada.
if (registro === undefined || Date.now() - registro.desde > VIGENCIA_MS) {
registros.set(id, { huella: huellaPeticion, grabada: null, desde: Date.now() });
return { tipo: "nueva" };
}
if (registro.huella !== huellaPeticion) return { tipo: "otra-peticion" };
if (registro.grabada === null) return { tipo: "en-curso" };
return { tipo: "repetir", grabada: registro.grabada };
}
export function grabar(credencial: string, llave: string, grabada: Grabada): void {
const registro = registros.get(`${credencial}:${llave}`);
if (registro !== undefined) registro.grabada = grabada;
}
// Si la operación falla sin crear nada, la llave se libera para que el reintento pueda intentarlo.
export function soltar(credencial: string, llave: string): void {
registros.delete(`${credencial}:${llave}`);
}
Същественото са два реда: записът пази desde: Date.now(), а tomarTurno третира като нов ключ, чийто запис е по-стар от VIGENCIA_MS. Изпълнението:
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: vence-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/7
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: vence-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/7
$ sleep 6
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: vence-1' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200"}'
{"type":"https://revisor.example/problemas/nombre-repetido","title":"Ya existe un servicio con ese nombre","status":409,"detail":"ya existe un servicio llamado avisos","instance":"/servicios","existente":"/servicios/7"}
[409]
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: vence-2' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/8
$ curl -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:18080/servicios/8 -H 'Authorization: Bearer escritura-a'
204
$ sleep 6
$ curl -o /dev/null -w '%{http_code} %header{location}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'Idempotency-Key: vence-2' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200"}'
201 /servicios/9
--- terminal del revisor:
revisor en http://127.0.0.1:18080
POST /servicios
POST /servicios
idempotencia: la llave vence-1 ya se atendió; repito su respuesta
POST /servicios
POST /servicios
DELETE /servicios/8
POST /servicios
Първи експеримент: 201, повторен 201 (в срока на валидност) и шест секунди по-късно 409 nombre-repetido. Ключът е изтекъл, revisor е обслужил регистрацията като нова и я е спряло правилото за уникално име. Без това правило щеше да създаде още една avisos.
Втори експеримент: 201 с идентификатор 8, извеждането от поддръжка и шест секунди по-късно 201 с идентификатор 9. Ключът беше изтекъл, а името вече беше свободно, така че повторният опит създаде нова услуга. Това е точно границата от 6.7.1: след срока на валидност повторният опит вече не е защитен. В рамките на петте секунди същата заявка щеше да получи записания 201 с идентификатор 8. Ако твоят API публикува срок на валидност от 24 часа, клиентите ти трябва да знаят, че повторен опит на следващия ден може да създаде дубликат.
Решение 4
$ for ruta in codigo/504 cortar codigo/500; do curl -o /dev/null -w "%{http_code} %header{location}\n" -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d "{\"nombre\":\"e-${ruta#*/}\",\"url\":\"http://127.0.0.1:18081/$ruta\"}"; done
201 /servicios/7
201 /servicios/8
201 /servicios/9
$ for id in 7 8 9; do curl -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$id/revisiones -H 'Authorization: Bearer escritura-a'; done; curl -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18080/servicios/5/revisiones -H 'Authorization: Bearer admin'
202 202 202 202
$ sleep 8
$ for i in 1 2 3 4; do curl http://127.0.0.1:18080/revisiones/$i -H 'Authorization: Bearer escritura-a'; done
{"id":1,"servicio":7,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":504,"ms":1,"detalle":"504"}}
{"id":2,"servicio":8,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":null,"ms":null,"detalle":"respuesta inválida"}}
{"id":3,"servicio":9,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":500,"ms":2,"detalle":"500"}}
{"id":4,"servicio":5,"estado":"terminada","resultado":{"estado":"CAIDO","codigo":null,"ms":null,"detalle":"tiempo agotado"}}
--- terminal del laboratorio:
laboratorio en http://127.0.0.1:18081/codigo/NNN
GET /codigo/504
GET /cortar
GET /codigo/500
GET /lento/3000
GET /cortar
GET /codigo/504
GET /codigo/504
GET /cortar
GET /lento/3000
GET /lento/3000
/codigo/504 се появява три пъти: 504 може да се оправи сам и не носи Retry-After. /cortar се появява три пъти: лабораторията затваря връзката, без да отговори, заявката завършва като respuesta-invalida, а това също се повтаря (онзи, който прави заявката, няма доказателство, че услугата е счупена завинаги). /codigo/500 се появява веднъж: revisor не повтаря 500. А /lento/3000 се появява три пъти: всеки опит изтича след 1000 ms.
Детайлите са тези от последния опит: 504, respuesta inválida, 500 и tiempo agotado. Осемте секунди са заради reportes: три опита до 1000 ms всеки плюс две изчаквания до 500 и 1000 ms, плюс секундата, която всяка проверка чака в опашката, правят до пет секунди и половина. С четири секунди резултатът на reportes все още може да е pendiente.
Как да разбера, че съм успял
- Казваш по памет кои методи са безопасни и кои идемпотентни според RFC 9110 и защо
POSTиPATCHне са. - Повторен
DELETEдава204и после410и обясняваш защо остава идемпотентен; повторенPUTсъс същияIf-Matchдава200и после412, а при повторно прочитане намираш своята промяна. -
node fig06_02.tsпоказва хилядата повторни опита в един-единствен прозорец без jitter и разпределени в пет с jitter. - Проверка на услуга, която отговаря
502, извиква лабораторията три пъти; проверка наpagos(503сRetry-After: 120) и проверка на404я извикват само веднъж. - Два
POST /serviciosсъс същияIdempotency-Keyдават същия201и същияLocation, а в списъка има една-единствена услуга; с друго тяло дават422llave-reutilizada; едновременно —409llave-en-curso. -
node alta-segura.ts agenda http://127.0.0.1:18081/codigo/200дава502и после201; с--sin-llave—502и после409сexistente. - При
422alta-segura.tsправи една-единствена заявка и излиза с1; с503:2 201в симулирания сървър чака 2 s и излиза с0; с409:en-curso 201опитва отново и излиза с0, а сmudoиALTA_LIMITE_MS=300прекъсва четири опита по време и излиза с1. - При спрян
revisorшлюзът отговаря502и продължава да слуша;alta-segura.tsчака три пъти (до 500, 1000 и 2000 ms), казва „se agotaron los 4 intentos“ и излиза с код1; без шлюз четирите му опита казватsin respuesta (ECONNREFUSED)и няма стек на Node. - Обясняваш, с изпълнение за всяко нещо, колко трае един ключ в
revisor, защо полетата в друг ред дават422и защо записаният отговор може да сочи услуга, изведена от поддръжка. - Казваш, че
Idempotency-Keyе проект на IETF с изтекъл срок, а не стандарт, и какво трябва да публикува API, който го използва. -
node fig06_03.tsдава5 de 5, а срещуdefectos/sin-huella/дава4 de 5. - Накрая не остава нито една програма, която слуша:
curl -i http://127.0.0.1:18080/,curl -i http://127.0.0.1:18081/иcurl -i http://127.0.0.1:18082/дават грешка 7.
Обобщение
Отговори, без да поглеждаш урока:
- Какво е безопасен метод и какво е идемпотентен? Кои са те според RFC 9110?
- Какво обещава идемпотентността и какво не обещава? Дай пример за всяко с
revisor. - Кои са двата въпроса преди повторен опит? Защо
revisorне повтаря500? - Какъв е таванът на изчакването за опит
nс основа 250 ms и горна граница 2000? Какво добавя пълният jitter и защо? - Какво прави
revisor, когато една услуга отговориRetry-After: 120? Защо не изчаква 5 s и не опитва отново? - Какво означава
502за онзи, който е направилPOST? Какво може да направи без ключ и какво — с ключ? - В какво състояние е
Idempotency-Keyв IETF? Какви кодове предлага проектът и за кой случай е всеки от тях? - Защо ключът се създава веднъж за всяко намерение, а не по един за всеки опит?
- Назови четири неща, които ключът на
revisorне покрива. - Ако четирите опита на
alta-segura.tsсе провалят, колко изчаквания прави, какво казва накрая и с какъв код излиза? Защо не чака след последния опит? - Кога
alta-segura.tsизлиза с0? Какво прави сRetry-Afterот 2 s и с такъв от 120 s? Какво отличава409, при който опитва отново, от онзи, при който не опитва?
Източници
Всички са консултирани на 21 октомври 2026 г.
- RFC 9110, HTTP Semantics, раздел 9.2.1 (юни 2022 г., интернет стандарт): безопасните методи, примерът с дневника на достъпа и отговорността на онзи, който проектира URI.
- RFC 9110, раздел 9.2.2: идемпотентните методи, автоматичното повторение след повреда в комуникацията, „though the response might differ“, клиентът, който не бива да повтаря неидемпотентни методи, проксито, което не трябва да го прави, и повторният опит на повторен опит.
- RFC 9110, раздели 9.3.3, 9.3.4 и 9.3.5:
POST,PUTиDELETE. - RFC 9110, раздел 10.2.3:
Retry-After, двата му вида и значението му при503. - RFC 9110, раздели 15.6.3, 15.6.4 и 15.6.5:
502,503и504. - RFC 6585, Additional HTTP Status Codes, раздел 4:
429и неговиятRetry-After. - RFC 5789, PATCH Method for HTTP, раздел 2:
PATCHне е нито безопасен, нито идемпотентен. - The Idempotency-Key HTTP Header Field, draft-ietf-httpapi-idempotency-key-header-07 (J. Jena и S. Dalal, 15 октомври 2025 г., проект на IETF, изтекъл на 18 април 2026 г.): синтаксис (2.1), уникалност (2.2), валидност (2.3), отпечатък (2.4), отговорности (2.5), какво да се прави (2.6), грешките
400,409и422(2.7), реализации (4) и сигурност (5). - Datatracker на IETF, страница на проекта: статус „Expired Internet-Draft“, работна група
httpapi, ревизия 07. - RFC 9651, Structured Field Values for HTTP, раздел 3.3.3 (септември 2024 г., заменя RFC 8941, който цитира проектът): Strings, ограничени с двойни кавички.
- RFC 9562, Universally Unique IDentifiers (UUIDs) (май 2024 г., заменя RFC 4122, който цитира проектът): форматът, който проектът препоръчва за ключа.
- Marc Brooker, Exponential Backoff And Jitter, AWS Architecture Blog (4 март 2015 г.): пълният jitter, „равният“ и „декорелираният“, сравнени.
- Timeouts, retries, and backoff with jitter, Amazon Builders' Library (Marc Brooker, 2019 г.): повторните опити като нещо „егоистично“, усилването 243 пъти с пет слоя, повторни опити само в една точка, кофата с жетони (token bucket) за ограничаване на повторните опити и идемпотентността с жетони от клиента.
- Google, Site Reliability Engineering, „Addressing Cascading Failures“: случайно експоненциално отлагане, ограничение на повторните опити за всяка заявка, бюджет за повторни опити и примерът с 64 опита при три слоя.
- Stripe, Idempotent requests: API, който прилага модела в голям мащаб: ключове до 255 знака, препоръчан UUID v4, без лични данни, изтриване след 24 часа, сравнение на параметрите и резултати, които не се записват, когато валидацията се провали.
- Node.js, Command-line API,
--unhandled-rejections=mode: във версия 24 режимът по подразбиране еthrow: отхвърлен promise, който никой не хваща, се вдига като неприхванато изключение и процесът завършва; това би се случило с шлюз, който не хваща грешката на свояfetch(6.5.1). - MDN,
AbortSignal.timeout(): връща сигнал, който прекратява сам, когато времето изтече; грешката еTimeoutError. Това е границата за всеки опит вalta-segura.ts(6.5.2). - RFC 9112, HTTP/1.1, раздел 9.6: затварянето на етапи (полузатворена връзка, продължаване на четенето, пълно затваряне) и
RST, който може да изтрие последния отговор; това правиcerrarConCalmaвrespuestas.ts.
Термини в този урок
| безопасен метод | метод, който по своята семантика не иска никаква промяна на състоянието в сървъра: GET, HEAD, OPTIONS, TRACE |
| идемпотентен метод | метод, който, когато се повтори, има същия целен ефект като при еднократно изпълнение: безопасните, PUT и DELETE |
| повторен опит (retry) | повторение на заявка, която се е провалила, по решение на клиента |
| експоненциално отлагане (exponential backoff) | двойно по-дълго изчакване (до горна граница) преди всеки повторен опит |
| пълен jitter | случаен избор на изчакването между 0 и тавана на отлагането, за да не опитват клиентите отново заедно |
| усилване | умножаването на натоварването, когато няколко слоя повтарят на своя глава |
| ключ за идемпотентност | идентификаторът, който клиентът измисля веднъж за всяка операция, за да може сървърът да разпознае повторните му опити |
| отпечатък | обобщението на тялото, което сървърът пази заедно с ключа, за да знае дали повторният опит е същата заявка |
| съгласуване | повторно прочитане на състоянието на сървъра, за да се разбере дали една несигурна операция се е случила |
Предпочитате имейл? Пишете ни на hola@habil.mx