Съдържание на курса

Урок 8 — Проверимият контракт

От · основател и главен изпълнителен директор на Hábil ·

Вторник, 10:05, и пристига първото известие от екипа по интеграции на друга фирма, онази, която от три седмици изгражда програмата си срещу API-то на revisor. Казват, че клиентът им е полудял: повтаря заявката на всеки петдесет милисекунди, без да спира, срещу режима за поддръжка. Някой от твоя екип поглежда дневниците и не намира нищо странно. Режимът за поддръжка отговаря 503, както винаги. Тестовете на екипа минаха снощи, всичките в зелено, и таблото за наличност е зелено. В 10:20 идва второто известие: програмата им вече не разпознава грешките. Очаквала е application/problem+json, а получава application/json, така че клонът, който чете type, не се изпълнява и пада в онзи, който повтаря вслепено. В 10:40 трето: 503 вече не носи Retry-After и програмата им, без това число, ползва стойността си по подразбиране, която е почти нищо.

Причината се появява в 11:00, при преглед на онова, което е било качено в понеделник. Някой е направил „почистване“ на централния обработчик на грешки: унифицирал е типа на съдържанието на всички отговори и мимоходом е махнал една заглавка, която му се е сторила излишна. Нито един от кодовете не се е променил. Един 503 си остава 503; един 404 — 404. А тъй като тестовете на екипа питаха само за кода, никой не го забеляза. Променило се е всичко останало, което API-то е обещавало в отговорите си: типът на съдържанието, заглавката за изчакване, начинът, по който една машина решава какво да прави. И това обещание не е било записано никъде, където една програма да може да го сравни с действителността.

Това обещание е онова, което изгражда този урок: описание на API-то във формат, който машините четат (OpenAPI), и една матрица, която с една-единствена команда прави 23 заявки, гледа какво API-то наистина отговаря и го сравнява с обещаното, включително заглавките, типа на съдържанието и формата на тялото. Ако някой пак направи почистване като това от понеделник, матрицата почервенява още в понеделник, а не във вторник в 10:05.

Време: 2 × 45 мин (прекъсването е между раздели 8.7 и 8.8)

Какво изграждаш: описанието на revisor в OpenAPI 3.1 (openapi.json), с всеки маршрут, всяка грешка и всяка заглавка; матрицата от 23 сценария (matriz23.ts), която стартира сървъра, преминава през по един сценарий на код и спира сама; същата матрица, написана с curl (fig08_03.sh); програма, която кръстосва каталога на типовете проблеми с документа (fig08_02.ts); и завършека на защитата срещу DNS rebinding в cliente.ts, със симулация, която го доказва (fig08_04.ts), и контрапроверка на сертификата (fig08_07.ts); и програмите, с които се измери една проверка, която понякога се проваляше (fig08_05.ts за надпревара в мрежата, fig08_06.ts за времето за стартиране на един процес и fig08_08.sh за повтаряне на матрицата)

Какво научаваш: какво е описание в OpenAPI и как се чете на пет части (info, servers, security, paths и components); как да документираш всяка грешка с преизползваеми отговори, схемата Problema и заглавките, които всеки код налага; как да провериш, че каталогът на типовете и документът казват едно и също; какво проверява една матрица и какво не, и как се доказва, че един тест „хапе“, като отмениш поправката и го видиш в червено; разликата между валиден документ и верен документ; DNS rebinding и защо свързването към вече проверения адрес го затваря; и как в OpenAPI се документира обявеното оттегляне на една версия

Откъде идваш. От Урок 7, с цял revisor в двете му лица: като сървър произвежда 23-те кода с техните Problem Details, а като клиент решава, с една таблица, дали да продължи, да поправи, да се удостовери наново, да изчака, да съгласува или да се откаже. От Урок 5 наследяваш каталога на типовете проблеми (problemas.ts) и програмата с тринайсетте проверки, която обяви тази матрица; от Урок 6 — заглавката Idempotency-Key, която тук се документира с предупреждението, че не е стандарт, и проверителя от пет теста, който матрицата повтаря; от Урок 3 — предупрежденията Deprecation и Sunset на версия 1; от Урок 2 — решението, че формата на API-то не е тайна, защото ще бъде в документ, който всеки може да прочете; и от Урок 1 — границата, която остана заявена: DNS rebinding, чиято пълна защита идва днес.

Какво не прави този урок. Не генерира клиенти, нито сървъри от документа: документът се пише на ръка и се проверява срещу сървъра, което е избор на курса, а не единственият възможен. Не сравнява тялото на всеки отговор с неговата JSON схема поле по поле: матрицата проверява, че кодът, заглавките, типът на съдържанието и основните членове на тялото са налице, а упражнение 2 я разширява с една стъпка. Не публикува страница с документация от документа (съществуват визуализатори, които го правят, а курсът не ги ползва). Не описва удостоверяване с OAuth, нито други схеми: удостоверенията на revisor са за упражнение, а документът ги обявява като bearer. И не обещава, че зелена матрица означава API без недостатъци: раздел 8.14 казва какво не вижда.

За изпълненията. Linux Mint 22 е референтната система на курса. Изходите на този урок са получени на 21 октомври 2026 г. под macOS, с Node 24.14.1 (версия 24 LTS, с която се изпълнява целият курс; има по-нови версии 24.x и изходите са от тази) и curl 8.7.1. Валидаторът на OpenAPI от раздел 8.11 е Redocly CLI 2.57.0 (публикуван на 30 септември 2026 г.), инсталиран с npm install в отделна папка, защото е инструмент за преглед, а не зависимост на програмите, които продължават да нямат никаква. Redocly записва част от изхода си в стандартния изход за грешки и, ако има нова версия, отпечатва кутия с известие; затова изпълненията му са с 2>&1 и променливата REDOCLY_SUPPRESS_UPDATE_NOTICE=true, които скриват само тази кутия. Проверката на типовете ползва TypeScript 7.0.2, както в предишните уроци. Докато се пишеше този урок, други тестове заемаха портовете на курса, така че сериите се изпълниха с копие на програмите, идентично освен портовете (всяка серия със своите, проверени като свободни преди изпълнението), а в изходите, поставени тук (и в техните файлове .salida.txt), отново бяха записани 18080, 18081 и 18082, които са тези, които ще въведеш ти. Те са числа със същия брой цифри, така че нито един Content-Length не се променя. При твоето изпълнение ще се променят датата в Date, последните осем цифри на всеки ETag, измерените милисекунди (ms), времето, което измерва Redocly, и urn:uuid: на грешките 500. Тестовете от раздел 8.12, които питат https://example.com, https://expired.badssl.com и https://wrong.host.badssl.com, се нуждаят от интернет; ако машината ти няма, пропусни тази серия и останалите вървят по същия начин. Цялата матрица отнема около 16 секунди на лаптоп. При изпълнение на матрицата десетки пъти поред се откри надпревара между затварянето на една връзка и изпращането на едно тяло; раздел 8.14 разказва как се откри, как се измери и как се затвори, с числата отпреди и от след това. Програмите са в programas/08-contrato-verificable/, в хранилището на курса: цял revisor във fig08_01/, кръстосването на каталога във fig08_02.ts, матрицата с curl във fig08_03.sh, симулацията на DNS rebinding във fig08_04.ts, контрапроверката на сертификата във fig08_07.ts, измерванията от раздел 8.14.1 във fig08_05.ts, fig08_06.ts и fig08_08.sh, нарочните дефекти в defectos/, решенията на упражненията в ejercicios/ и всяко изпълнение в своя файл .salida.txt. Всички минават tsc в строг режим (раздел 8.16).

След урока ще можеш да

  • Четеш документ OpenAPI 3.1 и определяш петте му основни части, и обясняваш какво прави една препратка $ref и защо отговорите с грешки се пишат веднъж и се преизползват.
  • Документираш цял отговор с грешка: неговия код, типа му на съдържанието application/problem+json, схемата Problema и заглавката, която този код налага (Allow при 405, Retry-After при 429 и 503, WWW-Authenticate при 401).
  • Изпълняваш програма, която кръстосва каталога на типовете проблеми с документа, и обясняваш защо днес открива една дупка и как се затваря.
  • Изпълняваш матрицата от 23 сценария с една команда, четеш я ред по ред и казваш какво се проверява във всеки сценарий: код, заглавки, тип на съдържанието, тяло, течове в 5xx и присъствие в документа.
  • Предизвикваш нарочно две повреди, една в сървъра и една в документа, и четеш матрицата в червено; и обясняваш защо един външен валидатор може да каже „валиден“ пред документ, който лъже.
  • Обясняваш какво е DNS rebinding, защо проверката преди всяка връзка не го затваря напълно, и показваш с изпълнение, че клиентът на урока стига до правилния сървър, докато такъв, който ползва fetch, стига до грешния.
  • Документираш в OpenAPI обявеното оттегляне на една версия, с deprecated: true и заглавките Deprecation, Sunset и Link.
  • Казваш с измерени примери какво матрицата не може да види и я разширяваш с една стъпка, за да го види.

Защо, преди как

Едно API е обещание, дадено на хора, които не са в стаята. Който го е написал, знае какво прави; който го ползва, знае само онова, което са му казали. Между двете има документ и този документ има вроден недостатък: той е от хартия. Пише се веднъж, чете се внимателно веднъж и оттогава нататък кодът се променя всеки ден, а документът се променя само когато някой се сети. С времето документът престава да описва API-то и започва да описва какво е било API-то, когато някой се е сетил да го обнови.

Има две лекарства и този урок събира двете. Първото е документът да е четим за машини: ако е файл със структура, една програма може да го прочете, а онова, което една програма може да прочете, една програма може да сравни. Второто е това сравнение да се изпълнява само, при всяка промяна, с една команда, и да се проваля със съобщение, което казва кое обещание е нарушено. Документ, който се сравнява ежедневно с действителността, много прилича на тест; тест, който се пише по документ, много прилича на обещание. Оттам идва името на урока: един проверим контракт е такъв, който може да се провери, без да искаш позволение от никого.

Този ред има неудобно следствие, което трябва да се каже от самото начало: има три места, където може да стои лъжата. Може да е в сървъра, който се е променил, а документът не (сцената от началото). Може да е в документа, който казва нещо, което сървърът никога не е правил. И може да е в теста, който, зелен или червен, не гледа онова, което има значение. Един валидатор на OpenAPI може да каже само дали документът е добре написан; не знае нищо за сървъра. Един тест за кодове гледа само кодовете. Матрицата от този урок гледа трите места едновременно и въпреки това има слепи петна, които раздел 8.14 изброява, защото една проверка, която не казва какво не вижда, има същия проблем като документа от хартия.

Урокът има пет части:

  1. Документът (8.1 до 8.5): какво е OpenAPI, как се чете, как се документират грешките и как се кръстосва каталогът на типовете с написаното.
  2. Матрицата (8.6 до 8.9): как е направена, какво проверява във всеки сценарий, как се изпълнява и нейната версия с curl.
  3. Тестът да „хапе“ (8.10 и 8.11): как се доказва, че една проверка открива онова, което твърди, че открива, и защо един валиден документ не е същото като верен документ.
  4. Дупката, останала от Урок 1 (8.12): DNS rebinding и неговото затваряне.
  5. Версии и граници (8.13 до 8.16): обявеното оттегляне на една версия в документа, онова, което матрицата не обещава, цял revisor и проверката на типовете.

Концепциите

8.1 revisor на този урок

8.1.1 Какво се променя

revisor от Урок 7 получава два нови файла, matriz23.ts и openapi.json, и променя три: cliente.ts, destinos.ts и respuestas.ts. Всичко останало е от предишните уроци във вече действащата си версия, а маршрутите, кодовете, заглавките и имената на полетата не се променят.

  • openapi.json (нов) е описанието на API-то в OpenAPI 3.1.1: всеки маршрут, всеки метод, всеки отговор (успехи и грешки), схемите на телата и заглавките. Той е центърът на раздели 8.3 до 8.5.
  • matriz23.ts (нов) стартира лабораторията и revisor, преминава през 23 сценария (по един на код на курса) и осемнайсет допълнителни проверки, и ги спира. Вместо да чака фиксирано време след стартирането на всеки процес, чака портът да приема връзки, а ако някой порт вече е зает, не измерва нищо и го казва. Излиза с код 0, ако всичко е минало, и с 1, ако нещо се е провалило (2, ако не е могла да започне, защото някой порт е бил зает). Той е центърът на раздели 8.6 до 8.10.
  • cliente.ts се променя в consultar и получава две помощни функции, soloA и pedir: вместо fetch, ползва http.request или https.request и се свързва към адреса, който destinos.ts вече е проверил. Това е раздел 8.12.
  • respuestas.ts се променя в една-единствена функция: когато тялото надхвърли тавана, 413 излиза веднага и връзката се затваря на етапи (първо само записът; сървърът продължава да чете и да изхвърля каквото пристигне и разрушава връзката след 2 секунди). Това е историята на раздел 8.14.1.
  • destinos.ts се променя в едно: revisarDestino вече връща, заедно с „да“, адресите, които е проверил ({ permitido: true, direcciones }), за да може който се свързва, да ползва един от тях и да не пита отново DNS.

Изходите на consultar не се променят: пак има същите пет вида резултат (отговор, изтекло време, без връзка, невалиден отговор и забранено местоназначение), същата класификация в ARRIBA, LENTO и CAIDO и същите кодове във всеки отговор на сървъра. Затова revisar.ts, tabla23.ts и por-type.ts, трите програми от Урок 7, дават същото като преди.

8.1.2 Да подредиш папката и да провериш, че работи

revisor живее в ~/curso-http/fig08_01/; раздел 8.15 носи всичките му файлове. С инсталиран Node 24 и от тази папка една-единствена линия проверява, че всичко е на мястото си:

$ cd ~/curso-http/fig08_01
$ node matriz23.ts

Матрицата сама стартира лабораторията и revisor, така че не ти трябват отделни терминали. Нужно ѝ е обаче портовете 18080, 18081 и 18082 да са свободни. Ако някой е зает, матрицата го казва и излиза с 2, без да измерва нищо; най-честата причина е revisor от предишен урок, който си оставил отворен: спри тази програма с Ctrl+C и започни отново. Раздел 8.7 изпълнява матрицата и чете изхода ѝ ред по ред.

8.2 Контракт, който може да се провери

8.2.1 Какво се обещава в един отговор

Преди да отворим документа, струва си да преброим какво обещава един отговор, защото документът трябва да го казва всичко, а тестът трябва да го проверява всичко. Един HTTP отговор обещава четири неща:

  1. Кодът на състоянието. Това е първото, което чете всяка машина по пътя. Това е, което Урок 0 нарече „кодът командва“.
  2. Заглавките, които този код налага или които дизайнът е решил. Един 405 налага да се изпрати Allow (RFC 9110, раздел 15.5.6: «The origin server MUST generate an Allow header field in a 405 response»). Един 401 носи WWW-Authenticate. Един 201 носи Location и, в това API, ETag. Един 429 или един 503 носят Retry-After, което раздел 15.6.4 на същия RFC допуска при 503 с «MAY». Тази разлика има значение: онова, което RFC оставя по избор, това API реши да го изпраща във всеки отговор, който му го позволява, и го обещава в своя документ.
  3. Типът на съдържанието. Една грешка е application/problem+json; един успех с тяло е application/json; 204 и 304 не носят тяло. Типът на съдържанието е онова, което казва на една програма как да чете следващото.
  4. Формата на тялото. В един Problem Details — петте члена (type, title, status, detail, instance) и, според типа, разширения като errors или existente.

Сцената от началото наруши две от четирите (заглавката и типа на съдържанието) и остави недокосната първата, която беше единствената, която някой проверяваше. Това е обичаен модел: тества се онова, което е лесно за тестване, а кодът на състоянието е най-лесният. Една пълна проверка трябва да се издигне от лесното към онова, което има значение.

8.2.2 Трите места, където може да е лъжата

Ако документът, сървърът и тестът са три различни неща, има три начина едно обещание да се наруши, без никой да го види.

  • Сървърът се променя, а документът — не. Това е сцената от началото. API-то прави нещо различно от онова, което документът казва. Който е прочел документа и е написал клиента си по него, остава с програма, която работи срещу API, което вече не съществува.
  • Документът казва нещо, което сървърът никога не е правил. Това е случаят, за който се говори най-малко и който е най-често срещан в документи, писани на ръка: документира се онова, което са мислели да направят, или онова, което е направено в друг клон, или онова, което е копирано от друго API. Сървърът никога не връща този 429 или тази заглавка. Който се доверява на документа, пише код за случай, който не съществува, и не пише този, който съществува.
  • Тестът не гледа онова, което има значение. Един тест в зелено казва, че онова, което е гледал, е наред. Не казва нищо за онова, което не е гледал, а раздел 8.14 (точка 2) и упражнение 2 предизвикват този случай с измерен пример: матрицата, която вече съществува, със сървър, на когото липсва един от петте члена във всички грешки, дава 40 от 41 и почти не забелязва.

Затова този урок не се ограничава с писането на документа. Пише го, сравнява го със сървъра и после отменя сървъра и документа нарочно, за да види, че сравнението почервенява. Една проверка, която никога не е била виждана да се проваля, не доказва нищо: същото би дала зелено, дори да е развалена.

8.2.3 Защо OpenAPI

За да сравнява една програма, документът трябва да е във формат със структура. Най-разпространеният формат за описване на HTTP API е OpenAPI, публична спецификация на OpenAPI Initiative. Версията, която ползва курсът, е 3.1.1, публикувана на 24 октомври 2024 г. (spec.openapis.org). Съществуват още пачът 3.1.2 и линията 3.2 (3.2.0 и 3.2.1), които са на страницата с версиите. Курсът остава на 3.1.1, защото всичките му цитати на раздели и всичките му изходи са взети срещу нея; не се провери кои инструменти вече разбират 3.2, а смесването на версии в урок, който цитира номера на раздели, би било източник на грешки. Ако започваш нов документ, виж първо коя от трите разбира твоят инструмент. Един документ OpenAPI може да се пише в JSON или в YAML, а курсът ползва JSON, защото Node го чете без никаква зависимост.

Версия 3.1 донесе промяна, която личи в документа на revisor: изравнява схемата на данните с JSON Schema 2020-12. Раздел 4.8.24 на спецификацията казва, че обектът схема на OpenAPI е надмножество на тази версия на JSON Schema. На практика, когато едно поле може да е нулево, вече не се пише nullable: true, както в 3.0, а се пише списък от типове: "type": ["object", "null"]. Ще го видиш в схемата Resultado, която е null, докато една проверка е изчакваща (записът в блога на OpenAPI за прехода от 3.0 към 3.1 разказва за тази и други промени).

8.3 OpenAPI в пет части

Един документ OpenAPI от 1 191 реда стряска, но се чете като книга с пет глави. Следващото ги преминава с отворен openapi.json на revisor до тебе; цитатите и номерата на раздели са от спецификацията 3.1.1.

8.3.1 Част 1: openapi и info

Документът започва с четири полета, които се четат с един поглед (openapi, info, servers и security):

  "openapi": "3.1.1",
  "info": {
    "title": "API del revisor",
    "version": "1.0.0",
    "summary": "Contrato verificable del curso de HTTP de Hábil.",
    "description": "info.version es la versión de ESTE documento, no la de la API. La API vigente vive en la raíz (/servicios); la v1 está desaprobada y la v0 retirada.",
    "license": {
      "name": "CC BY-SA 4.0",
      "identifier": "CC-BY-SA-4.0"
    }
  },
  "servers": [
    {
      "url": "http://127.0.0.1:18080"
    }
  ],
  "security": [
    {
      "credencial": []
    }
  ],
  "paths": {

Този последен ред отваря част 4, която виждаме в 8.3.4. Предишното са части 1, 2 и 3.

openapi казва коя версия на спецификацията следва документът: 3.1.1. Това е първото, което чете всеки инструмент, защото от него зависи как да тълкува останалото.

info описва документа, и тук има клопка, която заслужава собствен ред. Полето info.version звучи като „версията на API-то“ и не е такова: раздел 4.8.2.1 на спецификацията го определя като версията на документа OpenAPI (която е различна от версията на спецификацията), а раздел 4.8.1.1 уточнява, че няма връзка с полето openapi. revisor има едно действащо API (това в корена, /servicios), една версия 1, обявена за остаряла (deprecated), и една версия 0, оттеглена (премахната); ако info.version беше „версията на API-то“, коя от трите би била? Затова description на документа го казва с всички букви: info.version е версията на този документ. Това е видът пояснение, който спестява половинчасов спор с този, който го чете за първи път.

license казва с какъв лиценз се публикува документът. Този, който ползва курсът, е CC BY-SA 4.0, а полето identifier ползва идентификатора на SPDX, CC-BY-SA-4.0.

8.3.2 Част 2: servers

"servers": [{ "url": "http://127.0.0.1:18080" }]

Това е списъкът с адресите, където живее API-то. Ако полето липсва, спецификацията казва (раздел 4.8.1.1), че стойността по подразбиране е един сървър с url /, тоест същото място, от което се сервира документът. revisor го пише цял, защото в курса документът пътува във файл и не се сервира отникъде.

Обърни внимание на онова, което в този списък няма: никакъв вътрешен адрес на фирма и никакво име на машина. Един документ OpenAPI почти винаги завършва публикуван; всичко, което напишеш в servers, ще го прочете всеки. За курса е 127.0.0.1:18080, което не е ничия машина.

8.3.3 Част 3: security и securitySchemes

Списъкът security по-горе казва, че освен ако една операция не каже друго, всички искат схема на име credencial. Това име се определя по-долу, в components:

    "securitySchemes": {
      "credencial": {
        "type": "http",
        "scheme": "bearer"
      }
    },

Това са три реда с много съдържание: схемата е от тип http, а нейната схема (scheme) е bearer, този, който revisor ползва с Authorization: Bearer lectura-a. Защо Authorization не се документира като параметър-заглавка, както се прави с Idempotency-Key? Защото спецификацията не го взема предвид: в раздел 4.8.12.2.1 казва, че ако един параметър е в заглавката и се казва Accept, Content-Type или Authorization, определението се игнорира. Трите заглавки имат собствен механизъм, а тази на удостоверението е този.

Има маршрути, които не искат удостоверение: старите пренасочвания (/viejo/servicios, /api/servicios) и оттеглената версия (/v0). На тях се пише "security": [] в операцията. Един празен списък значи „никаква“ и така е записано в спецификацията (раздел 4.8.10.1): «To remove a top-level security declaration, an empty array can be used.» Това е начинът да се разкаже в документа онова, което Урок 2 разказа с едно изречение: маршрутът и методът са преди удостоверението.

8.3.4 Част 4: paths

Тук е тялото на документа. paths е обект с един запис на маршрут; всеки маршрут има един запис на метод (спецификацията допуска осем: get, put, post, delete, options, head, patch и trace, в раздел 4.8.9.1) и всеки метод описва една операция. revisor има дванайсет маршрута и седемнайсет операции. Ето една цялостна, най-късата от тези с грешки, тази, която премахва една услуга:

      "delete": {
        "summary": "Retirar un servicio",
        "responses": {
          "204": {
            "description": "Retirado; sin cuerpo."
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "403": {
            "$ref": "#/components/responses/E403"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          }
        }
      }

Чете се отгоре надолу:

  • summary: едно изречение за този, който чете документа. Никоя машина не го ползва, за да реши нещо.
  • responses: картата на онова, което тази операция може да отговори, с кода като ключ. Ключовете са в кавички, защото в JSON всеки ключ е текст, а спецификацията (раздел 4.8.16.2) го казва така: като име може да се ползва всеки код и само по едно свойство на код. Обектът трябва да носи поне един отговор (раздел 4.8.16: «MUST contain at least one response code»).
  • "204": единственият отговор за успех. Един отговор трябва да носи description; това е единственото задължително поле на един отговор (раздел 4.8.17.1) и затова дори 204, който не връща тяло, го носи.
  • Останалите отговори са { "$ref": "…" }, което се обяснява в 8.4. Засега е достатъчно да се знае, че всеки сочи място в components, където е записан само веднъж.

Тази операция не казва нищо за маршрута си. Параметърът {id} е в записа на маршрута, едно ниво по-горе, защото го споделят GET, PUT и DELETE:

    "/servicios/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {

Параметърът id е in: path, задължителен е и е цяло число, по-голямо или равно на 1. Спецификацията допуска четири места за един параметър (query, header, path и cookie, в раздел 4.8.12.2.1); revisor ползва path за id и header за If-Match, If-None-Match и Idempotency-Key.

8.3.5 Част 5: components

Това е складът на частите, които се преизползват. Има пет чекмеджета в този документ: securitySchemes (вече го видя), parameters (ключът Idempotency-Key), headers (девет заглавки с описанието им), responses (осемнайсет преизползваеми отговора с грешки) и schemas (телата). Една част се ползва от всяка точка на документа с една препратка: обект { "$ref": "#/components/responses/E401" }. Текстът след диеза е пътят вътре в същия документ: components, после responses, после E401.

Защо се пази там, а не се пише там, където се ползва? Защото документът на revisor има седемнайсет операции и 79 препратки към осемнайсетте отговора с грешки. Ако всеки се пишеше цял, 401 би се появил 12 пъти със заглавката си WWW-Authenticate и би стигнало някой да поправи 11 от 12, за да си противоречи документът сам на себе си. С препратката 401 се поправя веднъж. Това е същият аргумент като в Урок 5: каталогът в problemas.ts е една-единствена таблица, а не двайсет и четири изречения, пръснати навсякъде.

Едно тънко място при препратките. Във версия 3.1 един обект препратка може да носи също summary и description, които по подразбиране заместват тези на препратената част (раздел 4.8.23.1). revisor не я ползва: всичките му препратки са чисти $ref, така че онова, което казва преизползваемият отговор, е онова, което чете всеки.

8.4 Документиране на всяка грешка

8.4.1 Един цял преизползваем отговор

Ето отговорът на 401, така както живее в документа:

      "E401": {
        "description": "Falta una credencial válida (type credencial-invalida).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "WWW-Authenticate": {
            "$ref": "#/components/headers/WWW-Authenticate"
          }
        }
      },
      "E403": {

Има три части и трите са обещания, които матрицата ще провери.

description разказва кога се случва и казва type, който носи. Това е текст за хора и е единствената част, където документът назовава type (повече за това в 8.5).

content е карта, чиито ключове са типове на съдържанието. Ключът е application/problem+json, не application/json, и това е точно обещанието, което разруши „почистването“ от сцената в началото. Отдолу schema сочи схемата Problema (виждаш я в 8.4.3).

А headers казват кои заглавки придружават този отговор. Тук има класическа клопка и спецификацията я назовава: ако един отговор определя заглавка на име Content-Type, това определение се игнорира (раздел 4.8.17). Типът на съдържанието не се документира като заглавка, документира се като ключ на content. Затова при 401 се появява само WWW-Authenticate, която е тази, която налага кодът, а типът на съдържанието е на друго място.

8.4.2 Заглавките, които всеки код налага или обещава

Документът на revisor декларира заглавки в шест от осемнайсетте отговора. Таблицата показва кои и откъде идва всяка:

Отговор Заглавка Кой я изисква
E401 (401) WWW-Authenticate RFC 9110, раздел 15.5.2 («MUST send a WWW-Authenticate header field»), и схемата Bearer от RFC 6750; Урок 2: предизвикателството към удостоверението
E405 (405) Allow RFC 9110, раздел 15.5.6: «MUST generate an Allow header field»
E415 (415) Accept Урок 2: кои типове се допускат
E429 и E429Salud (429) Retry-After Урок 4: колко да се чака
E503 (503) Retry-After RFC 9110, раздел 15.6.4: сървърът «MAY» я изпраща, а това API реши да я изпраща

Обърни внимание на разликата между последните два реда и предишните. 405 налага заглавката си: без Allow отговорът нарушава RFC. 503 само я допуска: друго API би могло да отговори 503 без Retry-After и пак да спазва RFC. Но това API, на revisor, я изпраща във всеки 503 и това е собствено обещание, което документът записва и което този, който го интегрира, ще ползва, за да чака както трябва. Сцената от началото наруши точно едно собствено обещание, не такова от RFC; затова матрицата проверява двата вида със същата строгост.

8.4.3 Схемата Problema

Тялото на всички грешки е един Problem Details (Урок 5) и се пише само веднъж:

      "Problema": {
        "type": "object",
        "description": "Problem Details (RFC 9457). type es el identificador estable; title y detail son para personas.",
        "required": [
          "type",
          "title",
          "status",
          "detail",
          "instance"
        ],

Това са петте члена, които това API обещава във всяка грешка (required). Трябва да се каже внимателно: RFC 9457 (раздел 3.1) не изисква петте, а type е about:blank, ако липсва. Да ги искаш всичките е по-строго обещание от това на RFC и затова документът го записва, а матрицата го проверява. Следва списъкът properties, който включва петте плюс двете разширения, които каталогът ползва: errors (редовете на 422, всеки с pointer) и existente (маршрутът на услугата, която вече съществува, в 409 за повторено име). И затваря с един ред, който прилича на недоглеждане, а е решение: "additionalProperties": true. RFC 9457 позволява да се добавят членове-разширения към един Problem Details; схема, която ги забранява, би казала нещо невярно за API-то, щом нов тип проблем добави такъв. Да ги оставиш отворени е честно: схемата изброява тези, които се ползват днес, без да затваря вратата на утрешните.

Типът на съдържанието application/problem+json е регистриран в IANA с RFC 9457 като препратка (регистър); той е ключът на content във всеки отговор с грешка.

8.4.4 Къде се побира онова, което не е грешка: успехи, празни тела и 405 на PATCH

Успехите се документират със същата грижа като грешките, защото и един клиент изгражда неща по тях. 201 на POST /servicios декларира двете си заглавки, Location и ETag, и схемата Servicio. 304 и 204 декларират своя description и никакво content, а това отсъствие е обещание: не носят тяло и матрицата го проверява, като пита дали тялото е пристигнало празно.

Остава един случай, който спецификацията не позволява да се напише така, както би искал човек: 405 на PATCH. Никой маршрут на revisor не допуска PATCH, а OpenAPI описва операции, които съществуват; да декларираш patch в един маршрут, за да документираш, че не се допуска, би накарало документа да каже обратното (че има PATCH). Изходът на документа е да постави 405 в операциите, които съществуват (GET, PUT и DELETE на /servicios/{id}) и да каже там, че всеки друг метод получава този отговор, със заглавката си Allow. Матрицата ползва операцията GET на този маршрут за сценария на 405 (така го декларира в полето operacion, раздел 8.6), а упражнение 3 те кара да го изпробваш.

8.4.5 Заглавката, която не е стандарт

Idempotency-Key се документира като преизползваем параметър:

      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Patrón documentado por esta API, NO un estándar: draft-ietf-httpapi-idempotency-key-header-07 venció el 18 de abril de 2026 sin publicarse como RFC.",
        "schema": {
          "type": "string",
          "minLength": 1
        }
      }

description прави онова, което Урок 6 каза, че този урок ще направи: предупреждава, че това е модел на API-то и не е стандарт, и защо. Черновата на IETF, на която се основава, draft-ietf-httpapi-idempotency-key-header-07, изтече на 18 април 2026 г., без да бъде публикувана като RFC. required: false е другата половина на обещанието: който не я изпрати, пак бива обслужен, както преди Урок 6. Един документ, който казва просто Idempotency-Key без предупреждението, би обещавал, без да иска, че всяко друго API я разбира по същия начин.

8.5 Каталогът на типовете проблеми, кръстосан с документа

Урок 5 обеща, че каталогът на типовете може да се „чете, преглежда и документира наведнъж“. Има евтин начин да се провери, че документът изпълнява: да се вземе каталогът (CATALOGO в problemas.ts, с 24 типа, откакто Урок 6 добави два) и за всеки да се попита документът за две неща: декларира ли някоя операция неговия код? назовава ли документът неговия type? Това прави fig08_02.ts. Не му трябва никакъв сървър: чете два файла.

// fig08_02.ts — ¿el documento cuenta todo lo que el servidor puede decir? Cruza el catálogo de tipos de
// problema (problemas.ts) con openapi.json. No necesita ningún servidor: solo lee dos archivos.
// Uso: node fig08_02.ts          (desde la carpeta que contiene fig08_01/)
import { readFileSync } from "node:fs";
import { CATALOGO } from "./fig08_01/problemas.ts";

type Documento = { paths: Record<string, Record<string, { responses?: Record<string, unknown> }>> };

const texto = readFileSync(new URL("./fig08_01/openapi.json", import.meta.url), "utf8");
const documento = JSON.parse(texto) as Documento;

// Todos los códigos que alguna operación del documento declara.
const documentados = new Set<string>();
for (const ruta of Object.values(documento.paths)) {
  for (const operacion of Object.values(ruta)) {
    for (const codigo of Object.keys(operacion.responses ?? {})) documentados.add(codigo);
  }
}

console.log("clave                     código  type                  ¿código en el documento?  ¿type en el documento?");
let huecos = 0;
for (const [clave, { type, status }] of Object.entries(CATALOGO)) {
  // Con about:blank no hay nombre propio que buscar: el type es el mismo para todos esos errores.
  const nombre = type === "about:blank" ? null : type.split("/").at(-1) ?? "";
  const codigoDocumentado = documentados.has(String(status));
  const typeDocumentado = nombre === null ? null : texto.includes(nombre);
  if (!codigoDocumentado || typeDocumentado === false) huecos++;
  console.log(
    `${clave.padEnd(25)} ${String(status).padEnd(7)} ${(nombre ?? "about:blank").padEnd(21)} ` +
      `${(codigoDocumentado ? "sí" : "NO").padEnd(25)} ${typeDocumentado === null ? "(no aplica)" : typeDocumentado ? "sí" : "NO"}`,
  );
}
console.log();
console.log(`${Object.keys(CATALOGO).length} tipos en el catálogo; ${huecos} con un hueco en el documento`);
process.exitCode = huecos === 0 ? 0 : 1;

Чети го на блокове:

  • Редовете за четене. import { CATALOGO } носи таблицата на сървъра; readFileSync чете документа два пъти, като текст (за да търси имена) и вече превърнат в обект (за да обхожда кодовете). Същата информация, погледната от два ъгъла, нарочно.
  • documentados е множеството на всички кодове, които някоя операция декларира. Трите вложени for обхождат маршрутите, операциите и ключовете на всеки responses.
  • Основният цикъл обхожда 24-те записа на каталога. За всеки изчислява nombre, което е последната част на type (servicio-inexistente), освен при about:blank, който няма собствено име: той е един и същ за всички тези грешки.
  • Дупката се отчита, ако липсва кодът или ако има име и текстът на документа не го съдържа.
  • Накрая изходният код е 0 само ако няма дупки. Това позволява да се ползва в скрипт или в стъпка на непрекъсната интеграция.

Изпълни го от папката, която съдържа fig08_01/:

$ node fig08_02.ts
clave                     código  type                  ¿código en el documento?  ¿type en el documento?
ruta-inexistente          404     about:blank           sí                        (no aplica)
metodo-no-admitido        405     about:blank           sí                        (no aplica)
tipo-no-admitido          415     about:blank           sí                        (no aplica)
cuerpo-demasiado-grande   413     about:blank           sí                        (no aplica)
cuerpo-ilegible           400     cuerpo-ilegible       sí                        sí
credencial-invalida       401     credencial-invalida   sí                        sí
permiso-insuficiente      403     permiso-insuficiente  sí                        sí
servicio-inexistente      404     servicio-inexistente  sí                        sí
revision-inexistente      404     revision-inexistente  sí                        NO
nombre-repetido           409     nombre-repetido       sí                        sí
servicio-retirado         410     servicio-retirado     sí                        sí
version-retirada          410     version-retirada      sí                        sí
etiqueta-vencida          412     etiqueta-vencida      sí                        sí
destino-prohibido         422     destino-prohibido     sí                        sí
datos-invalidos           422     datos-invalidos       sí                        sí
llave-en-curso            409     llave-en-curso        sí                        sí
llave-reutilizada         422     llave-reutilizada     sí                        sí
falta-condicion           428     falta-condicion       sí                        sí
demasiadas-revisiones     429     demasiadas-revisiones sí                        sí
demasiadas-consultas      429     demasiadas-consultas  sí                        sí
error-interno             500     error-interno         sí                        sí
respuesta-invalida        502     respuesta-invalida    sí                        sí
en-mantenimiento          503     en-mantenimiento      sí                        sí
servicio-lento            504     servicio-lento        sí                        sí

24 tipos en el catálogo; 1 con un hueco en el documento
(código de salida: 1)

В нито един от редовете не се появява NO, освен в един: revision-inexistente, и това не е повреда на програмата: дупката съществува. За да я видиш с очите си, поискай от сървъра онова, което документът обещава за този маршрут, и го сравни с онова, което наистина става в съседния маршрут:

$ curl -i http://127.0.0.1:18080/revisiones/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 177
Date: Wed, 21 Oct 2026 22:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"type":"https://revisor.example/problemas/revision-inexistente","title":"La revisión no existe","status":404,"detail":"no existe la revisión 99","instance":"/revisiones/99"}

$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 22:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"type":"https://revisor.example/problemas/servicio-inexistente","title":"El servicio no existe","status":404,"detail":"no existe el servicio 99","instance":"/servicios/99"}

Сравни двата отговора. GET /revisiones/99 отговаря 404, чийто type е revision-inexistente. GET /servicios/99 отговаря с друг 404, с type servicio-inexistente. Но документът има един-единствен отговор 404 за двата маршрута, E404, и неговото description казва „type servicio-inexistente или about:blank“. За маршрута на проверките това изречение е невярно: който прочете документа, ще очаква type, който този маршрут никога не връща, и няма да знае, че съществува този, който връща.

Защо матрицата не го видя? Защото сценарият на 404 иска услуга, която не съществува (/servicios/99), и проверява само че 404 е декларирана в тази операция. Никой не попита /revisiones/{id} за неговия type. Това е общ урок: една проверка, която не гледа един случай, не знае за този случай, а кръстосването от този раздел намери онова, което матрицата, по замисъл, не гледа. Да го поправиш е упражнение 1.

Трите слепи петна на това кръстосване. Първо, търси името където и да е в текста на документа: ако revision-inexistente се появи в описанието на друг маршрут, кръстосването би казало „да“, дори /revisiones/{id} да си остане погрешен. Второ, за типовете с about:blank не проверява нищо за името им, защото нямат такова. Трето, не гледа дали status на каталога съвпада с маршрута, където се документира. Това е евтина мрежа за безопасност, не пълен тест; матрицата от раздел 8.6 е тази, която гледа действителните отговори.

8.6 Матрицата от 23 сценария: как е направена

matriz23.ts има около 680 реда (wc -l дава 676) и значителна част от тях са данни, а не логика. Това е първото проектантско решение и си струва да се види преди кода: 23-те сценария не са 23 функции, а 23 реда на една таблица и една-единствена функция ги обхожда. Да добавиш сценарий е да добавиш ред; да промениш какво се проверява е да промениш една функция, не 23.

8.6.1 Един сценарий е стойност

Всеки ред има формата на този тип:

type Escenario = {
  codigo: number;
  nombre: string;
  operacion: [string, string]; // método y ruta tal como los documenta openapi.json
  antes?: Peticion[];
  peticion: Peticion;
  cabeceras: string[];
  tipo: "application/json" | "application/problem+json" | null; // null: sin cuerpo
  type?: string;
  revisarCuerpo?: (cuerpo: Record<string, unknown>) => string | null;
};

Поле по поле:

  • codigo и nombre: очакваният код и едно изречение за реда на изхода.
  • operacion: методът и маршрутът така, както ги пише openapi.json. Не са тези на заявката: заявката може да е GET /servicios/99, а операцията ["get", "/servicios/{id}"], защото в документа маршрутът носи параметъра във фигурни скоби. С това поле матрицата знае къде да търси в документа дали кодът е декларирен.
  • antes (по избор): заявките, които трябва да се направят преди тази, която се измерва, за да се остави сървърът в състоянието, в което кодът се случва. За 429 са нужни три предишни проверки; за 503 — включване на поддръжката.
  • peticion: тази, която се измерва. Метод, маршрут, заглавки и тяло.
  • cabeceras: тези, които отговорът трябва да носи. Списък с имена, а сравнението не различава главни и малки букви (HTTP заглавките не ги различават).
  • tipo: очакваният тип на съдържанието или null, ако не бива да има тяло.
  • type: при грешките — type на Problem Details.
  • revisarCuerpo: незадължителна функция за онова, което е специфично за този сценарий, като „разширението existente сочи към /servicios/2“.

Два реда, за да се видят в действие. Този на 429:

  { codigo: 429, nombre: "demasiadas revisiones", operacion: ["post", "/servicios/{id}/revisiones"],
    antes: [1, 2, 3].map(() => ({ metodo: "POST", ruta: "/servicios/2/revisiones", cabeceras: ESCRIBE })),
    peticion: { metodo: "POST", ruta: "/servicios/2/revisiones", cabeceras: ESCRIBE },
    cabeceras: ["retry-after"], tipo: "application/problem+json", type: PROBLEMA + "demasiadas-revisiones" },

Три предишни проверки и четвъртата е тази, която се измерва. Очаква заглавката retry-after, типа на съдържанието application/problem+json и type demasiadas-revisiones. И този на поддръжката:

  { codigo: 503, nombre: "mantenimiento", operacion: ["get", "/servicios"],
    antes: [{ metodo: "PUT", ruta: "/mantenimiento", cabeceras: { Authorization: "Bearer admin", ...JSON_ }, cuerpo: '{"activo":true}' }],
    peticion: { metodo: "GET", ruta: "/servicios", cabeceras: LEE }, cabeceras: ["retry-after"], tipo: "application/problem+json", type: PROBLEMA + "en-mantenimiento" },

Преди да поиска списъка, включва поддръжката с един PUT /mantenimiento, направен от admin. Обърни внимание, че operacion на този сценарий е ["get", "/servicios"], а не /mantenimiento: 503 се документира в операциите, които могат да го върнат, и матрицата търси кода там, където един клиент би го видял.

8.6.2 Редът има значение и е записан

Сценариите споделят един-единствен сървър, който пази състоянието си в паметта, така че редът, в който се изпълняват, е част от теста. Списъкът на матрицата спазва четири зависимости и трябва да се знаят, защото са тези, които се чупят първи, когато някой пренареди:

  • 201 добавя една услуга, която получава идентификатор 7 (началните са шест). 204 я изважда, а след това 410 я иска отново и намира, че е изведена. Ако 410 се изпълнеше преди 204, би дал 200.
  • 500 ползва услуга 4, тази без приключени проверки: това е нарочният дефект от Урок 4 и живее до този урок тъкмо за да има 500, който да се предизвика.
  • 503 включва поддръжката в своето antes, а 504 я изключва в своето. Ако никой не я изключеше, всичко следващо би дало 503.
  • 304 се нуждае от действащия ETag на услуга 1, който се променя при всяко стартиране. Матрицата го чете веднъж в началото, преди първия сценарий, и го пази в SIN_CAMBIOS.

8.6.3 Функцията, която проверява

Функцията comprobar получава един сценарий и връща списък с повреди; празният списък значи, че е минал. Ето я цялата:

async function comprobar(e: Escenario): Promise<string[]> {
  const fallas: string[] = [];
  for (const previa of e.antes ?? []) await (await enviar(previa)).text();
  const respuesta = await enviar(e.peticion);
  const texto = await respuesta.text();
  if (respuesta.status !== e.codigo) fallas.push(`código ${respuesta.status}, se esperaba ${e.codigo}`);
  for (const cabecera of e.cabeceras) if (!respuesta.headers.has(cabecera)) fallas.push(`falta ${cabecera}`);
  const tipo = respuesta.headers.get("content-type");
  if (e.tipo === null) {
    if (texto !== "") fallas.push("trae cuerpo y no debía");
  } else if (tipo !== e.tipo) {
    fallas.push(`tipo ${tipo}, se esperaba ${e.tipo}`);
  } else {
    const cuerpo = JSON.parse(texto) as Record<string, unknown>;
    if (e.tipo === "application/problem+json") {
      if (cuerpo.type !== e.type) fallas.push(`type ${String(cuerpo.type)}, se esperaba ${e.type}`);
      if (cuerpo.status !== e.codigo) fallas.push("status del cuerpo distinto del código");
      if (typeof cuerpo.title !== "string") fallas.push("falta title");
    }
    const otra = e.revisarCuerpo?.(cuerpo) ?? null;
    if (otra !== null) fallas.push(otra);
  }
  if (e.codigo >= 500) for (const fuga of FUGAS) if (fuga.test(texto)) fallas.push(`el cuerpo filtra ${fuga}`);
  const [metodo, ruta] = e.operacion;
  if (documento.paths[ruta]?.[metodo]?.responses?.[String(e.codigo)] === undefined) {
    fallas.push(`openapi.json no documenta ${e.codigo} en ${metodo.toUpperCase()} ${ruta}`);
  }
  return fallas;
}

Следвай редовете в реда, в който се изпълняват:

  1. Предишните заявки. Изпращат се и тялото им се изхвърля с .text(), защото отговор, който не се прочете, оставя връзката заета.
  2. Измерваната заявка. Изпраща се с enviar, което ползва fetch с redirect: "manual". Тази опция има значение: един 301 или 308 трябва да се види като 301 или 308, а не като отговора на маршрута, при който fetch би стигнал сам. За да провериш едно пренасочване, не бива да го следваш.
  3. Кодът. Ако не е очакваният, добавя се една повреда.
  4. Заглавките. Всяко име от списъка трябва да е в отговора.
  5. Типът на съдържанието и тялото. Ако сценарият не очаква тяло, текстът трябва да е празен. Ако го очаква, типът трябва да съвпада; и само ако съвпада, се опитва да се прочете тялото като JSON. Ако сценарият е грешка, се проверяват три члена на Problem Details: type, status равен на кода и title от тип текст.
  6. Собствената проверка на сценария, ако има такава.
  7. Течовете, само при 5xx. Обясняват се по-долу.
  8. Документацията. Ако документът не декларира този код в тази операция, има повреда. Това е редът, който свързва матрицата с openapi.json.

Има едно решение в структурата, което не се забелязва, докато нещо не се счупи: функцията събира всички повреди и не спира на първата. При „почистването“ от сцената 503 се проваля по две причини (липсва retry-after и типът е application/json); тест, който спре на първата, би казал едната, който я поправи, ще види другата при следващото изпълнение и ще загуби още един цикъл. Това е същата политика като 422 от Урок 3, който събира всички повреди на тялото.

8.6.4 Течовете

Грешките на сървъра са тези, които най-много склонят да кажат повече от нужното. Един зле обработен 500 може да върне стека на изключението, а стекът носи пътища на файлове, имена на функции и понякога данни. Урок 4 научи да се казва в един 5xx само една препратка; матрицата го следи със списък от шаблони, които не бива да се появяват в тялото на един 5xx:

const FUGAS = [/\bat \S+ \(/, /file:\/\//, /\/(home|Users)\//, /node:internal/, /\bSELECT\b.+\bFROM\b/i, /Bearer \S+/, /TypeError|ReferenceError/];

Чети ги един по един:

Шаблон Какво улавя
\bat \S+ \( ред от стека на JavaScript: at nombre (archivo:línea)
file:// адрес на файл, като тези, които Node отпечатва в стековете
\/(home|Users)\/ папка на потребител на истинска машина
node:internal вътрешен кадър на Node
\bSELECT\b.+\bFROM\b SQL заявка
Bearer \S+ удостоверение
TypeError|ReferenceError името на изключение на JavaScript

Списъкът не е изчерпателен и не може да бъде: това е набор от сигнали за онова, което вече е виждано, а не гаранция, че няма други. Покрива най-честите от този вид сървър. Прилага се само при 5xx, защото там неочакваното се промъква; 404 или 422 ги пише централният обработчик с изречения от каталога.

8.6.5 Стартиране, измерване и спиране

Последното, което прави програмата, е да стартира онова, което ще измерва, и да го спре, каквото и да стане:

// Si algo ya escucha en los puertos del curso, la corrida mediría a otro programa (o esperaría para siempre a un
// hijo que no pudo arrancar): se detiene en seguida.
for (const puerto of [18080, 18081, 18082]) {
  if (await aceptaConexiones(puerto)) {
    console.error(`puerto ${puerto} ocupado; apaga lo que lo usa`);
    process.exit(2);
  }
}

const procesos: ChildProcess[] = [
  spawn(process.execPath, ["laboratorio.ts"], { cwd: import.meta.dirname, stdio: "ignore" }),
  spawn(process.execPath, ["servidor.ts"], { cwd: import.meta.dirname, stdio: "ignore" }),
];
let fallidos = 0;
let extra = 0;
let abortada = false;
try {
  await esperarPuerto(18081, procesos[0]);
  await esperarPuerto(18080, procesos[1]);
  SIN_CAMBIOS["If-None-Match"] = (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: LEE })).headers.get("etag") ?? "";
  for (const escenario of ESCENARIOS) {
    const fallas = await comprobar(escenario);
    if (fallas.length > 0) fallidos++;
    console.log(`${fallas.length === 0 ? "ok   " : "FALLA"} ${escenario.codigo} ${escenario.nombre}${fallas.length > 0 ? ": " + fallas.join("; ") : ""}`);
  }
  const resultados = await adicionales();
  extra = resultados.length;
  for (const [nombre, fallas] of resultados) {
    if (fallas.length > 0) fallidos++;
    console.log(`${fallas.length === 0 ? "ok   " : "FALLA"} ${nombre}${fallas.length > 0 ? ": " + fallas.join("; ") : ""}`);
  }
} catch (error) {
  // Un proceso no arrancó (o no contestó a tiempo): no hay resumen que dar, y se dice por qué.
  console.error(`no se pudo correr la matriz: ${error instanceof Error ? error.message : String(error)}`);
  abortada = true;
} finally {
  for (const proceso of procesos) proceso.kill();
}
if (abortada) process.exit(1);
const total = ESCENARIOS.length + extra;
console.log(`\n${total - fallidos} de ${total} comprobaciones pasaron (${ESCENARIOS.length} escenarios de código y ${extra} adicionales).`);
process.exitCode = fallidos === 0 ? 0 : 1;

Преди да стартира каквото и да е, матрицата проверява, че трите порта са свободни: ако някой отговаря, казва кой и излиза с 2, защото да измерваш срещу чужда програма е да измерваш онова, което не е твое (упражнението „Сървърът, който остана отворен“ го показва). Двата spawn стартират лабораторията и revisor като дъщерни процеси, със същата версия на Node, с която работи матрицата (process.execPath), а esperarPuerto (в началото на файла, обяснява се в 8.14.1) чака всеки да приема връзки, вместо да брои фиксирано време. Ако някой не отговори до 10 секунди, catch го казва и изпълнението свършва с 1 без резюме. try … finally е частта, която прави матрицата честна: каквото и да стане в проверките (изключение, повреда, процес, който не е стартирал), finally спира двата дъщерни процеса. Без него една грешка по средата би оставила два процеса, които слушат на портове 18080 и 18081, а следващото изпълнение би се провалило по причина, която няма нищо общо с онова, което се измерва.

Последният ред има значение за този, който ползва матрицата в скрипт: програмата излиза с 0, ако не е имало повреди, и с 1, ако е имало някоя. Това позволява да се постави в стъпка на непрекъсната интеграция и стъпката да почервенее. Обърни внимание и че ползва process.exitCode, а не process.exit(…): задава кода и оставя програмата да приключи сама, което позволява на дъщерните процеси и връзките да се затворят спокойно.

8.7 Изпълнение и четене

От папката fig08_01/, със свободни портове 18080, 18081 и 18082 (третият се ползва от шлюза на една от допълнителните проверки):

$ node matriz23.ts
ok    200 consultar un servicio
ok    304 consultar sin cambios
ok    201 dar de alta
ok    202 pedir revisión
ok    204 retirar
ok    301 ruta vieja con 301
ok    308 ruta vieja con 308
ok    400 cuerpo ilegible
ok    401 sin credencial
ok    403 lectura que intenta escribir
ok    404 servicio que no existe
ok    405 método no admitido
ok    409 nombre repetido
ok    410 servicio retirado
ok    412 etiqueta vencida
ok    415 formato no admitido
ok    422 datos imposibles
ok    428 cambio sin If-Match
ok    429 demasiadas revisiones
ok    500 el defecto a propósito
ok    502 el servicio responde basura
ok    503 mantenimiento
ok    504 el servicio no responde a tiempo
ok    misma Idempotency-Key: no duplica; con otro cuerpo, 422
ok    SSRF: http://169.254.169.254/latest/meta-data/ se rechaza con 422 destino-prohibido
ok    SSRF: http://localhost:18080/mantenimiento se rechaza con 422 destino-prohibido
ok    credencial heredada de Object.prototype (constructor, __proto__, toString): 401
ok    If-None-Match con lista, W/ y * (débil): 304; If-Match con W/: 412; con lista o *: 200
ok    actualización perdida: dos PUT a la vez con la misma If-Match: 200 y 412, gana la primera que escribe
ok    orden del PUT: text/plain da 415 con etiqueta vieja o sin ella; retirado en el hueco, 410
ok    esquema sin distinguir mayúsculas (bearer: 200); Basic: reto sin error="invalid_token"
ok    método heredado de Object.prototype (constructor, toString, __proto__): 405
ok    cuerpo JSON que no es objeto (null, 7, "texto", []): 400, y el revisor sigue vivo
ok    cuerpo de más de 16 KiB: 413 (con Content-Length y por partes); en el tope, 400; el revisor sigue vivo
ok    429 por credencial: la cuarta consulta en vivo, 429; otra credencial no lo paga; enumerar ids llega al 429
ok    Retry-After: 1.5, -1 y «12 30» se ignoran; las tres formas de HTTP-date dan lo mismo (GMT)
ok    SSRF: 12 direcciones de uso especial (CGNAT, NAT64, 6to4, compatible, 64:ff9b:1::/48, 240/4, 198.18/15, TEST-NET, 2001:db8::, Teredo, multicast): 422; 6to4 con IPv4 pública: 201
ok    la pasarela reenvía Content-Length; sin revisor responde 502 y sigue viva; alta-segura termina al agotar los intentos, también sin conexión
ok    alta-segura decide: un 4xx sale con 1; obedece Retry-After si es razonable y no insiste si pide mucho; se corta por tiempo ante un servidor mudo; el 409 de llave en curso se reintenta y el de nombre repetido no
ok    el banco responde el 205 sin cuerpo, con Content-Length: 0 y sin Transfer-Encoding
ok    ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)

41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 0)

Чети я на блокове.

Първите седем реда (200 до 308) са успехите и пренасочванията. Всеки е минал същия изпит като грешките, но в друга форма: 200 и 201 носят тяло application/json и заглавките etag (и location при 201); 304, 204, 301 и 308 не носят тяло и матрицата проверява, че текстът е празен. 301 и 308 са POST: това е двойката от Урок 1, със същата заявка и две пренасочвания, които се държат различно в клиента.

Следващите шестнайсет (400 до 504) са грешките. Всяка е проверила application/problem+json, type от каталога и, при пет от тях, една заглавка: www-authenticate при 401, allow при 405, accept при 415, retry-after при 429 и 503. 403 има собствена проверка, която вече се появи в Урок 2: тялото не повтаря удостоверението lectura-a. А 500 проверява instance да е urn:uuid:…, препратката, която клиентът може да даде на този, който експлоатира сървъра.

Последните осемнайсет не са кодове. Те са допълнителни проверки и раздел 8.8 ги обяснява.

Предпоследният ред сумира: 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales). А последният ред, (código de salida: 0), е този, който чете един скрипт.

Две подробности при четенето. Редът за SSRF казва http://localhost:18080/mantenimiento: матрицата се опитва да регистрира URL с име, което се разрешава към самата машина, и очаква 422 с типа destino-prohibido. И времето: изпълнението отнема около 16 секунди и значителна част са нарочни изчаквания: стартирането, бавната проверка на 504, повторните опити на alta-segura.ts и рестартиранията на revisor.

8.8 Другите осемнайсет проверки

Щом 23-те кода са зелени, матрицата преминава през още осемнайсет проверки. Всяка е родена от конкретен дефект, който предишните уроци измериха в свое време, и съществува, за да не се върне този дефект, без някой да разбере. Наричат се регресии: тестове на нещо, което вече веднъж е било поправено.

# Какво проверява От кой урок идва
1 Idempotency-Key: същият ключ не дублира; с друго тяло — 422 6
2 и 3 SSRF: два URL адреса (169.254.169.254 и localhost) се отхвърлят с 422 destino-prohibido 1
4 Удостоверение, което съществува само в Object.prototype (constructor, __proto__, toString), дава 401 2
5 Условни заявки: If-None-Match със списък, W/ и * дава 304; If-Match с W/ дава 412; със списък или * дава 200 1 и 3
6 Загубената актуализация: два едновременни PUT със същия If-Match, 200 и 412 3
7 Редът на PUT: text/plain дава 415 със стар етикет или без него; ако междувременно го изтрият, 410 3
8 Схемата bearer без разлика между главни и малки букви и предизвикателството на Basic без error="invalid_token" 2
9 Наследен метод (constructor, toString, __proto__) дава 405 1 и 2
10 JSON тяло, което не е обект (null, 7, "texto", []), дава 400, а revisor остава жив 1
11 Таванът от 16 KiB: 413 с Content-Length и на части; точно на тавана — 400 2
12 Ограничението по удостоверение: четвъртата проверка на живо, 429; друго удостоверение не го плаща; изброяването на идентификатори стига до 429 4
13 Строго Retry-After: 1.5, -1 и „12 30“ се игнорират; трите форми на дата дават същото 4
14 SSRF с дванайсет адреса със специално предназначение, отхвърлени; един 6to4 с публичен IPv4, приет 1
15 Шлюзът препраща Content-Length; без revisor отговаря 502 и остава жив; alta-segura.ts свършва, когато изчерпи опитите, също и без връзка 6
16 alta-segura.ts решава: един 4xx излиза с 1; подчинява се на Retry-After, ако е разумно, и не настоява, ако иска много; прекъсва се по време при ням сървър; 409 за ключ в ход се повтаря, а този за повторено име — не 6
17 ETag след рестартиране на revisor: старият вече не съвпада 1
18 Стендът за тестове отговаря 205 без тяло (RFC 9110, раздел 15.3.6), с Content-Length: 0 (без него 205 няма деклариран размер и би се четял, докато се затвори връзката: RFC 9112, раздел 6.3) и без Transfer-Encoding (решение на стенда: Node би изпратил chunked с празно тяло; RFC не го забранява при 205) 0

Три от тях имат техника, която си струва да се види, защото показват как се тества нещо, което не може да се предизвика с нормална заявка.

8.8.1 Загубената актуализация: тяло, което пристига на парчета

За да се докаже, че два едновременни PUT със същия етикет не се стъпкват, трябва да се постигне едното да чака тялото си, докато другото пише. Един нормален fetch изпраща заглавките и тялото заедно, така че няма пролука. Решението е тяло, което се предава на два пъти:

async function actualizacionPerdida(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const vigente = (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: ESCRIBE })).headers.get("etag") ?? "";
  const cabeceras = { ...ESCRIBE, ...JSON_, "If-Match": vigente };
  let soltar = (): void => {};
  const suelto = new Promise<void>((listo) => { soltar = listo; });
  // El primer pedazo sale enseguida (fetch no manda las cabeceras hasta tener algo del cuerpo); el resto,
  // hasta que la rápida terminó.
  const texto = JSON.stringify({ nombre: "catalogo", url: `${LAB}/codigo/200`, limite_ms: 1000 });
  const codificar = (pedazo: string): Uint8Array => new TextEncoder().encode(pedazo);
  const cuerpo = new ReadableStream<Uint8Array>({
    async start(control) {
      control.enqueue(codificar(texto.slice(0, 10)));
      await suelto;
      control.enqueue(codificar(texto.slice(10)));
      control.close();
    },
  });
  const lenta = fetch(R + "/servicios/1", { method: "PUT", headers: cabeceras, body: cuerpo, duplex: "half" } as RequestInit);
  await esperar(300);
  const rapida = await enviar({ metodo: "PUT", ruta: "/servicios/1", cabeceras,
    cuerpo: JSON.stringify({ nombre: "catalogo", url: `${LAB}/codigo/200`, limite_ms: 3000 }) });
  await rapida.text();
  soltar();
  const respuestaLenta = await lenta;
  await respuestaLenta.text();
  const final = (await (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: ESCRIBE })).json()) as { limite_ms?: number };
  if (rapida.status !== 200) fallas.push(`la rápida dio ${rapida.status}, se esperaba 200`);
  if (respuestaLenta.status !== 412) fallas.push(`la lenta dio ${respuestaLenta.status}, se esperaba 412`);
  if (final.limite_ms !== 3000) fallas.push(`el servicio quedó con limite_ms ${final.limite_ms}, se esperaba 3000 (el de la rápida)`);
  return ["actualización perdida: dos PUT a la vez con la misma If-Match: 200 y 412, gana la primera que escribe", fallas];
}

Тялото на бавната заявка е ReadableStream. Предава десет знака веднага (без това fetch не изпраща дори заглавките), а останалото — когато обещанието suelto се изпълни. Опцията duplex: "half" е задължителна, когато тялото е поток: спецификацията на fetch я изисква, а "half" е стойността, която се ползва в Node, и значи, че заявката се изпраща цялата, преди да се прочете отговорът. Без нея fetch отхвърля с TypeError: RequestInit: duplex option is required when sending a body. (измерено с Node 24.14.1). Междувременно бързата заявка променя услугата със същия етикет. Когато бързата свърши, вика се soltar() и бавната довършва изпращането на тялото си. Сървърът, който е сравнил етикета в началото, го сравнява отново при записа (Урок 3), намира го остарял и отговаря 412. Трите последни проверки затварят случая: бързата дава 200, бавната дава 412 и услугата остава със стойността на бързата.

Този тест почти не зависи от късмета. Би могло да се опита с два едновременни fetch и да се надяваме да съвпаднат, и би бил случай, който понякога се проваля, а понякога не. Тук единственото чакане по часовник са 300 ms, дадени на първото парче да пристигне; оттам нататък редът го налага програмата (soltar()), не случайността.

8.8.2 Наследеният метод: да почукаш на входната точка без сървър

Object.prototype има методи с имена като constructor или toString. Ако входната точка на revisor търсеше метода на една заявка в обикновен обект, constructor би се появил, сякаш е метод на маршрута. Поправката е да се търси с Object.hasOwn. Как се проверява, ако Node отговаря 400 на метод, който не познава, преди да извика revisor (може да се види с curl -X constructor)? Като се извика входната точка директно, без сървър:

async function metodoHeredado(): Promise<[string, string[]]> {
  const { atender } = await import("./rutas.ts");
  const fallas: string[] = [];
  // rutas.ts escribe cada petición en la terminal del revisor; aquí correría dentro de la matriz.
  const registrar = console.log;
  console.log = () => {};
  for (const method of ["constructor", "toString", "__proto__"]) {
    const peticion = { method, url: "/servicios/1", headers: { authorization: "Bearer escritura-a" } };
    const codigo = await new Promise<string>((listo) => {
      const respuesta = {
        req: peticion,
        statusCode: 0,
        writeHead(codigo: number) { this.statusCode = codigo; return this; },
        end() { listo(String(this.statusCode)); },
      };
      atender(peticion as unknown as IncomingMessage, respuesta as unknown as ServerResponse)
        .catch((error: unknown) => listo(`excepción ${String(error)}`));
    });
    if (codigo !== "405") fallas.push(`${method}: ${codigo}`);
  }
  console.log = registrar;
  return ["método heredado de Object.prototype (constructor, toString, __proto__): 405", fallas];
}

Функцията внася atender от rutas.ts и я вика със симулирани заявка и отговор: два обекта само с онова, което входната точка чете. Ако входната точка отговори с 405 (statusCode, който е получил writeHead), всичко е наред; ако хвърли изключение, обещанието на listo се разрешава с текста „excepción …“. Преди да извика, заглушава console.log, защото входната точка записва всяка заявка в терминала, а тук ще се изпълни вътре в матрицата; и го възстановява накрая. Това е модулен тест, вмъкнат в интеграционна матрица, и е наред: важното е да се изпълнява, а не името на вида тест.

8.8.3 ETag след рестартиране: спиране и стартиране

Последната техника е да се рестартира сървърът от самата матрица:

async function etiquetaTrasReinicio(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const B = { Authorization: "Bearer escritura-b" };
  const antes = (await enviar({ metodo: "GET", ruta: "/servicios/3", cabeceras: B })).headers.get("etag") ?? "";
  const viejo = procesos[1];
  const salida = new Promise((listo) => viejo.once("exit", listo));
  viejo.kill();
  await salida;
  procesos[1] = await arrancar("servidor.ts", 18080);
  const r = await enviar({ metodo: "GET", ruta: "/servicios/3", cabeceras: { ...B, "If-None-Match": antes } });
  await r.text();
  const despues = r.headers.get("etag");
  if (r.status !== 200) fallas.push(`If-None-Match: ${antes} tras reiniciar dio ${r.status}, se esperaba 200`);
  if (despues === antes) fallas.push(`la etiqueta se repitió: ${antes}`);
  return ["ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)", fallas];
}

Чете етикета на услуга 3, който никога не се променя, спира revisor, който самата тя е стартирала, стартира го отново и му иска услуга 3 със стария етикет. Ако revisor повтаряше етикета ("3-1" преди и "3-1" след), би отговорил 304, фалшив 304: клиентът би останал с копие отпреди рестартирането. С идентификатора на стартирането в етикета ("3-1-86b39f38" срещу друг от осем цифри) отговаря 200. Това е правилото на RFC 9110, раздел 8.8.1, което изисква силен етикет да не се преизползва за друго представяне.

8.9 Същото, с curl

Матрицата на TypeScript е тази, която се изпълнява при всяка промяна, но първият въпрос на този, който я чете, обикновено е: а как би изглеждало това, написано на ръка? fig08_03.sh отговаря на този въпрос. Това е същата идея от 23 сценария, написана така, както би я въвел в терминал: заявки с curl, един тип съдържание и една заглавка, които се очакват, и един брояч накрая. Стартира лабораторията и revisor сам и ги спира по PID накрая.

#!/usr/bin/env bash
# fig08_03.sh — los 23 códigos del curso con curl y nada más: la misma idea de matriz23.ts, escrita como la
# teclearías tú en una terminal. Levanta el laboratorio y el revisor, prueba, y los apaga por PID.
# Uso: bash fig08_03.sh            (desde la carpeta que contiene fig08_01/)
set -u
cd "$(dirname "$0")/fig08_01" || exit 2
R=http://127.0.0.1:18080
LAB=http://127.0.0.1:18081

# Si algo ya escucha en los puertos del curso, la corrida mediría a otro programa: se detiene.
for puerto in 18080 18081; do
  if (exec 3<>/dev/tcp/127.0.0.1/$puerto) 2>/dev/null; then echo "el puerto $puerto está ocupado; apaga lo que lo usa"; exit 1; fi
done

node laboratorio.ts > /dev/null 2>&1 &
PID_LAB=$!
node servidor.ts > /dev/null 2>&1 &
PID_REV=$!
CAB=$(mktemp)
trap 'kill $PID_LAB $PID_REV 2>/dev/null; rm -f "$CAB"' EXIT
sleep 1

total=0
bien=0

# probar CODIGO "nombre" TIPO CABECERA -- argumentos de curl
#   TIPO:     problema (application/problem+json) · json (application/json) · nada (sin cuerpo)
#   CABECERA: la cabecera obligatoria de esa respuesta, o «-» si no hay
probar() {
  local esperado=$1 nombre=$2 tipo=$3 cabecera=$4
  shift 4
  total=$((total + 1))
  local salida codigo bytes tipo_visto fallas=""
  salida=$(curl -sS -o /dev/null -D "$CAB" -w '%{http_code} %{size_download}' "$@")
  codigo=${salida% *}
  bytes=${salida#* }
  tipo_visto=$(grep -i '^content-type:' "$CAB" | tr -d '\r' | sed 's/^[^:]*: *//')
  [ "$codigo" = "$esperado" ] || fallas="$fallas código $codigo;"
  case "$tipo" in
    problema) [ "$tipo_visto" = "application/problem+json" ] || fallas="$fallas tipo «$tipo_visto»;" ;;
    json) [ "$tipo_visto" = "application/json" ] || fallas="$fallas tipo «$tipo_visto»;" ;;
    nada) [ "$bytes" = "0" ] || fallas="$fallas trae $bytes bytes de cuerpo;" ;;
  esac
  if [ "$cabecera" != "-" ]; then
    grep -qi "^$cabecera:" "$CAB" || fallas="$fallas falta $cabecera;"
  fi
  if [ -z "$fallas" ]; then
    bien=$((bien + 1))
    echo "ok    $esperado $nombre"
  else
    echo "FALLA $esperado $nombre:$fallas"
  fi
}

LEE='Authorization: Bearer lectura-a'
ESCRIBE='Authorization: Bearer escritura-a'
ADMIN='Authorization: Bearer admin'
JSON='Content-Type: application/json'

ETAG=$(curl -sS -o /dev/null -w '%header{etag}' "$R/servicios/1" -H "$LEE")

probar 200 "consultar un servicio"            json     etag     "$R/servicios/1" -H "$LEE"
probar 304 "consultar sin cambios"            nada     etag     "$R/servicios/1" -H "$LEE" -H "If-None-Match: $ETAG"
probar 201 "dar de alta"                      json     location -X POST "$R/servicios" -H "$ESCRIBE" -H "$JSON" -d "{\"nombre\":\"curl\",\"url\":\"$LAB/codigo/200\"}"
probar 202 "pedir revisión"                   json     location -X POST "$R/servicios/1/revisiones" -H "$ESCRIBE"
probar 204 "retirar"                          nada     -        -X DELETE "$R/servicios/7" -H "$ESCRIBE"
probar 301 "ruta vieja con 301"               nada     location -X POST "$R/viejo/servicios" -H "$ESCRIBE" -H "$JSON" -d '{"nombre":"viejo","url":"http://127.0.0.1:18081/codigo/200"}'
probar 308 "ruta vieja con 308"               nada     location -X POST "$R/api/servicios" -H "$ESCRIBE" -H "$JSON" -d '{"nombre":"api","url":"http://127.0.0.1:18081/codigo/200"}'
probar 400 "cuerpo ilegible"                  problema -        -X POST "$R/servicios" -H "$ESCRIBE" -H "$JSON" -d '{"nombre":'
probar 401 "sin credencial"                   problema www-authenticate "$R/servicios"
probar 403 "lectura que intenta escribir"     problema -        -X DELETE "$R/servicios/1" -H "$LEE"
probar 404 "servicio que no existe"           problema -        "$R/servicios/99" -H "$LEE"
probar 405 "método no admitido"               problema allow    -X PATCH "$R/servicios/1" -H "$ESCRIBE"
probar 409 "nombre repetido"                  problema -        -X POST "$R/servicios" -H "$ESCRIBE" -H "$JSON" -d "{\"nombre\":\"pagos\",\"url\":\"$LAB/codigo/200\"}"
probar 410 "servicio retirado"                problema -        "$R/servicios/7" -H "$LEE"
probar 412 "etiqueta vencida"                 problema -        -X PUT "$R/servicios/1" -H "$ESCRIBE" -H "$JSON" -H 'If-Match: "1-0"' -d "{\"nombre\":\"catalogo\",\"url\":\"$LAB/codigo/200\"}"
probar 415 "formato no admitido"              problema accept   -X POST "$R/servicios" -H "$ESCRIBE" -H 'Content-Type: text/plain' -d 'curl'
probar 422 "datos imposibles"                 problema -        -X POST "$R/servicios" -H "$ESCRIBE" -H "$JSON" -d '{"nombre":"","url":"ftp://x"}'
probar 428 "cambio sin If-Match"              problema -        -X PUT "$R/servicios/1" -H "$ESCRIBE" -H "$JSON" -d "{\"nombre\":\"catalogo\",\"url\":\"$LAB/codigo/200\"}"
for i in 1 2 3; do curl -sS -o /dev/null -X POST "$R/servicios/2/revisiones" -H "$ESCRIBE"; done
probar 429 "demasiadas revisiones"            problema retry-after -X POST "$R/servicios/2/revisiones" -H "$ESCRIBE"
probar 500 "el defecto a propósito"           problema -        "$R/servicios/4/informe" -H "$LEE"
probar 502 "el servicio responde basura"      problema -        "$R/servicios/6/salud" -H "$LEE"
curl -sS -o /dev/null -X PUT "$R/mantenimiento" -H "$ADMIN" -H "$JSON" -d '{"activo":true}'
probar 503 "mantenimiento"                    problema retry-after "$R/servicios" -H "$LEE"
curl -sS -o /dev/null -X PUT "$R/mantenimiento" -H "$ADMIN" -H "$JSON" -d '{"activo":false}'
probar 504 "el servicio no responde a tiempo" problema -        "$R/servicios/5/salud" -H "$LEE"

echo
echo "$bien de $total escenarios pasaron"
[ "$bien" = "$total" ]

Пет идеи от скрипта:

  • Входната врата. Преди да стартира каквото и да е, опитва да отвори връзка към всеки порт с /dev/tcp, възможност на bash. Ако някой отговори, портът е зает и скриптът отказва да продължи: да измерваш срещу програмата на друг е сигурен начин да видиш резултати, които не са твои.
  • probar. Това е функцията, която върши цялата работа. Получава очаквания код, име, вида на тялото (problema, json или nada), задължителната заглавка (или тире, ако няма) и аргументите на curl. Иска от curl да запази заглавките във файл (-D), да не отпечатва тялото (-o /dev/null) и да запише кода и размера на тялото (-w '%{http_code} %{size_download}'). После сравнява.
  • size_download. За да се знае, че един 204 или 304 не носи тяло, не е нужно да се вижда тялото: размерът на изтеглено трябва да е нула.
  • trap … EXIT. Това е finally на скрипта: спира лабораторията и revisor по PID и изтрива временния файл със заглавките, дори скриптът да свърши с грешка.
  • sleep 1. Това е същото фиксирано чакане, което раздел 8.14.1 замества в матрицата на TypeScript; тук се запазва заради простотата и е първото място, където този скрипт би се провалил на бавна машина.

Изпълни го от папката, която съдържа fig08_01/:

$ bash fig08_03.sh
ok    200 consultar un servicio
ok    304 consultar sin cambios
ok    201 dar de alta
ok    202 pedir revisión
ok    204 retirar
ok    301 ruta vieja con 301
ok    308 ruta vieja con 308
ok    400 cuerpo ilegible
ok    401 sin credencial
ok    403 lectura que intenta escribir
ok    404 servicio que no existe
ok    405 método no admitido
ok    409 nombre repetido
ok    410 servicio retirado
ok    412 etiqueta vencida
ok    415 formato no admitido
ok    422 datos imposibles
ok    428 cambio sin If-Match
ok    429 demasiadas revisiones
ok    500 el defecto a propósito
ok    502 el servicio responde basura
ok    503 mantenimiento
ok    504 el servicio no responde a tiempo

23 de 23 escenarios pasaron
(código de salida: 0)

Двайсет и три от двайсет и три и скриптът излиза с 0. Версията с curl е честна за онова, което проверява по-малко: гледа кода, типа на съдържанието, заглавката и, при успехите без тяло, размера. Не гледа type на тялото, нито status на тялото, нито течовете, нито че кодът е деклариран в документа. Нито изпълнява осемнайсетте допълнителни. Служи, за да се види скелетът на идеята и за да се провери бърза промяна без Node под ръка; пълната проверка е тази на TypeScript.

8.10 Тестът да „хапе“

Една проверка, която никога не е била виждана да се проваля, не доказва нищо: ще даде зелено, независимо дали работи, или е развалена. Затова няколко проверки на матрицата бяха изпробвани по два пъти, веднъж с поставената поправка и веднъж с отменената, и беше записано какво число дава матрицата във всеки случай (тези от този раздел, тази за 405 от упражнение 3, тази за тялото на части от 8.14.1 и тази за бавното стартиране от упражнение 6). Останалите нямат запазен отрицателен контрол и това е едно от нещата, които матрицата не обещава (раздел 8.14). Тук го правиш ти с два нарочни дефекта, един в сървъра и един в документа. И двата са в папката defectos/, с предупреждение на първия ред на файла.

8.10.1 „Почистеният“ сървър

Първият дефект е сцената от началото. Променя един-единствен файл, problemas.ts, централния обработчик на грешки. Променено е следното:

    // DEFECTO A PROPÓSITO: se quitó Retry-After (una «limpieza» de cabeceras) ...
    ...Object.fromEntries(Object.entries(opciones.cabeceras ?? {}).filter(([nombre]) => nombre.toLowerCase() !== "retry-after")),
    // ... y el tipo de contenido pasó a ser el genérico.
    "Content-Type": "application/json",

Това са две промени, всяка разумна, погледната сама. Първата маха една заглавка, която на някого му се е сторила излишна. Втората унифицира типа на съдържанието на всички грешки с този на успехите. Нито един код на състоянието не се променя. За да го предизвикаш, копирай папката, замени файла и изпълни матрицата:

$ cp -R fig08_01 rota
$ cp defectos/sin-problem-json/problemas.ts rota/problemas.ts
$ cd rota
$ node matriz23.ts
ok    200 consultar un servicio
ok    304 consultar sin cambios
ok    201 dar de alta
ok    202 pedir revisión
ok    204 retirar
ok    301 ruta vieja con 301
ok    308 ruta vieja con 308
FALLA 400 cuerpo ilegible: tipo application/json, se esperaba application/problem+json
FALLA 401 sin credencial: tipo application/json, se esperaba application/problem+json
FALLA 403 lectura que intenta escribir: tipo application/json, se esperaba application/problem+json
FALLA 404 servicio que no existe: tipo application/json, se esperaba application/problem+json
FALLA 405 método no admitido: tipo application/json, se esperaba application/problem+json
FALLA 409 nombre repetido: tipo application/json, se esperaba application/problem+json
FALLA 410 servicio retirado: tipo application/json, se esperaba application/problem+json
FALLA 412 etiqueta vencida: tipo application/json, se esperaba application/problem+json
FALLA 415 formato no admitido: tipo application/json, se esperaba application/problem+json
FALLA 422 datos imposibles: tipo application/json, se esperaba application/problem+json
FALLA 428 cambio sin If-Match: tipo application/json, se esperaba application/problem+json
FALLA 429 demasiadas revisiones: falta retry-after; tipo application/json, se esperaba application/problem+json
FALLA 500 el defecto a propósito: tipo application/json, se esperaba application/problem+json
FALLA 502 el servicio responde basura: tipo application/json, se esperaba application/problem+json
FALLA 503 mantenimiento: falta retry-after; tipo application/json, se esperaba application/problem+json
FALLA 504 el servicio no responde a tiempo: tipo application/json, se esperaba application/problem+json
ok    misma Idempotency-Key: no duplica; con otro cuerpo, 422
ok    SSRF: http://169.254.169.254/latest/meta-data/ se rechaza con 422 destino-prohibido
ok    SSRF: http://localhost:18080/mantenimiento se rechaza con 422 destino-prohibido
ok    credencial heredada de Object.prototype (constructor, __proto__, toString): 401
ok    If-None-Match con lista, W/ y * (débil): 304; If-Match con W/: 412; con lista o *: 200
ok    actualización perdida: dos PUT a la vez con la misma If-Match: 200 y 412, gana la primera que escribe
ok    orden del PUT: text/plain da 415 con etiqueta vieja o sin ella; retirado en el hueco, 410
ok    esquema sin distinguir mayúsculas (bearer: 200); Basic: reto sin error="invalid_token"
ok    método heredado de Object.prototype (constructor, toString, __proto__): 405
ok    cuerpo JSON que no es objeto (null, 7, "texto", []): 400, y el revisor sigue vivo
ok    cuerpo de más de 16 KiB: 413 (con Content-Length y por partes); en el tope, 400; el revisor sigue vivo
FALLA 429 por credencial: la cuarta consulta en vivo, 429; otra credencial no lo paga; enumerar ids llega al 429: el 429 no trae Retry-After
ok    Retry-After: 1.5, -1 y «12 30» se ignoran; las tres formas de HTTP-date dan lo mismo (GMT)
ok    SSRF: 12 direcciones de uso especial (CGNAT, NAT64, 6to4, compatible, 64:ff9b:1::/48, 240/4, 198.18/15, TEST-NET, 2001:db8::, Teredo, multicast): 422; 6to4 con IPv4 pública: 201
ok    la pasarela reenvía Content-Length; sin revisor responde 502 y sigue viva; alta-segura termina al agotar los intentos, también sin conexión
ok    alta-segura decide: un 4xx sale con 1; obedece Retry-After si es razonable y no insiste si pide mucho; se corta por tiempo ante un servidor mudo; el 409 de llave en curso se reintenta y el de nombre repetido no
ok    el banco responde el 205 sin cuerpo, con Content-Length: 0 y sin Transfer-Encoding
ok    ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)

24 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 1)

Чети я както би я прочел този, който я вижда за първи път в 10:05. Първото, което прави впечатление, е онова, което не се проваля: първите седем реда (успехите и пренасочванията) си остават зелени, а също и всички допълнителни проверки с изключение на една. Второто е онова, което се проваля: всички грешки, от 400 до 504, със същото съобщение: типът на съдържанието, който е пристигнал, е application/json, а се е очаквал application/problem+json. При 429 и 503 съобщението носи още едно изречение, falta retry-after, и в допълнителната проверка на 429 по удостоверение се повтаря. А 401, 405 и 415 запазват заглавките си (www-authenticate, allow, accept), защото дефектът махна само Retry-After. Всяка повреда казва кое обещание е нарушено и в кой сценарий, а пълният списък има 17 червени реда от 41; програмата излиза с 1.

Има една стойност, която значи повече от броя: нито един ред не казва „código“. Ако тестовете от сцената в началото бяха само за код, това изпълнение би дало 41 от 41. Матрицата не гледа само кода и затова би почервеняла още в понеделника, когато е качена промяната.

8.10.2 Документът, който изостана

Вторият дефект живее в документа. Това е другата половина на сцената: някой е добавил 429 в сървъра и не го е записал, или го е записал и после го е махнал. За да го предизвикаш, файлът defectos/sin-429-documentado/openapi.json е обичайният документ без 429 на POST /servicios/{id}/revisiones:

$ cp -R fig08_01 rota
$ cp defectos/sin-429-documentado/openapi.json rota/openapi.json
$ cd rota
$ node matriz23.ts
ok    200 consultar un servicio
ok    304 consultar sin cambios
ok    201 dar de alta
ok    202 pedir revisión
ok    204 retirar
ok    301 ruta vieja con 301
ok    308 ruta vieja con 308
ok    400 cuerpo ilegible
ok    401 sin credencial
ok    403 lectura que intenta escribir
ok    404 servicio que no existe
ok    405 método no admitido
ok    409 nombre repetido
ok    410 servicio retirado
ok    412 etiqueta vencida
ok    415 formato no admitido
ok    422 datos imposibles
ok    428 cambio sin If-Match
FALLA 429 demasiadas revisiones: openapi.json no documenta 429 en POST /servicios/{id}/revisiones
ok    500 el defecto a propósito
ok    502 el servicio responde basura
ok    503 mantenimiento
ok    504 el servicio no responde a tiempo
ok    misma Idempotency-Key: no duplica; con otro cuerpo, 422
ok    SSRF: http://169.254.169.254/latest/meta-data/ se rechaza con 422 destino-prohibido
ok    SSRF: http://localhost:18080/mantenimiento se rechaza con 422 destino-prohibido
ok    credencial heredada de Object.prototype (constructor, __proto__, toString): 401
ok    If-None-Match con lista, W/ y * (débil): 304; If-Match con W/: 412; con lista o *: 200
ok    actualización perdida: dos PUT a la vez con la misma If-Match: 200 y 412, gana la primera que escribe
ok    orden del PUT: text/plain da 415 con etiqueta vieja o sin ella; retirado en el hueco, 410
ok    esquema sin distinguir mayúsculas (bearer: 200); Basic: reto sin error="invalid_token"
ok    método heredado de Object.prototype (constructor, toString, __proto__): 405
ok    cuerpo JSON que no es objeto (null, 7, "texto", []): 400, y el revisor sigue vivo
ok    cuerpo de más de 16 KiB: 413 (con Content-Length y por partes); en el tope, 400; el revisor sigue vivo
ok    429 por credencial: la cuarta consulta en vivo, 429; otra credencial no lo paga; enumerar ids llega al 429
ok    Retry-After: 1.5, -1 y «12 30» se ignoran; las tres formas de HTTP-date dan lo mismo (GMT)
ok    SSRF: 12 direcciones de uso especial (CGNAT, NAT64, 6to4, compatible, 64:ff9b:1::/48, 240/4, 198.18/15, TEST-NET, 2001:db8::, Teredo, multicast): 422; 6to4 con IPv4 pública: 201
ok    la pasarela reenvía Content-Length; sin revisor responde 502 y sigue viva; alta-segura termina al agotar los intentos, también sin conexión
ok    alta-segura decide: un 4xx sale con 1; obedece Retry-After si es razonable y no insiste si pide mucho; se corta por tiempo ante un servidor mudo; el 409 de llave en curso se reintenta y el de nombre repetido no
ok    el banco responde el 205 sin cuerpo, con Content-Length: 0 y sin Transfer-Encoding
ok    ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)

40 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 1)

$ cp ~/curso-http/rota/openapi.json ~/validador/openapi.json && cd ~/validador   # la carpeta se arma en 8.11
$ npx redocly lint openapi.json --format=stylish 2>&1 | grep -v operationId   # el mismo documento, sin el 429
No configurations were provided -- using built in recommended configuration by default.

validating openapi.json...
openapi.json:
  540:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  540:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  554:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  554:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  570:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  570:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  584:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  584:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  694:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  899:7  warning  no-unused-components    Component: "E429" is never used.

openapi.json: validated in 39ms

Woohoo! Your API description is valid. 🎉
You have 27 warnings.

(código de salida: 0)

В края на този изход е същият документ, минал през валидатор (Redocly, който се инсталира в 8.11; трите команди копират всеки документ в папката на валидатора с името openapi.json); обяснява се в 8.11.3. Матрицата отбелязва една повреда, точно тази, която съответства: 429 demasiadas revisiones: openapi.json no documenta 429 en POST /servicios/{id}/revisiones. Сървърът продължава да прави онова, което правеше; документът е спрял да го казва и матрицата го забеляза, защото сценарият на 429 пита, накрая на comprobar, дали документът декларира този код в тази операция (раздел 8.6.3). Резултатът е чист: 40 от 41, един-единствен червен ред, един-единствен виновник.

Ред с предупреждение вътре в един JSON. JSON не допуска коментари, така че предупреждение на първия ред не може да се напише както в TypeScript. Файлът ползва начина, който спецификацията на OpenAPI предлага за това (раздел 4.9, «Specification Extensions»): поле, чието име започва с x-, може да носи каквато и да е стойност, а документът остава валиден. Затова първият ред на дефекта е { "x-defecto": "DEFECTO A PROPÓSITO …",.

8.11 Валиден документ не е верен документ

Същият документ, от който махна 429, прави нещо интересно, когато го дадеш на валидатор на OpenAPI. Един валидатор (на английски linter, когато освен това дава мнение за стила) чете документа и казва дали е добре написан според спецификацията. Има няколко; курсът ползва Redocly CLI, инструмент за командния ред с лиценз MIT, и неговата команда lint (документация). Не е зависимост на revisor: инсталира се отделно, в работна папка, и се изхвърля, когато приключиш.

$ mkdir ~/validador && cd ~/validador
$ npm init -y
$ npm install --no-save @redocly/cli@2.57.0
$ cp ~/curso-http/fig08_01/openapi.json .

8.11.1 Документът на revisor, валидиран

$ cp ~/curso-http/fig08_01/openapi.json ~/validador/openapi.json   # otra vez el documento bueno
$ npx redocly lint openapi.json --format=stylish
No configurations were provided -- using built in recommended configuration by default.

validating openapi.json...
openapi.json:
  25:7   warning  operation-operationId   Operation object should contain `operationId` field.
  49:7   warning  operation-operationId   Operation object should contain `operationId` field.
  127:7  warning  operation-operationId   Operation object should contain `operationId` field.
  180:7  warning  operation-operationId   Operation object should contain `operationId` field.
  260:7  warning  operation-operationId   Operation object should contain `operationId` field.
  299:7  warning  operation-operationId   Operation object should contain `operationId` field.
  356:7  warning  operation-operationId   Operation object should contain `operationId` field.
  411:7  warning  operation-operationId   Operation object should contain `operationId` field.
  457:7  warning  operation-operationId   Operation object should contain `operationId` field.
  491:7  warning  operation-operationId   Operation object should contain `operationId` field.
  540:7  warning  operation-operationId   Operation object should contain `operationId` field.
  543:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  543:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  554:7  warning  operation-operationId   Operation object should contain `operationId` field.
  557:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  557:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  570:7  warning  operation-operationId   Operation object should contain `operationId` field.
  573:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  573:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  584:7  warning  operation-operationId   Operation object should contain `operationId` field.
  587:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.
  587:9  warning  operation-4xx-response  Operation must have at least one `4XX` response.
  600:7  warning  operation-operationId   Operation object should contain `operationId` field.
  649:7  warning  operation-operationId   Operation object should contain `operationId` field.
  693:7  warning  operation-operationId   Operation object should contain `operationId` field.
  697:9  warning  operation-2xx-response  Operation must have at least one `2XX` response.

openapi.json: validated in 48ms

Woohoo! Your API description is valid. 🎉
You have 26 warnings.

(código de salida: 0)

Чети я отгоре надолу. Редът в началото предупреждава, че не е дадена никаква конфигурация и че се ползва препоръчаната, която е стойността по подразбиране на командата. Следват 26 предупреждения (warnings) и присъдата: «Woohoo! Your API description is valid.» Тоест: нула грешки. Документът е добре написан според спецификацията.

26-те предупреждения не са грешки и се разпределят в три групи, които трябва да се разберат:

  • 17 от operation-operationId. Всяка операция може да носи operationId, уникален идентификатор (спецификацията, раздел 4.8.10.1, казва, че трябва да е уникален сред всички операции на документа). Служи на инструментите, които генерират код от документа, за да именуват функциите. Курсът не генерира код, така че документът не го носи. Това е решение, а предупреждението е валидаторът, който пита дали не си го забравил.
  • 5 от operation-2xx-response. Пет операции не декларират никакъв отговор 2xx: двете стари пренасочвания (GET и POST на /viejo/servicios и на /api/servicios, четири) и GET /v0/servicios (оттеглената версия, която отговаря само 410). Вярно е: тези маршрути нямат успех. Предупреждението е валидаторът, който пита дали е нарочно, и е нарочно.
  • 4 от operation-4xx-response. Четирите пренасочвания също не декларират никакъв отговор 4xx.

Нито едно от 26-те не е нарушено обещание и затова документът си остава както е. Но това е видът решение, което се взема на показ, не мълчаливо: изпълнява се валидаторът, чете се всяка група и се решава.

8.11.2 Положителният контрол: документ, който наистина е зле написан

Валидатор, който винаги казва „валиден“, също не доказва нищо. За да се види, че този по-горе хапе, дефектът documento-roto има две грешки при писането: препратка към отговор, който не съществува (E4O1, с буква O вместо нула), и 204 без своя description:

$ cp ~/curso-http/defectos/documento-roto/openapi.json ~/validador/openapi.json
$ npx redocly lint openapi.json --format=stylish 2>&1 | grep -v operationId   # el documento roto a propósito
No configurations were provided -- using built in recommended configuration by default.

validating openapi.json...
openapi.json:
  38:18   error    no-unresolved-refs      Can't resolve $ref
  263:11  error    struct                  The field `description` must be present on this level.
  541:9   warning  operation-2xx-response  Operation must have at least one `2XX` response.
  541:9   warning  operation-4xx-response  Operation must have at least one `4XX` response.
  555:9   warning  operation-2xx-response  Operation must have at least one `2XX` response.
  555:9   warning  operation-4xx-response  Operation must have at least one `4XX` response.
  571:9   warning  operation-2xx-response  Operation must have at least one `2XX` response.
  571:9   warning  operation-4xx-response  Operation must have at least one `4XX` response.
  585:9   warning  operation-2xx-response  Operation must have at least one `2XX` response.
  585:9   warning  operation-4xx-response  Operation must have at least one `4XX` response.
  695:9   warning  operation-2xx-response  Operation must have at least one `2XX` response.

openapi.json: validated in 93ms

❌ Validation failed with 2 errors and 26 warnings.
run `redocly lint --generate-ignore-file` to add all problems to the ignore file.

(código de salida: 1)

Сега наистина: две грешки и валидаторът отказва да го приеме за добър. Първата казва no-unresolved-refs, «Can't resolve $ref», на ред 38, която е погрешно написаната препратка. Втората казва struct, «The field description must be present on this level», на ред 263, който е 204: спецификацията (раздел 4.8.17.1) изисква description във всеки отговор. И двете са от вида грешка, който се промъква в документ от над хиляда реда, писан на ръка, а валидаторът ги улавя, без да изпълняваш никакъв сървър.

8.11.3 Неудобният извод

Сега същият инструмент, срещу документа от раздел 8.10.2, този без 429:

$ npx redocly lint openapi.json --format=stylish

Присъдата му: «Woohoo! Your API description is valid.», с 27 предупреждения, едно повече от 26-те отпреди. Новото казва no-unused-components: «Component: "E429" is never used.» Тоест: валидаторът наистина забеляза нещо: преизползваемият отговор E429 остана без никаква операция, която да го ползва. Но го каза като предупреждение, не като грешка, и нямаше как да знае, че сървърът продължава да връща този 429. Документът си остава валиден и валидаторът не видя, че лъже.

Това не е повреда на валидатора. Един валидатор отговаря на въпроса добре ли е написан?, а документът без 429 е съвсем добре написан: просто не споменава код, който сървърът наистина връща. За да се отговори на верен ли е?, трябва да се сравни документът със сървъра и това прави матрицата. Двата инструмента не се заместват, допълват се:

Въпрос Кой отговаря Пример в този урок
Добре ли е написан? валидаторът (redocly lint) препратката E4O1 и 204 без description
Верен ли е? матрицата (matriz23.ts) 429, който сървърът връща, а документът премълчава
Казва ли всичко? кръстосването на каталога (fig08_02.ts) revision-inexistente, който документът не споменава

Едно API само с първия ред има елегантен и остарял документ. Едно API само с втория има проверка, която зависи от документ, за който никой не знае дали може да се чете. С трите всеки вид грешка има кой да я улови.

8.12 DNS rebinding: дупката от Урок 1, затворена

Урок 1 остави един дълг, записан в коментар в destinos.ts: revisor проверява местоназначението преди всяка връзка и въпреки това остава един прозорец. Днес се затваря. Преди да видим как, си струва да се разкаже защо има значение, с два случая от същата тази година.

8.12.1 Две публични известия

На 10 юли 2026 г. беше публикувано известие за сигурност за mcp-atlassian, MCP сървър с отворен код и от трета страна (не е продукт на Atlassian), за свързване на агенти с изкуствен интелект с Jira и Confluence (GHSA-489g-7rxv-6c8q). Интересното е, че вече е имало поправка срещу SSRF; известието е за това защо тази поправка не е стигнала. Поправката е разрешавала името веднъж, за да го провери, а следващата заявка пак е ползвала името такова, каквото е, така че, казва известието, заявката «re-resolves at connect time with no IP pinning» (на български: разрешава наново при свързване, без да фиксира IP адреса). Име, контролирано от нападателя, може да отговори с публичен адрес, докато се проверява, и с вътрешен (като 169.254.169.254, където облаците публикуват метаданните на машината), когато се свързва. Известието уточнява, че тестът, придружаващ доклада, не доказва пълна атака срещу работещ сървър и че да се експлоатира изисква да се спечели тази надпревара. Квалифицира я като надпревара между проверката и употребата (на английски TOCTOU, time of check, time of use), а версията, която я поправя, е 0.22.0.

Малко по-рано, на 15 юни 2026 г., беше публикувано друго, със същия модел и в друг продукт: CVE-2026-53708, в шлюз за MCP сървъри (известие GHSA-9hgc-g3w5-67cm). Административна входна точка проверявала името на един URL, като го разрешавала, блокирала частното и после предавала първоначалното име на HTTP клиента, който го разрешавал отново. Нападател, който контролира DNS, може да върне публичен IP по време на валидацията и частен по време на връзката. Поправено е във версия 1.0.3 (умерена тежест, CVSS 6.6). Известието го казва така: входната точка валидира името, като го разрешава, а HTTP клиентът, от своя страна, разрешава името наново при свързване, без нищо да свързва двете операции (наше перифразиране на известието, което е на английски). Препоръката е същата като в другото известие: да се фиксира вече провереният IP при връзката.

Едно пояснение за имената: «DNS rebinding» означава, в класическия си смисъл, атака от браузъра срещу политиката за същия произход; известията (и OWASP) наричат със същото име тази надпревара от страна на сървъра, което е смисълът, който има тук. И двата случая имат нещо общо с revisor от Урок 1: програма, чиято работа е да се свързва с адреси, дадени ѝ от някой друг. И двата са имали дефекта, който Урок 1 назова и остави отворен: проверката и свързването са две разрешавания, а не едно. Ръководството на OWASP за SSRF го казва без заобикалки: „втори непроверен DNS lookup между валидацията и връзката може да заобиколи тези защити“ (наш превод); и препоръчва да се свързваш само към проверените адреси, като запазваш първоначалното име за заглавката Host, SNI и проверката на сертификата (ръководството на OWASP).

8.12.2 Механизмът, със собствените ти ръце

Преди да прочетеш защитата, накарай нападението да се случи, в малък мащаб и без да нападаш никого. fig08_04.ts симулира DNS, който лъже: първия път, когато го питат за rebote.ejemplo, отговаря 127.0.0.1 (лабораторията, която е разрешена); от втория отговаря ::1, където слуша друг сървър, който отговаря 299 (код, който никой друг не връща, за да се разпознае). dns.lookup и dns/promises.lookup се заместват с наши функции, които броят колко пъти ги викат. Нищо от това не излиза от твоята машина:

// fig08_04.ts — DNS rebinding simulado: ¿a qué servidor llega el cliente cuando el nombre cambia de dueño
// entre la validación y la conexión? No ataca nada: el «DNS malicioso» es una función nuestra que miente
// la segunda vez que le preguntan.
// Uso: node fig08_04.ts <carpeta con cliente.ts y destinos.ts>      (por ejemplo, fig08_01 o con-fetch)
import dns from "node:dns";
import dnsPromesas from "node:dns/promises";
import { createServer } from "node:http";
import { syncBuiltinESMExports } from "node:module";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

const carpeta = process.argv[2];
if (carpeta === undefined) {
  console.error("uso: node fig08_04.ts <carpeta>");
  process.exit(2);
}

const NOMBRE = "rebote.ejemplo";
const PUERTO = 18081; // el del laboratorio: es el único destino privado que destinos.ts permite

// Dos servidores: el laboratorio de siempre (127.0.0.1) y «otro», en ::1, que responde 299.
// Ningún nombre se resuelve de verdad: las dos direcciones se escriben a mano.
const laboratorio = createServer((_p, r) => { r.writeHead(200); r.end(); });
const otro = createServer((_p, r) => { r.writeHead(299); r.end(); });
await new Promise<void>((listo) => laboratorio.listen(PUERTO, "127.0.0.1", listo));
await new Promise<void>((listo) => otro.listen(PUERTO, "::1", listo));

// El DNS que miente: la primera pregunta recibe una dirección permitida; las demás, la del «otro».
let preguntas = 0;
function responder(nombre: string): { address: string; family: number } {
  preguntas++;
  const direccion = preguntas === 1 ? { address: "127.0.0.1", family: 4 } : { address: "::1", family: 6 };
  console.log(`  pregunta ${preguntas} al DNS: ${nombre} -> ${direccion.address}`);
  return direccion;
}
const lookupOriginal = dns.lookup;
dnsPromesas.lookup = (async (nombre: string) => [responder(nombre)]) as unknown as typeof dnsPromesas.lookup;
dns.lookup = ((nombre: string, opciones: unknown, listo?: (...args: unknown[]) => void) => {
  const cuando = typeof opciones === "function" ? (opciones as (...args: unknown[]) => void) : listo;
  const todas = typeof opciones === "object" && opciones !== null && (opciones as { all?: boolean }).all === true;
  if (nombre !== NOMBRE) return (lookupOriginal as (...args: unknown[]) => void)(nombre, opciones, listo);
  const { address, family } = responder(nombre);
  if (todas) cuando?.(null, [{ address, family }]);
  else cuando?.(null, address, family);
}) as typeof dns.lookup;
syncBuiltinESMExports(); // para que el «import { lookup }» de destinos.ts vea la función falsa

const { consultar } = (await import(pathToFileURL(join(resolve(carpeta), "cliente.ts")).href)) as {
  consultar: (url: string, limiteMs: number) => Promise<unknown>;
};

console.log(`$ consultar("http://${NOMBRE}:${PUERTO}/")   con el cliente de ${carpeta}`);
console.log(JSON.stringify(await consultar(`http://${NOMBRE}:${PUERTO}/`, 2000)));
console.log(`el DNS fue preguntado ${preguntas} ${preguntas === 1 ? "vez" : "veces"}`);

laboratorio.close();
otro.close();
process.exit(0);

Частите, които имат значение:

  • Два сървъра, два адреса. „Лабораторията“ слуша на 127.0.0.1:18081, а „другият“ — на [::1]:18081. Същият номер на порт на два различни адреса, което е възможно, защото един IPv4 адрес и един IPv6 адрес са различни места.
  • responder. Това е DNS, който лъже: брои колко пъти го викат и отговаря с различен адрес според номера на въпроса.
  • Два пача. dnsPromesas.lookup се ползва от destinos.ts за проверката (версията с обещания), а dns.lookup се ползва от Node отвътре, когато една връзка пита за адреса на едно име (версията с callback). Вторият пач има два начина да отговаря, защото dns.lookup може да се поиска с опцията all или без нея (ръководството на Node, на страницата на dns: с all аргументите на callback са (err, addresses); без нея — (err, address, family)).
  • syncBuiltinESMExports(). Когато внасяш { lookup } от вътрешен модул на Node, името lookup остава фиксирано към стойността, която е имало в онзи момент. След като заместиш функцията, трябва да синхронизираш изнесените имена, иначе destinos.ts би продължил да вика оригинала.
  • Динамичният import() в края зарежда cliente.ts от папката, подадена като аргумент. Първо се пачва, после се внася; обратното не би работило.

Програмата получава папката на клиента, който иска да изпробва. За първия опит е нужен клиентът от Урок 7, този, който ползва fetch. Той е в defectos/con-fetch/ (два файла: cliente.ts и destinos.ts, с предупреждение на първия ред). Подреди папка с файловете на revisor и замени тези два:

$ cp -R fig08_01 con-fetch
$ cp defectos/con-fetch/cliente.ts defectos/con-fetch/destinos.ts con-fetch/
$ node fig08_04.ts con-fetch
$ node fig08_04.ts fig08_01
$ node fig08_04.ts con-fetch
$ consultar("http://rebote.ejemplo:18081/")   con el cliente de con-fetch
  pregunta 1 al DNS: rebote.ejemplo -> 127.0.0.1
  pregunta 2 al DNS: rebote.ejemplo -> ::1
{"tipo":"respuesta","codigo":299,"ms":8,"reintentarEn":null}
el DNS fue preguntado 2 veces
(código de salida: 0)

$ node fig08_04.ts fig08_01
$ consultar("http://rebote.ejemplo:18081/")   con el cliente de fig08_01
  pregunta 1 al DNS: rebote.ejemplo -> 127.0.0.1
{"tipo":"respuesta","codigo":200,"ms":6,"reintentarEn":null}
el DNS fue preguntado 1 vez
(código de salida: 0)

Първият блок е нападението: клиентът от Урок 7 е попитал DNS два пъти. Първият път (за проверка) е получил 127.0.0.1, който е в списъка на разрешените; вторият (този, който fetch е направил отвътре при свързване) е получил ::1 и клиентът е стигнал до сървъра с 299, без нито едно правило на destinos.ts да го види. Вторият блок е защитата: клиентът на този урок е попитал само веднъж, свързал се е с онова, което са му отговорили, и е стигнал до лабораторията, с 200. Същата заявка, същото име, същият DNS, който лъже: двама клиенти, две местоназначения.

Това е симулация и го казва. DNS, който лъже, е наша функция; „другият сървър“ е наш сървър. Един истински нападател, освен това, се нуждае от отговор с много кратък срок на валидност (TTL) и от никакъв кеш (на операционната система или на резолвера), който да повтори първия отговор, нещо, което симулацията пропуска по построение; а пачът на dns.lookup работи, защото net.connect пита тази функция при свързване, което е предположение за това как е устроен Node, а не гаранция. Ако системата ти няма ::1, смени втория сървър на 127.0.0.2 (Linux го допуска като loopback). Тестът доказва, че клиентът от Урок 7 пита наново, а този от Урок 8 — не, което е онова, което трябва да се знае. Не доказва, че истински нападател може да експлоатира revisor: това зависи от това къде работи, каква мрежа има около себе си и от втория слой, който Урок 1 поиска (мрежа, която не позволява достъп до метаданните, дори програмата да опита).

8.12.3 Защитата: свързване към вече проверения адрес

Защитата са три малки промени. Първата е в destinos.ts: revisarDestino вече не отговаря само „да“ или „не“, отговаря „да, и това са адресите, които проверих“:

// Si se permite, trae las direcciones ya validadas: quien se conecte debe usar una de éstas.
export type Veredicto = { permitido: true; direcciones: string[] } | { permitido: false; motivo: string };

А накрая на функцията, вместо return { permitido: true };, връща return { permitido: true, direcciones };. Който се свързва, има сега подръка точно адресите, които са минали проверката, и никакви други.

Втората и третата са в cliente.ts. Първо, функция, която не разрешава нищо:

// Un lookup que no resuelve: devuelve siempre la dirección que destinos.ts ya validó. Node lo llama
// a veces con { all: true } (cuando prueba IPv4 e IPv6), y entonces espera una lista.
function soloA(direccion: string): LookupFunction {
  const family = isIP(direccion);
  return (_nombre, opciones, listo) => {
    if (opciones.all) listo(null, [{ address: direccion, family }]);
    else listo(null, direccion, family);
  };
}

soloA получава един адрес и връща функция за търсене (lookup), която винаги отговаря с този адрес, без да пита никого. Типът LookupFunction идва от node:net. Опцията lookup на http.request и на net.connect съществува за това: според ръководството на Node стойността ѝ по подразбиране е dns.lookup() и може да се замести с всяка функция със същата форма. Двата пътя на if не са украса. От Node 20 net.connect има включена по подразбиране опцията autoSelectFamily, която изпробва IPv4 и IPv6 адреси последователно и, когато е активна, подава на lookup опцията all: true; в такъв случай функцията очаква списък от адреси, а не един. Затова soloA отговаря и по двата начина, според това какво ѝ поискат.

След това функцията, която прави заявката:

// GET a la URL, conectándose a la dirección indicada. El nombre sigue en la URL, así que Host y, en
// https, el SNI y la verificación del certificado siguen siendo los del nombre, no los de la IP.
// agent: false abre una conexión nueva y la cierra: ninguna conexión vieja se reutiliza.
function pedir(url: URL, direccion: string, senal: AbortSignal): Promise<IncomingMessage> {
  const pedirCon = url.protocol === "https:" ? pedirHttps : pedirHttp;
  return new Promise((listo, falla) => {
    const peticion = pedirCon(url, { lookup: soloA(direccion), signal: senal, agent: false }, listo);
    peticion.on("error", falla);
    peticion.end();
  });
}

Три решения, по ред:

  • Подава се обектът URL, не адрес. pedirCon(url, opciones, …) взема името от URL за заглавката Host и, при https, за SNI и проверката на сертификата, а от lookup взема накъде да се свърже. Затова сървърът на лабораторията получава Host: rebote.ejemplo:18081, макар връзката да е отишла към 127.0.0.1 (ще го провериш в упражнение 4).
  • agent: false. Ръководството на Node казва, че с тази опция се ползва нов агент за еднократна употреба и че служи «to avoid connection reuse». Една преизползвана връзка от споделен агент би могла да е била отворена преди това, към друг адрес; с agent: false всяка проверка отваря своя, към адреса, който току-що е проверен.
  • signal. Това е AbortSignal с ограничението във времето. Прекъсва цялата заявка, не само връзката: това е, което преди правеше AbortSignal.timeout с fetch.

И тялото на consultar, което затваря кръга:

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.
  const veredicto = await revisarDestino(url);
  if (!veredicto.permitido) return { tipo: "destino-prohibido", motivo: veredicto.motivo };
  // Y la conexión va a una dirección que se acaba de validar. fetch volvería a resolver el nombre,
  // y entre esa resolución y la nuestra el nombre podría apuntar a otra parte (DNS rebinding).
  const tiempo = AbortSignal.timeout(limiteMs);
  const inicio = performance.now();
  try {
    const respuesta = await pedir(new URL(url), veredicto.direcciones[0], tiempo);
    respuesta.destroy(); // solo interesa el código y las cabeceras: el cuerpo no se lee
    return {
      tipo: "respuesta",
      codigo: respuesta.statusCode ?? 0,
      ms: Math.round(performance.now() - inicio),
      reintentarEn: segundosDeEspera(respuesta.headers["retry-after"] ?? null),
    };
  } catch (error) {
    if (tiempo.aborted) return { tipo: "tiempo-agotado" };
    const codigo = (error as { code?: string }).code;
    if (codigo === "ECONNREFUSED" || codigo === "ENOTFOUND") return { tipo: "sin-conexion" };
    return { tipo: "respuesta-invalida", motivo: error instanceof Error ? error.message : String(error) };
  }
}

Редът има значение: първо се проверява местоназначението (revisarDestino, което разрешава веднъж), после се подготвя ограничението във времето и накрая се свързва към veredicto.direcciones[0]. Няма трето разрешаване. respuesta.destroy() прекъсва четенето след заглавките: на revisor му интересуват само кодът и заглавките, не тялото. А класификацията на грешките се промени по форма, не по същество: преди гледаше изключение на fetch (DOMException с TimeoutError или cause със code); сега гледа дали сигналът е прекъснат (tiempo.aborted) или code на изключението на Node. Петте изхода са същите.

8.12.4 Какво не се промени: сертификатът, пренасочването и изходите

Три разумни въпроса, с измерен отговор.

Продължава ли да се проверява сертификатът спрямо името? Да, и това е причината да се подава URL, а не IP. Провери се срещу един публичен сайт и два тестови на общността на браузърите, единият със изтекъл сертификат, а другият с име, което не съвпада (нужен е интернет):

$ node --input-type=module -e 'import { consultar } from "./cliente.ts"; console.log(JSON.stringify(await consultar(process.argv[1], 8000)))' https://example.com/
{"tipo":"respuesta","codigo":200,"ms":484,"reintentarEn":null}
$ node --input-type=module -e 'import { consultar } from "./cliente.ts"; console.log(JSON.stringify(await consultar(process.argv[1], 8000)))' https://expired.badssl.com/
{"tipo":"respuesta-invalida","motivo":"certificate has expired"}
$ node --input-type=module -e 'import { consultar } from "./cliente.ts"; console.log(JSON.stringify(await consultar(process.argv[1], 8000)))' https://wrong.host.badssl.com/
{"tipo":"respuesta-invalida","motivo":"Hostname/IP does not match certificate's altnames: Host: wrong.host.badssl.com. is not in the cert's altnames: DNS:*.badssl.com, DNS:badssl.com"}

Първият отговаря 200. Другите два са respuesta-invalida, с причината, която дава Node: certificate has expired в единия, а в другия че името на сървъра не е сред тези на сертификата. Това доказва, че името продължава да се сравнява със сертификата: клиентът е променил само накъде се свързва сокетът, не спрямо какво се проверява.

За да се види какво би станало, ако вместо това името се замести с IP в URL, fig08_07.ts прави точно това (разрешава името, съставя https://<IP>/ и иска страницата):

$ node fig08_07.ts example.com
{"nombre":"example.com","error":"EPROTO"}
$ node fig08_07.ts expired.badssl.com
{"nombre":"expired.badssl.com","error":"CERT_HAS_EXPIRED"}
$ node fig08_07.ts wrong.host.badssl.com
{"nombre":"wrong.host.badssl.com","error":"CERT_HAS_EXPIRED"}

Това не е алтернатива: дори добрият сайт не работи (example.com свършва с EPROTO, грешка при TLS ръкостискането: без името сървърът не знае кой сертификат да покаже), а и двата лоши сайта също не дават 200 (и двата свършват с CERT_HAS_EXPIRED: срокът на един сертификат се проверява винаги, а без името сървърът предлага този, който има по подразбиране). Тоест, свързването „към IP“ не отпуска проверката, а я чупи на друго място и с друго съобщение. Затова consultar подава URL с името и променя само адреса на сокета: името се запазва за заглавката Host, за SNI и за проверката на сертификата.

Следват ли се пренасочванията? Не. Заявка към местоназначение, което отговаря 301, връща 301, не отговора на мястото, към което сочи. http.request не следва пренасочвания сам, а revisor също не ги следва: ръководството на OWASP ги изброява като път за заобикаляне на валидацията (разрешен URL, който пренасочва към забранен), а препоръката е да се изключат. Преди това се постигаше с redirect: "manual" във fetch; сега с това, че не се следват.

Промени ли се някой изход? Не. Петте типа Consulta са същите, а кодовете, които излизат в матрицата, са същите. Единственото, което се променя, е вътрешният текст на motivo в respuesta-invalida, който никой отговор на сървъра не показва.

8.12.5 Какво тази защита не прави

Би било лош навик да затвориш един раздел за сигурност с „готово“. Три граници, на показ.

  • Свързва се към един-единствен адрес. veredicto.direcciones[0] е първият от адресите, които DNS е проверил. Ако името има няколко и първият не отговаря, revisor не опитва втория: според това, което се чете в кода, име, чийто първи адрес отказва връзката, се отчита като sin-conexion, дори вторият да работи. Това е цената на фиксирането на адреса; версия, която би опитвала следващите, би трябвало да приложи същата проверка към всеки. Ръководството на OWASP го иска така: политиката за местоназначението да се прилага и към повторните опити и към алтернативните връзки.
  • Не замества мрежата. Един revisor, изложен в интернет, би трябвало да работи в мрежа, която няма маршрут към онова, до което не бива да стига. Урок 1 го каза; продължава да е вярно. Тази защита намалява онова, което зависи от един файл.
  • Не покрива онова, което не минава през consultar. Днес единственото място, където revisor се свързва, е cliente.ts. Ако утре някой добави друго (известие по имейл, изтегляне на файл), то трябва да мине през същата входна точка, а матрицата от раздел 8.6 не го знае: проверява само местоназначенията, които ѝ кажеш.

8.13 Версиите на API в документа

Урок 3 показа как се оттегля една версия, без да се изненада никой: месеци предупреждения и накрая 410. Липсваше място, където тези предупреждения да са записани за машините, и това е документът. Първо, какво сървърът наистина прави:

$ curl -i http://127.0.0.1:18080/v1/servicios/1 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
Link: <https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 22:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"nombre":"catalogo","direccion":"http://127.0.0.1:18081/codigo/200","limite":2}

$ curl -i http://127.0.0.1:18080/v0/servicios
HTTP/1.1 410 Gone
Content-Type: application/problem+json
Content-Language: es
Content-Length: 231
Date: Wed, 21 Oct 2026 22:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"type":"https://revisor.example/problemas/version-retirada","title":"Esa versión de la API se retiró","status":410,"detail":"la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios","instance":"/v0/servicios"}

$ curl -i -X PATCH http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, PUT, DELETE
Content-Type: application/problem+json
Content-Language: es
Content-Length: 137
Date: Wed, 21 Oct 2026 22:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"type":"about:blank","title":"Method Not Allowed","status":405,"detail":"PATCH no se admite en /servicios/1","instance":"/servicios/1"}

Нека видим онова, което има значение в трите отговора. /v1/servicios/1 отговаря 200 с три заглавки: Deprecation, Sunset и Link. /v0/servicios отговаря 410, с типа version-retirada. А маршрутът PATCH от последния блок вече го познаваш от раздел 8.4.4.

8.13.1 Какво казват трите заглавки

Deprecation е RFC 9745, публикуван през март 2025 г. в пътя на стандартите (Standards Track). Стойността му е дата във формата на структурираните полета на HTTP, число секунди от 1970 г., предшествано от @: раздел 2.1 казва «Deprecation is an Item Structured Header Field; its value MUST be a Date». Стойността, която revisor изпраща, @1782864000, е 1 юли 2026 г. в 00:00 UTC. Значи „тази версия е обявена за остаряла на тази дата“, а датата може да е в миналото или в бъдещето.

Sunset е RFC 8594, от май 2019 г., информативен. Стойността му е обикновена HTTP дата (Sun, 31 Jan 2027 00:00:00 GMT) и казва кога се очаква ресурсът да спре да отговаря. Самият RFC казва, че който го получи, трябва да приема датите като подсказки (hints), не като гаранции. Връзката между двете е в раздел 4 на RFC 9745: датата на Sunset не бива да е преди тази на Deprecation, а тази на версия 1 на revisor (31 януари 2027 г.) е седем месеца по-късно.

Link с rel="deprecation" сочи към документацията за хора на оттеглянето: ръководството за мигриране. RFC 9745 регистрира тази релация и дава пример със същия формат, който ползва revisor.

8.13.2 Как това се казва в OpenAPI

Документът има три места, където се появяват версиите:

    "/v1/servicios": {
      "get": {
        "summary": "Versión 1, desaprobada",
        "deprecated": true,
        "responses": {
          "200": {
            "description": "La lista en la forma de la v1.",
            "headers": {
              "Deprecation": {
                "$ref": "#/components/headers/Deprecation"
              },
              "Sunset": {
                "$ref": "#/components/headers/Sunset"
              },
              "Link": {
                "$ref": "#/components/headers/Link"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          }
        }
      }
    },

Това е операцията GET /v1/servicios и носи три неща:

  • "deprecated": true. Поле на операцията. Спецификацията (раздел 4.8.10.1) казва: «Declares this operation to be deprecated. Consumers SHOULD refrain from usage of the declared operation.» Това е предупреждението за машините: инструментите за документация обикновено зачертават операцията, а тези, които генерират код, могат да маркират функцията като остаряла.
  • Трите заглавки (Deprecation, Sunset и Link) в отговора 200, препратени от components/headers, всяка със своето описание и RFC, който я определя.
  • Изтеглянето, /v0/servicios, с deprecated: true, "security": [] (не иска удостоверение, защото 410 се отговаря, преди да се попита кой си) и само отговора 410, същият E410, който ползва изтеглената услуга, защото каталогът преизползва схемата Problema със собствения type version-retirada.

И има още една точка, която затваря кръга: в info на документа описанието казва, че info.version е версията на документа и че действащото API живее в корена, с v1 обявена за остаряла и v0 оттеглена. Без това изречение някой, който прочете "version": "1.0.0", би помислил, че /v1 е действащото.

Едно проектантско уточнение, което си струва да се каже на глас: версиите живеят в маршрута (/v1/…, /v0/…), а действащата няма префикс. Има и други начини за версиониране на едно API (по заглавка, по тип на съдържанието) и OpenAPI ги описва всичките; маршрутът е най-лесният за документиране, защото всяка версия е запис в paths и всяка може да се маркира deprecated поотделно.

8.14 Какво матрицата не обещава

Една честна проверка казва какво не вижда. Седем неща, с доказателство.

1. Не гледа случаите, които не е написала. Това научи кръстосването от раздел 8.5: GET /revisiones/{id} връща type, който документът не споменава, а матрицата беше в зелено, защото нейният сценарий на 404 иска услуга, не проверка. Всеки маршрут, който няма свой сценарий, е маршрут, който матрицата не познава.

2. Гледа дали членовете са налице, не дали формата им е правилна. Проверява type, status и title във всички грешки; не проверява detail и instance, освен в 500. Упражнение 2 го измерва: със сървър, на когото липсва instance във всичките му грешки, сегашната матрица дава 40 от 41 и единствената повреда е тази на 500; разширението от два реда я поставя на 25 от 41.

3. Не сравнява телата с техните схеми. Схемата Servicio казва, че една услуга има id, nombre, url и limite_ms; матрицата гледа id да е 1 в една справка и nombre да е matriz в едно добавяне, не че четирите полета имат типа, който схемата казва. Сървър, който върне limite_ms като текст, би минал. За да се затвори това, трябва валидатор на JSON Schema (нито един в зависимостите на курса) и това е естественото разширение на матрицата.

4. Не проверява заглавките на предупрежденията за версия. Документът обещава Deprecation, Sunset и Link в /v1 и това обещание беше проверено на ръка в 8.13 и в Урок 3; матрицата от 23 сценария няма сценарий за /v1.

5. Сценариите споделят сървър със състояние. Редът е записан (раздел 8.6.2) и промяна, която пренарежда сценариите, може да ги счупи по причини, които не са дефекти на API-то.

6. Броенето на течовете е списък със сигнали, не доказателство за отсъствие. Покрива стекове, пътища на файлове, SQL, удостоверения и имена на изключения на JavaScript и оставя навън всичко, което не е било въобразено.

7. Това е тест, а и тестовете се провалят без причина. Това заслужава отделно място, защото е онова, което най-много изтрива доверието във всяка автоматична проверка. По време на този урок една от проверките на матрицата понякога се провали, без да има никакъв дефект, и цялата история (как се откри, как се измери и как се затвори) е в следващия раздел.

8.14.1 Проверка, която понякога се проваля: така я открихме, така я измерихме и така я затворихме

Как я открихме. Докато се пишеше този урок, матрицата понякога излизаше с код 1, без нищо да е счупено. Бяха работни изпълнения, някои срещу копия с нарочни дефекти, и броят им не беше запазен: затова тук няма число колко пъти се е провалявала, а всичко следващо е онова, което беше измерено после, с програми и изходи, които са в хранилището. Една от проверките, които се провалиха, беше тази на тяло над 16 KiB (редът е от едно от тези работни изпълнения; не беше запазен във файл):

FALLA cuerpo de más de 16 KiB: 413 (con Content-Length y por partes); en el tope, 400; el revisor sigue vivo: 20000 bytes por partes: error TypeError: fetch failed

Друга повреда беше от друго семейство: заявка към шлюз, който още не слушаше (pasarela sin revisor: 0 null). Тя се подема отново в края на този раздел.

Как я измерихме. Една проверка, която се проваля от време на време, се измерва най-добре, като се изолира. fig08_05.ts повтаря 400 пъти точно тялото на части, което матрицата изпраща (20 парчета по 1000 байта, без Content-Length), и брои какво е станало всеки път. Нужен е revisor, който слуша: node servidor.ts от fig08_01/, в един терминал, и в друг. Това е изпълнението срещу revisor така, както беше преди затварянето на надпреварата, което е съдържанието на defectos/cierre-brusco/respuestas.ts (изпробва се, като се копира fig08_01/ в друга папка и се замени този файл; пълният изход е в defectos/cierre-brusco.salida.txt):

$ node fig08_05.ts   # contra el revisor con defectos/cierre-brusco/respuestas.ts
  382  respuesta 413
    4  sin respuesta: ECONNRESET
   14  sin respuesta: EPIPE
400 intentos

$ for i in 1 2 3 4 5; do printf "corrida $i:"; node fig08_05.ts | grep "respuesta 413"; done
corrida 1:  356  respuesta 413
corrida 2:  358  respuesta 413
corrida 3:  357  respuesta 413
corrida 4:  363  respuesta 413
corrida 5:  346  respuesta 413

От 400 опита revisor отговори 413 в повечето; в останалите клиентът не получи отговор: EPIPE (връзката се скъса, докато клиентът още пишеше) и понякога ECONNRESET. С първото изпълнение и петте му повторения revisor отговори между 346 и 382 пъти 413 от 400: между 18 и 54 опита без отговор, тоест между 4,5 и 13,5 на всеки 100. Числото се променя от едно изпълнение на друго и това е част от онова, което го правеше трудно за забелязване: в матрицата, която прави един опит на изпълнение, се проявяваше като случайна повреда.

Причината. Това е надпревара и я описва RFC 9112, раздел 9.6. revisor решава, че тялото е прекалено голямо, щом преброи над 16 KiB, отговаря 413 с Connection: close и затваря. Но клиентът продължаваше да изпраща парчета. Когато една връзка се затвори напълно, а още пристигат данни, операционната система отговаря с reset (RST) и рестартирането може да изтрие отговора, който клиентът още не е прочел. RFC го казва така: ако един сървър затвори веднага една TCP връзка, «има значителен риск клиентът да не успее да прочете последния HTTP отговор» (наш превод). И описва какво обикновено правят сървърите, за да го избегнат, а то е затваряне на етапи: първо се затваря само страната на записа (half-close), после се продължава да се чете, докато клиентът затвори (или докато има разумна сигурност, че клиентът е получил последния отговор), и накрая се затваря напълно. Обърни внимание на реда: RFC отговаря първо и продължава да чете после.

Как я затворихме, първия път. Има три възможни изхода и само един е честен към сървъра. Да повтаряш теста, докато мине, скрива дефект, който съществува. Да приемеш двата изхода в теста (413 или затваряне) спира да зависи от надпреварата, но признава за добър сървър, който губи отговори. Да поправиш затварянето в сървъра коригира проблема там, където е. Първият опит беше най-простата за писане версия в Node: да се чете и изхвърля каквото клиентът продължава да праща, с таван, и чак тогава да се отговори. Това е съдържанието на defectos/drenar-antes/respuestas.ts (урокът вече не го ползва, но се пази, защото границата му беше измерена):

// Cuando el cuerpo ya pasó del tope, el cliente puede seguir mandando. Si el servidor responde el 413 y cierra
// en ese momento, el sistema operativo contesta con un RST a lo que todavía llega, 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 gradual: seguir leyendo (y tirando) lo que llegue hasta que el cliente termine. El límite
// en bytes y en tiempo (no lo fija el RFC) es del curso, para que nadie retenga la conexión mandando sin parar.
const DRENAR_MAX_BYTES = 1024 * 1024;
const DRENAR_MAX_MS = 2000;
function drenar(peticion: IncomingMessage): Promise<void> {
  return new Promise((listo) => {
    if (peticion.readableEnded || peticion.destroyed) {
      listo();
      return;
    }
    let tirados = 0;
    const fin = (): void => {
      clearTimeout(reloj);
      peticion.off("data", alLlegar);
      peticion.off("end", fin);
      peticion.off("close", fin);
      peticion.off("error", fin);
      listo();
    };
    const alLlegar = (trozo: Buffer): void => {
      tirados += trozo.length;
      if (tirados > DRENAR_MAX_BYTES) fin();
    };
    const reloj = setTimeout(fin, DRENAR_MAX_MS);
    peticion.on("data", alLlegar);
    peticion.on("end", fin);
    peticion.on("close", fin);
    peticion.on("error", fin);
    peticion.resume();
  });
}

Прочети го спокойно, защото всеки ред има причина:

  • Причината е в коментара: да продължиш да четеш и изхвърляш каквото пристигне, избягва RST. Но внимание към реда: тази версия чете преди да отговори, а RFC иска да се отговори първо. Затова 413 се бави: излиза, когато клиентът свърши да изпраща или когато се надхвърлят таваните.
  • DRENAR_MAX_BYTES (1 MiB) и DRENAR_MAX_MS (2 секунди) са таваните. Без тях сървърът би четял вечно тяло, което вече е решил да отхвърли, и би подарил връзката на всеки, който иска да я задържи, като праща без спиране. С таваните най-лошото, което се плаща, е 1 MiB и 2 секунди на връзка.
  • fin е единственият път за излизане и се вика от четири места: тялото свършва (end), връзката се затваря (close), има грешка или се надхвърлят таваните. При излизане маха всички слушатели и спира часовника.
  • peticion.resume() кара заявката да чете. Данните пристигат до слушателя data, който само брои байтовете и ги изхвърля.

С тела като това на матрицата (20 KB) проработи: 400 от 400. Но има граница, която беше измерена, не предположена. fig08_05.ts приема втори аргумент, броя на парчетата по 1000 байта (по подразбиране 20), и беше изпълнен срещу това копие с тела от 1 MB, от 1,1 MB и от 3 MB, по 100 опита всяко (defectos/drenar-antes.salida.txt):

$ node fig08_05.ts 100 1000
  100  respuesta 413
100 intentos
$ node fig08_05.ts 100 1100
    8  respuesta 413
   19  sin respuesta: ECONNRESET
   73  sin respuesta: EPIPE
100 intentos
$ node fig08_05.ts 100 3000
   23  sin respuesta: ECONNRESET
   77  sin respuesta: EPIPE
100 intentos

До 1 MB всичките 100 опита получават своя 413. Щом тялото надхвърли 1 MiB (1 048 576 байта) след тавана, сървърът спира да чете, отговаря и затваря, а клиентът, който продължава да пише, пак получава RST: надпреварата се появява отново и при 3 MB нито един опит не получава отговор. Първата поправка не затвори надпреварата: премести я отвъд тавана. Клиент, който изпраща над 1 MiB, е при това този, който най-вероятно е сгрешил много.

Как я затворихме, втория път: затварянето на етапи, което иска RFC. Нужно е онова, което RFC описва: да се отговори веднага, да се затвори само записът и да се продължи да се чете. Node не го прави сам: с Connection: close, когато свърши да отговаря, вика socket.destroySoon(), което затваря и разрушава сокета (може да се види в lib/_http_server.js; методът е документиран в net). Това е версията, която носи урокът:

// 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));
}
  • socket.destroySoon = … променя този метод само за тази връзка, така че когато свърши да отговаря, да прави единствено socket.end(): затваря записа (half-close) и оставя отворено четенето.
  • peticion.resume() продължава да чете и да изхвърля каквото клиентът изпрати. Вече няма таван в байтове: онова, което се плаща за такава връзка, е най-много 2 секунди четене.
  • Часовникът от 2 секунди разрушава връзката, дори клиентът да не затвори, за да не я задържа никой; socket.once("close", …) го спира, ако връзката е свършила по-рано.
  • 413 излиза веднага, защото leerCuerpo вика cerrarConCalma и отхвърля незабавно, без да чака нищо.

Една граница, която трябва да се каже: че http ползва destroySoon при затваряне е подробност от устройството на Node, а не обещание на документацията му. Ако по-късна версия го промени, fig08_05.ts би го видял (измерването по-долу би паднало под 400) и затова измерването е част от урока, а не само от текста.

Как проверихме, че е затворена. Същият fig08_05.ts, този път срещу revisor от fig08_01/ така, както е в урока (fig08_05.salida.txt), и после с големите тела, които счупиха първата поправка (fig08_05.limite.salida.txt):

$ node fig08_05.ts
  400  respuesta 413
400 intentos

$ for i in 1 2 3 4 5; do printf "corrida $i:"; node fig08_05.ts | grep "respuesta 413"; done
corrida 1:  400  respuesta 413
corrida 2:  400  respuesta 413
corrida 3:  400  respuesta 413
corrida 4:  400  respuesta 413
corrida 5:  400  respuesta 413
$ node fig08_05.ts 100 1000
  100  respuesta 413
100 intentos
$ node fig08_05.ts 100 3000
  100  respuesta 413
100 intentos
$ node fig08_05.ts 100 20000
  100  respuesta 413
100 intentos

Четиристотин от четиристотин във всичките шест изпълнения, срещу между 346 и 382 преди; и сто от сто с тела от 1 MB, 3 MB и 20 MB, където първата поправка даваше нула от сто. Това е контролът: със стария код измерването вижда дефекта; с новия — не. Онова, което не беше измерено: какво става с бавен клиент, който праща капка по капка повече от 2 секунди (сървърът разрушава връзката; отговорът вече е излязъл в началото и дали клиентът го е прочел преди reset-а е въпрос на собствения му ритъм), нито колко натоварване натрупват много такива връзки едновременно.

Другото семейство: стартирането. Другата повреда, тази на шлюза, нямаше нищо общо със затварянето, а със стартирането. Матрицата чакаше фиксирано време след стартирането на всеки процес (една секунда revisor, половин секунда шлюзът). Едно фиксирано чакане е залог колко отнема стартирането, а тази продължителност не е постоянна. fig08_06.ts измерва запаса: стартира шлюза 100 пъти и записва колко време отнема да отговори; в запазените изпълнения (ejercicios/ej6.salida.txt) максимумът беше 225 ms със свободна машина и 166 ms с 20 въртящи се процеса, много под 500-те ms на чакането. Тоест, почти винаги има запас, но фиксираното чакане е добро само докато стартирането е по-кратко от него, а това е точно онова, което не контролираш. За да се види повредата нарочно, ejercicios/ej6/arranque-lento.sh прави копие на една папка и забавя с 700 ms стартирането на servidor.ts и на pasarela.ts: с матрицата с фиксирани чакания излиза 40 de 41, а проверката, която се проваля, е точно тази, която се видя, pasarela sin revisor: 0 null; с матрицата на урока излиза 41 de 41.

Лекарството е това, което беше обявено: да се чака портът да приема връзки, вместо да се чака определен брой милисекунди. Това правят двете функции в началото на matriz23.ts (и за това е упражнение 6):

// Espera a que algo acepte conexiones en el puerto, hasta 10 s: en lugar de un tiempo fijo después de levantar un
// proceso, que con la máquina cargada se queda corto. Si el proceso que se levantó ya salió (por ejemplo, porque el
// puerto estaba ocupado), falla en seguida en vez de esperar a que conteste.
async function esperarPuerto(puerto: number, proceso?: ChildProcess): Promise<void> {
  for (let i = 0; i < 100; i++) {
    if (proceso !== undefined && proceso.exitCode !== null) throw new Error(`el proceso del puerto ${puerto} salió antes de escuchar`);
    if (await aceptaConexiones(puerto)) return;
    await esperar(100);
  }
  throw new Error(`el puerto ${puerto} no respondió en 10 s`);
}

// Levanta un programa del curso y espera a que escuche. Antes comprueba que el puerto esté libre: si ya lo ocupa
// un proceso viejo, esperarPuerto lo daría por bueno aunque el programa nuevo muriera con EADDRINUSE.
async function arrancar(archivo: string, puerto: number): Promise<ChildProcess> {
  if (await aceptaConexiones(puerto)) throw new Error(`puerto ${puerto} ocupado; apaga lo que lo usa`);
  const proceso = spawn(process.execPath, [archivo], { cwd: import.meta.dirname, stdio: "ignore" });
  await esperarPuerto(puerto, proceso);
  return proceso;
}

esperarPuerto пита на всеки 100 ms, до 10 секунди, и хвърля грешка, ако срокът изтече или ако стартираният процес вече е излязъл (например заради зает порт); да свърши мълчаливо би оставило матрицата да измерва никого. arrancar проверява преди това дали портът е свободен: без това един стар revisor, който вече слуша, би накарал esperarPuerto да приеме за добър нов процес, който е умрял с EADDRINUSE. С двете матрицата на урока, изпълнена 30 пъти поред с fig08_08.sh (fig08_08.salida.txt), излезе с код 0 и 30-те пъти, на машина за обикновена употреба. Това не доказва, че не може пак да се провали; доказва, че случаят, за който се знаеше, че се проваля, вече не се проваля и че сега, ако един процес не стартира, матрицата го казва вместо да измерва нещо друго.

8.15 Цял revisor

Това са всички файлове на fig08_01/. Тези, които се променят или се раждат в този урок, са първи; останалите са от предишните уроци във вече действащата им версия и са тук, за да можеш да подредиш цялата папка, дори да не си правил предишните уроци. Името на папката, fig08_01, се появява на първия ред на всеки файл, както в предишните уроци. Документът е единственият, който не го носи, защото JSON не допуска коментари.

8.15.1 Файловете, които се променят или се раждат

// fig08_01/cliente.ts — la cara cliente del revisor: consulta un servicio y clasifica lo que respondió.
import { request as pedirHttp, type IncomingMessage } from "node:http";
import { request as pedirHttps } from "node:https";
import { isIP, type LookupFunction } from "node:net";
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;
}

// Un lookup que no resuelve: devuelve siempre la dirección que destinos.ts ya validó. Node lo llama
// a veces con { all: true } (cuando prueba IPv4 e IPv6), y entonces espera una lista.
function soloA(direccion: string): LookupFunction {
  const family = isIP(direccion);
  return (_nombre, opciones, listo) => {
    if (opciones.all) listo(null, [{ address: direccion, family }]);
    else listo(null, direccion, family);
  };
}

// GET a la URL, conectándose a la dirección indicada. El nombre sigue en la URL, así que Host y, en
// https, el SNI y la verificación del certificado siguen siendo los del nombre, no los de la IP.
// agent: false abre una conexión nueva y la cierra: ninguna conexión vieja se reutiliza.
function pedir(url: URL, direccion: string, senal: AbortSignal): Promise<IncomingMessage> {
  const pedirCon = url.protocol === "https:" ? pedirHttps : pedirHttp;
  return new Promise((listo, falla) => {
    const peticion = pedirCon(url, { lookup: soloA(direccion), signal: senal, agent: false }, listo);
    peticion.on("error", falla);
    peticion.end();
  });
}

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.
  const veredicto = await revisarDestino(url);
  if (!veredicto.permitido) return { tipo: "destino-prohibido", motivo: veredicto.motivo };
  // Y la conexión va a una dirección que se acaba de validar. fetch volvería a resolver el nombre,
  // y entre esa resolución y la nuestra el nombre podría apuntar a otra parte (DNS rebinding).
  const tiempo = AbortSignal.timeout(limiteMs);
  const inicio = performance.now();
  try {
    const respuesta = await pedir(new URL(url), veredicto.direcciones[0], tiempo);
    respuesta.destroy(); // solo interesa el código y las cabeceras: el cuerpo no se lee
    return {
      tipo: "respuesta",
      codigo: respuesta.statusCode ?? 0,
      ms: Math.round(performance.now() - inicio),
      reintentarEn: segundosDeEspera(respuesta.headers["retry-after"] ?? null),
    };
  } catch (error) {
    if (tiempo.aborted) return { tipo: "tiempo-agotado" };
    const codigo = (error as { code?: string }).code;
    if (codigo === "ECONNREFUSED" || codigo === "ENOTFOUND") return { tipo: "sin-conexion" };
    return { tipo: "respuesta-invalida", motivo: error instanceof Error ? error.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);
}
// fig08_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.
//
// DNS rebinding: validar y luego dejar que fetch resuelva el nombre otra vez abre una ventana; entre
// las dos resoluciones el nombre puede pasar a apuntar a 127.0.0.1. Por eso revisarDestino devuelve
// las direcciones que validó, y cliente.ts se conecta a una de ellas sin volver a resolver.
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). Ya queda fuera de 2000::/3, así que la lista blanca lo rechaza; se deja a la vista por si algún día se ensancha la lista blanca.

// 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");
}

// Si se permite, trae las direcciones ya validadas: quien se conecte debe usar una de éstas.
export type Veredicto = { permitido: true; direcciones: string[] } | { 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, direcciones };
}
// fig08_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);
  });
}
// fig08_01/matriz23.ts — la API verificable: los 23 códigos del curso, uno por escenario, con un solo comando.
// Levanta el laboratorio y el revisor, comprueba en cada respuesta el código, las cabeceras obligatorias,
// el tipo de contenido y el cuerpo, revisa que cada respuesta esté documentada en openapi.json,
// y apaga todo. Sale con 0 si todo pasó y con 1 si algo falló.
// Uso: node matriz23.ts
import { spawn, type ChildProcess } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { readFileSync } from "node:fs";
import { connect } from "node:net";
import { setTimeout as esperar } from "node:timers/promises";

// Espera a que algo acepte conexiones en el puerto, hasta 10 s: en lugar de un tiempo fijo después de levantar un
// proceso, que con la máquina cargada se queda corto. Si el proceso que se levantó ya salió (por ejemplo, porque el
// puerto estaba ocupado), falla en seguida en vez de esperar a que conteste.
async function esperarPuerto(puerto: number, proceso?: ChildProcess): Promise<void> {
  for (let i = 0; i < 100; i++) {
    if (proceso !== undefined && proceso.exitCode !== null) throw new Error(`el proceso del puerto ${puerto} salió antes de escuchar`);
    if (await aceptaConexiones(puerto)) return;
    await esperar(100);
  }
  throw new Error(`el puerto ${puerto} no respondió en 10 s`);
}

// Levanta un programa del curso y espera a que escuche. Antes comprueba que el puerto esté libre: si ya lo ocupa
// un proceso viejo, esperarPuerto lo daría por bueno aunque el programa nuevo muriera con EADDRINUSE.
async function arrancar(archivo: string, puerto: number): Promise<ChildProcess> {
  if (await aceptaConexiones(puerto)) throw new Error(`puerto ${puerto} ocupado; apaga lo que lo usa`);
  const proceso = spawn(process.execPath, [archivo], { cwd: import.meta.dirname, stdio: "ignore" });
  await esperarPuerto(puerto, proceso);
  return proceso;
}

// ¿Hay algo que acepte conexiones TCP en el puerto?
function aceptaConexiones(puerto: number): Promise<boolean> {
  return new Promise((listo) => {
    const conexion = connect(puerto, "127.0.0.1");
    conexion.once("connect", () => { conexion.destroy(); listo(true); });
    conexion.once("error", () => listo(false));
  });
}

const R = "http://127.0.0.1:18080";
const LAB = "http://127.0.0.1:18081";
const PROBLEMA = "https://revisor.example/problemas/";
const LEE = { Authorization: "Bearer lectura-a" };
const ESCRIBE = { Authorization: "Bearer escritura-a" };
const JSON_ = { "Content-Type": "application/json" };

type Peticion = { metodo: string; ruta: string; cabeceras?: Record<string, string>; cuerpo?: string };
type Escenario = {
  codigo: number;
  nombre: string;
  operacion: [string, string]; // método y ruta tal como los documenta openapi.json
  antes?: Peticion[];
  peticion: Peticion;
  cabeceras: string[];
  tipo: "application/json" | "application/problem+json" | null; // null: sin cuerpo
  type?: string;
  revisarCuerpo?: (cuerpo: Record<string, unknown>) => string | null;
};

// La ETag cambia en cada arranque del revisor (almacen.ts): el 304 se pide con la que se lee al empezar.
const SIN_CAMBIOS: Record<string, string> = { ...LEE };

const alta = (nombre: string): string => JSON.stringify({ nombre, url: `${LAB}/codigo/200` });

const ESCENARIOS: Escenario[] = [
  { codigo: 200, nombre: "consultar un servicio", operacion: ["get", "/servicios/{id}"],
    peticion: { metodo: "GET", ruta: "/servicios/1", cabeceras: LEE }, cabeceras: ["etag"], tipo: "application/json",
    revisarCuerpo: (c) => (c.id === 1 ? null : "el id no es 1") },
  { codigo: 304, nombre: "consultar sin cambios", operacion: ["get", "/servicios/{id}"],
    peticion: { metodo: "GET", ruta: "/servicios/1", cabeceras: SIN_CAMBIOS }, cabeceras: ["etag"], tipo: null },
  { codigo: 201, nombre: "dar de alta", operacion: ["post", "/servicios"],
    peticion: { metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: alta("matriz") },
    cabeceras: ["location", "etag"], tipo: "application/json", revisarCuerpo: (c) => (c.nombre === "matriz" ? null : "no es matriz") },
  { codigo: 202, nombre: "pedir revisión", operacion: ["post", "/servicios/{id}/revisiones"],
    peticion: { metodo: "POST", ruta: "/servicios/1/revisiones", cabeceras: ESCRIBE }, cabeceras: ["location"], tipo: "application/json",
    revisarCuerpo: (c) => (c.estado === "pendiente" ? null : "no está pendiente") },
  { codigo: 204, nombre: "retirar", operacion: ["delete", "/servicios/{id}"],
    peticion: { metodo: "DELETE", ruta: "/servicios/7", cabeceras: ESCRIBE }, cabeceras: [], tipo: null },
  { codigo: 301, nombre: "ruta vieja con 301", operacion: ["post", "/viejo/servicios"],
    peticion: { metodo: "POST", ruta: "/viejo/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: alta("viejo") }, cabeceras: ["location"], tipo: null },
  { codigo: 308, nombre: "ruta vieja con 308", operacion: ["post", "/api/servicios"],
    peticion: { metodo: "POST", ruta: "/api/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: alta("api") }, cabeceras: ["location"], tipo: null },
  { codigo: 400, nombre: "cuerpo ilegible", operacion: ["post", "/servicios"],
    peticion: { metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: '{"nombre":' },
    cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "cuerpo-ilegible" },
  { codigo: 401, nombre: "sin credencial", operacion: ["get", "/servicios"],
    peticion: { metodo: "GET", ruta: "/servicios" }, cabeceras: ["www-authenticate"], tipo: "application/problem+json", type: PROBLEMA + "credencial-invalida" },
  { codigo: 403, nombre: "lectura que intenta escribir", operacion: ["delete", "/servicios/{id}"],
    peticion: { metodo: "DELETE", ruta: "/servicios/1", cabeceras: LEE }, cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "permiso-insuficiente",
    // La credencial es un secreto: el cuerpo no la repite (quedaría en cualquier bitácora que lo guarde).
    revisarCuerpo: (c) => (JSON.stringify(c).includes("lectura-a") ? "el cuerpo repite la credencial" : null) },
  { codigo: 404, nombre: "servicio que no existe", operacion: ["get", "/servicios/{id}"],
    peticion: { metodo: "GET", ruta: "/servicios/99", cabeceras: LEE }, cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "servicio-inexistente" },
  // OpenAPI no describe un método que no existe: el 405 se documenta en las operaciones que sí existen.
  { codigo: 405, nombre: "método no admitido", operacion: ["get", "/servicios/{id}"],
    peticion: { metodo: "PATCH", ruta: "/servicios/1", cabeceras: ESCRIBE }, cabeceras: ["allow"], tipo: "application/problem+json", type: "about:blank" },
  { codigo: 409, nombre: "nombre repetido", operacion: ["post", "/servicios"],
    peticion: { metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: alta("pagos") },
    cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "nombre-repetido",
    revisarCuerpo: (c) => (c.existente === "/servicios/2" ? null : "falta la extensión existente") },
  { codigo: 410, nombre: "servicio retirado", operacion: ["get", "/servicios/{id}"],
    peticion: { metodo: "GET", ruta: "/servicios/7", cabeceras: LEE }, cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "servicio-retirado" },
  { codigo: 412, nombre: "etiqueta vencida", operacion: ["put", "/servicios/{id}"],
    peticion: { metodo: "PUT", ruta: "/servicios/1", cabeceras: { ...ESCRIBE, ...JSON_, "If-Match": '"1-0"' }, cuerpo: alta("catalogo") },
    cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "etiqueta-vencida" },
  { codigo: 415, nombre: "formato no admitido", operacion: ["post", "/servicios"],
    peticion: { metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, "Content-Type": "text/plain" }, cuerpo: "matriz" },
    cabeceras: ["accept"], tipo: "application/problem+json", type: "about:blank" },
  { codigo: 422, nombre: "datos imposibles", operacion: ["post", "/servicios"],
    peticion: { metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: '{"nombre":"","url":"ftp://x"}' },
    cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "datos-invalidos",
    revisarCuerpo: (c) => (Array.isArray(c.errors) && c.errors.length === 2 ? null : "errors no trae las 2 fallas") },
  { codigo: 428, nombre: "cambio sin If-Match", operacion: ["put", "/servicios/{id}"],
    peticion: { metodo: "PUT", ruta: "/servicios/1", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: alta("catalogo") },
    cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "falta-condicion" },
  { codigo: 429, nombre: "demasiadas revisiones", operacion: ["post", "/servicios/{id}/revisiones"],
    antes: [1, 2, 3].map(() => ({ metodo: "POST", ruta: "/servicios/2/revisiones", cabeceras: ESCRIBE })),
    peticion: { metodo: "POST", ruta: "/servicios/2/revisiones", cabeceras: ESCRIBE },
    cabeceras: ["retry-after"], tipo: "application/problem+json", type: PROBLEMA + "demasiadas-revisiones" },
  { codigo: 500, nombre: "el defecto a propósito", operacion: ["get", "/servicios/{id}/informe"],
    peticion: { metodo: "GET", ruta: "/servicios/4/informe", cabeceras: LEE }, cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "error-interno",
    revisarCuerpo: (c) => (typeof c.instance === "string" && c.instance.startsWith("urn:uuid:") ? null : "instance no es urn:uuid") },
  { codigo: 502, nombre: "el servicio responde basura", operacion: ["get", "/servicios/{id}/salud"],
    peticion: { metodo: "GET", ruta: "/servicios/6/salud", cabeceras: LEE }, cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "respuesta-invalida" },
  { codigo: 503, nombre: "mantenimiento", operacion: ["get", "/servicios"],
    antes: [{ metodo: "PUT", ruta: "/mantenimiento", cabeceras: { Authorization: "Bearer admin", ...JSON_ }, cuerpo: '{"activo":true}' }],
    peticion: { metodo: "GET", ruta: "/servicios", cabeceras: LEE }, cabeceras: ["retry-after"], tipo: "application/problem+json", type: PROBLEMA + "en-mantenimiento" },
  { codigo: 504, nombre: "el servicio no responde a tiempo", operacion: ["get", "/servicios/{id}/salud"],
    antes: [{ metodo: "PUT", ruta: "/mantenimiento", cabeceras: { Authorization: "Bearer admin", ...JSON_ }, cuerpo: '{"activo":false}' }],
    peticion: { metodo: "GET", ruta: "/servicios/5/salud", cabeceras: LEE }, cabeceras: [], tipo: "application/problem+json", type: PROBLEMA + "servicio-lento" },
];

// Lo que jamás debe salir en el cuerpo de un 5xx: pilas, rutas de archivos, SQL, secretos.
const FUGAS = [/\bat \S+ \(/, /file:\/\//, /\/(home|Users)\//, /node:internal/, /\bSELECT\b.+\bFROM\b/i, /Bearer \S+/, /TypeError|ReferenceError/];

function enviar(p: Peticion): Promise<Response> {
  return fetch(R + p.ruta, { method: p.metodo, headers: p.cabeceras, body: p.cuerpo, redirect: "manual" });
}

const documento = JSON.parse(readFileSync(new URL("./openapi.json", import.meta.url), "utf8")) as {
  paths: Record<string, Record<string, { responses?: Record<string, unknown> }>>;
};

async function comprobar(e: Escenario): Promise<string[]> {
  const fallas: string[] = [];
  for (const previa of e.antes ?? []) await (await enviar(previa)).text();
  const respuesta = await enviar(e.peticion);
  const texto = await respuesta.text();
  if (respuesta.status !== e.codigo) fallas.push(`código ${respuesta.status}, se esperaba ${e.codigo}`);
  for (const cabecera of e.cabeceras) if (!respuesta.headers.has(cabecera)) fallas.push(`falta ${cabecera}`);
  const tipo = respuesta.headers.get("content-type");
  if (e.tipo === null) {
    if (texto !== "") fallas.push("trae cuerpo y no debía");
  } else if (tipo !== e.tipo) {
    fallas.push(`tipo ${tipo}, se esperaba ${e.tipo}`);
  } else {
    const cuerpo = JSON.parse(texto) as Record<string, unknown>;
    if (e.tipo === "application/problem+json") {
      if (cuerpo.type !== e.type) fallas.push(`type ${String(cuerpo.type)}, se esperaba ${e.type}`);
      if (cuerpo.status !== e.codigo) fallas.push("status del cuerpo distinto del código");
      if (typeof cuerpo.title !== "string") fallas.push("falta title");
    }
    const otra = e.revisarCuerpo?.(cuerpo) ?? null;
    if (otra !== null) fallas.push(otra);
  }
  if (e.codigo >= 500) for (const fuga of FUGAS) if (fuga.test(texto)) fallas.push(`el cuerpo filtra ${fuga}`);
  const [metodo, ruta] = e.operacion;
  if (documento.paths[ruta]?.[metodo]?.responses?.[String(e.codigo)] === undefined) {
    fallas.push(`openapi.json no documenta ${e.codigo} en ${metodo.toUpperCase()} ${ruta}`);
  }
  return fallas;
}

// Comprobaciones que el README pide además de los 23: idempotencia, SSRF y una credencial que solo
// existe en Object.prototype (regresión: con las credenciales en un objeto literal, pasaba con 200).
// Y cinco regresiones de la RFC: If-None-Match con lista, W/ y «*» (comparación débil); If-Match
// fuerte; el esquema «bearer» en minúsculas; el reto sin error con otro esquema; y un método con
// nombre de Object.prototype, que se prueba llamando a rutas.ts sin servidor (Node lo corta antes).
async function adicionales(): Promise<[string, string[]][]> {
  const resultados: [string, string[]][] = [];
  const llave = { ...ESCRIBE, ...JSON_, "Idempotency-Key": "matriz-1" };
  const a = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: llave, cuerpo: alta("idempotente") });
  const b = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: llave, cuerpo: alta("idempotente") });
  const c = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: llave, cuerpo: alta("otra-cosa") });
  const fallasLlave: string[] = [];
  if (a.status !== 201 || b.status !== 201) fallasLlave.push(`${a.status} y ${b.status}, se esperaba 201 y 201`);
  if (a.headers.get("location") !== b.headers.get("location")) fallasLlave.push("la repetición creó otro servicio");
  if (c.status !== 422) fallasLlave.push(`la misma llave con otro cuerpo dio ${c.status}, se esperaba 422`);
  resultados.push(["misma Idempotency-Key: no duplica; con otro cuerpo, 422", fallasLlave]);
  for (const url of ["http://169.254.169.254/latest/meta-data/", "http://localhost:18080/mantenimiento"]) {
    const r = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: JSON.stringify({ nombre: "ssrf", url }) });
    const cuerpo = (await r.json()) as { type?: string };
    const fallas = r.status === 422 && cuerpo.type === PROBLEMA + "destino-prohibido" ? [] : [`${r.status} ${cuerpo.type}`];
    resultados.push([`SSRF: ${url} se rechaza con 422 destino-prohibido`, fallas]);
  }
  const fallasPrototipo: string[] = [];
  for (const token of ["constructor", "__proto__", "toString"]) {
    const r = await enviar({ metodo: "GET", ruta: "/servicios", cabeceras: { Authorization: `Bearer ${token}` } });
    const cuerpo = (await r.json()) as { type?: string };
    if (r.status !== 401 || cuerpo.type !== PROBLEMA + "credencial-invalida") fallasPrototipo.push(`Bearer ${token}: ${r.status}`);
  }
  resultados.push(["credencial heredada de Object.prototype (constructor, __proto__, toString): 401", fallasPrototipo]);
  resultados.push(await condicionales());
  resultados.push(await actualizacionPerdida());
  resultados.push(await ordenDelPut());
  resultados.push(await esquemas());
  resultados.push(await metodoHeredado());
  resultados.push(await cuerpoNoObjeto());
  resultados.push(await cuerpoGrande());
  resultados.push(await consultasEnVivo());
  resultados.push(await esperaRetryAfter());
  resultados.push(await rangosSsrf());
  resultados.push(await sinRevisorNiPasarela());
  resultados.push(await altaSeguraDecide());
  resultados.push(await banco205());
  resultados.push(await etiquetaTrasReinicio());
  return resultados;
}

// Un cuerpo JSON que no es un objeto: 400 cuerpo-ilegible, y el revisor sigue respondiendo después.
async function cuerpoNoObjeto(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  for (const cuerpo of ["null", "7", '"texto"', "[]"]) {
    const r = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo });
    const problema = (await r.json()) as { type?: string };
    if (r.status !== 400 || problema.type !== PROBLEMA + "cuerpo-ilegible") fallas.push(`${cuerpo}: ${r.status} ${problema.type}`);
  }
  const vivo = await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: LEE }).then((r) => r.status, () => 0);
  if (vivo !== 200) fallas.push(`después, GET /servicios/1 dio ${vivo}`);
  return ["cuerpo JSON que no es objeto (null, 7, \"texto\", []): 400, y el revisor sigue vivo", fallas];
}

// El tope del cuerpo (16 KiB): con Content-Length y por partes, 413 about:blank; justo en el tope, 400;
// y el revisor sigue vivo después.
async function cuerpoGrande(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const grande = "a".repeat(20000);
  const conLargo = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: grande });
  const problema = (await conLargo.json()) as { type?: string; status?: number };
  if (conLargo.status !== 413 || problema.type !== "about:blank" || problema.status !== 413) {
    fallas.push(`20000 bytes con Content-Length: ${conLargo.status} ${problema.type}`);
  }
  if (conLargo.headers.get("connection") !== "close") fallas.push("el 413 no cierra la conexión");
  const porPartes = new ReadableStream<Uint8Array>({
    start(control) {
      for (let i = 0; i < 20; i++) control.enqueue(new TextEncoder().encode("a".repeat(1000)));
      control.close();
    },
  });
  const sinLargo = await fetch(R + "/servicios", { method: "POST", headers: { ...ESCRIBE, ...JSON_ }, body: porPartes, duplex: "half" } as RequestInit)
    .then(async (r) => { await r.text(); return r.status; }, (error: unknown) => `error ${String(error)}`);
  if (sinLargo !== 413) fallas.push(`20000 bytes por partes: ${sinLargo}`);
  const enElTope = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: "a".repeat(16384) });
  await enElTope.text();
  if (enElTope.status !== 400) fallas.push(`16384 bytes: ${enElTope.status}, se esperaba 400`);
  const vivo = await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: LEE }).then((r) => r.status, () => 0);
  if (vivo !== 200) fallas.push(`después, GET /servicios/1 dio ${vivo}`);
  return ["cuerpo de más de 16 KiB: 413 (con Content-Length y por partes); en el tope, 400; el revisor sigue vivo", fallas];
}

// /salud sale a la red como una revisión: 3 por credencial cada minuto; la cuarta, 429 demasiadas-consultas.
// Usa admin, que ningún escenario usa para /salud; después, escritura-a no paga lo que gastó admin.
// Y el límite se consulta antes de buscar el servicio: quien enumera ids con escritura-b llega al 429.
async function consultasEnVivo(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const codigos: number[] = [];
  let ultima: Response | undefined;
  for (let i = 0; i < 4; i++) {
    ultima = await enviar({ metodo: "GET", ruta: "/servicios/1/salud", cabeceras: { Authorization: "Bearer admin" } });
    codigos.push(ultima.status);
    if (i < 3) await ultima.text();
  }
  const problema = ultima === undefined ? {} : ((await ultima.json()) as { type?: string });
  if (codigos.join(" ") !== "200 200 200 429") fallas.push(`códigos ${codigos.join(" ")}, se esperaba 200 200 200 429`);
  if (problema.type !== PROBLEMA + "demasiadas-consultas") fallas.push(`type ${problema.type}`);
  if (ultima?.headers.get("retry-after") === null) fallas.push("el 429 no trae Retry-After");
  const otra = await enviar({ metodo: "GET", ruta: "/servicios/1/salud", cabeceras: ESCRIBE });
  await otra.text();
  if (otra.status !== 200) fallas.push(`escritura-a dio ${otra.status} después del 429 de admin, se esperaba 200`);
  const enumeracion: number[] = [];
  // Ids del 11 al 20: no existen y nadie más los pidió, así que solo un límite por credencial los frena.
  for (let id = 11; id <= 20; id++) {
    const r = await enviar({ metodo: "POST", ruta: `/servicios/${id}/revisiones`, cabeceras: { Authorization: "Bearer escritura-b" } });
    await r.text();
    enumeracion.push(r.status);
  }
  if (!enumeracion.includes(429)) fallas.push(`enumerar los ids 11 a 20 con escritura-b dio ${enumeracion.join(" ")}, sin ningún 429`);
  return ["429 por credencial: la cuarta consulta en vivo, 429; otra credencial no lo paga; enumerar ids llega al 429", fallas];
}

// Retry-After estricto (RFC 9110, sección 10.2.3): segundos o HTTP-date en GMT; lo demás se ignora.
async function esperaRetryAfter(): Promise<[string, string[]]> {
  const { segundosDeEspera } = await import("./cliente.ts");
  const fallas: string[] = [];
  for (const valor of ["1.5", "-1", "12 30", "2100-01-01T00:00:00Z"]) {
    const s = segundosDeEspera(valor);
    if (s !== null) fallas.push(`${JSON.stringify(valor)} dio ${s}, se esperaba ignorarlo`);
  }
  if (segundosDeEspera("120") !== 120) fallas.push("120 no dio 120");
  // En una zona que no es GMT, para que una fecha leída como hora local se note (UTC−6: 21600 s de más).
  const zona = process.env.TZ;
  process.env.TZ = "America/Mexico_City";
  const imf = segundosDeEspera("Wed, 01 Jan 2070 00:00:00 GMT");
  const formas = [segundosDeEspera("Wednesday, 01-Jan-70 00:00:00 GMT"), segundosDeEspera("Wed Jan  1 00:00:00 2070")];
  if (zona === undefined) delete process.env.TZ;
  else process.env.TZ = zona;
  if (imf === null || formas.some((s) => s === null || Math.abs(s - imf) > 1)) {
    fallas.push(`la misma fecha en sus tres formas dio ${imf}, ${formas.join(", ")}`);
  }
  return ["Retry-After: 1.5, -1 y «12 30» se ignoran; las tres formas de HTTP-date dan lo mismo (GMT)", fallas];
}

// Rangos que faltaban: CGNAT, NAT64 con una IPv4 de enlace local adentro, multicast IPv4 e IPv6.
async function rangosSsrf(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const urls = [
    "http://100.100.100.200/latest/meta-data/", "http://[64:ff9b::a9fe:a9fe]/", "http://224.0.0.1/", "http://[ff02::1]/",
    "http://[2002:a9fe:a9fe::]/", "http://[::7f00:1]/", "http://[64:ff9b:1::a9fe:a9fe]/", "http://240.0.0.1/",
    "http://198.18.0.1/", "http://192.0.2.1/", "http://[2001:db8::1]/", "http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/",
  ];
  for (const [i, url] of urls.entries()) {
    const nombre = `rango-${i + 1}`; // un nombre por URL: si una pasa, la siguiente no choca con un 409
    const r = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ }, cuerpo: JSON.stringify({ nombre, url }) });
    const problema = (await r.json()) as { type?: string };
    if (r.status !== 422 || problema.type !== PROBLEMA + "destino-prohibido") fallas.push(`${url}: ${r.status}`);
  }
  // Y la otra dirección: 6to4 con una IPv4 pública adentro (8.8.8.8) sí se acepta. Sin esto, una lista que
  // rechazara todo pasaría la comprobación.
  const publica = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ },
    cuerpo: JSON.stringify({ nombre: "rango-publico", url: "http://[2002:808:808::1]/" }) });
  await publica.text();
  if (publica.status !== 201) fallas.push(`http://[2002:808:808::1]/: ${publica.status}, se esperaba 201`);
  return ["SSRF: 12 direcciones de uso especial (CGNAT, NAT64, 6to4, compatible, 64:ff9b:1::/48, 240/4, 198.18/15, TEST-NET, 2001:db8::, Teredo, multicast): 422; 6to4 con IPv4 pública: 201", fallas];
}

// Corre un programa del curso y devuelve su salida completa y su código de salida.
function correr(archivo: string, argumentos: string[], topeMs = 120000): Promise<{ salida: string; codigo: number | null }> {
  return new Promise((listo) => {
    const hijo = spawn(process.execPath, [archivo, ...argumentos], { cwd: import.meta.dirname });
    // Un programa que se queda esperando para siempre no cuelga la matriz: se corta y su código queda en null.
    const corte = setTimeout(() => hijo.kill(), topeMs);
    hijo.on("close", () => clearTimeout(corte));
    let salida = "";
    hijo.stdout.on("data", (trozo: Buffer) => { salida += trozo.toString(); });
    hijo.stderr.on("data", (trozo: Buffer) => { salida += trozo.toString(); });
    hijo.on("close", (codigo) => listo({ salida, codigo }));
  });
}

// La pasarela y alta-segura.ts cuando no hay quien responda (lección 6). Apaga el revisor que levantó
// esta matriz, prueba, y lo vuelve a levantar.
async function sinRevisorNiPasarela(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  // 0. Con el revisor vivo, la pasarela reenvía Content-Length (y los mismos bytes) en una respuesta que no pierde.
  const primera = await arrancar("pasarela.ts", 18082);
  const ok = await fetch("http://127.0.0.1:18082/servicios", { method: "POST", headers: { ...ESCRIBE, ...JSON_ }, body: alta("con-largo") });
  const cuerpoOk = await ok.text();
  if (ok.status !== 201 || ok.headers.get("content-length") !== String(Buffer.byteLength(cuerpoOk))) {
    fallas.push(`pasarela con revisor: ${ok.status}, Content-Length ${ok.headers.get("content-length")} de ${Buffer.byteLength(cuerpoOk)} bytes`);
  }
  const apagadaPrimera = new Promise((listo) => primera.once("exit", listo));
  primera.kill();
  await apagadaPrimera;
  const viejo = procesos[1];
  const salida = new Promise((listo) => viejo.once("exit", listo));
  viejo.kill();
  await salida;
  const pasarela = await arrancar("pasarela.ts", 18082);
  // 1. La pasarela no se cae: 502 Problem Details, y sigue escuchando.
  const r = await fetch("http://127.0.0.1:18082/servicios", { method: "POST", headers: { ...ESCRIBE, ...JSON_ }, body: alta("sin-revisor") })
    .then(async (x) => ({ status: x.status, tipo: x.headers.get("content-type") }), () => ({ status: 0, tipo: null }));
  if (r.status !== 502 || r.tipo !== "application/problem+json") fallas.push(`pasarela sin revisor: ${r.status} ${r.tipo}`);
  await esperar(200);
  const pasarelaViva = pasarela.exitCode === null && pasarela.signalCode === null;
  if (!pasarelaViva) fallas.push("la pasarela se cayó");
  // 2. Cuatro 502: termina con mensaje y sin esperar después del último intento.
  const con502 = await correr("alta-segura.ts", ["sin-revisor", `${LAB}/codigo/200`]);
  const despues = con502.salida.split("intento 4:")[1] ?? "";
  if (despues.includes("espero")) fallas.push("después del intento 4 todavía espera");
  if (!con502.salida.includes("se agotaron los 4 intentos") || con502.codigo !== 1) fallas.push(`con 502: salida ${con502.codigo}`);
  // Si se cayó, ya no hay a quién esperar.
  if (pasarela.exitCode === null && pasarela.signalCode === null) {
    const apagada = new Promise((listo) => pasarela.once("exit", listo));
    pasarela.kill();
    await apagada;
  }
  // 3. Sin pasarela: reintenta los cuatro intentos y termina sin la pila de Node.
  const rechazada = await correr("alta-segura.ts", ["sin-pasarela", `${LAB}/codigo/200`]);
  const sinRespuesta = rechazada.salida.match(/sin respuesta \(ECONNREFUSED\)/g)?.length ?? 0;
  if (sinRespuesta !== 4) fallas.push(`conexión rechazada: ${sinRespuesta} intentos sin respuesta, se esperaban 4`);
  if (/TypeError|node:internal|triggerUncaughtException/.test(rechazada.salida)) fallas.push("conexión rechazada: imprime la pila de Node");
  procesos[1] = await arrancar("servidor.ts", 18080);
  return ["la pasarela reenvía Content-Length; sin revisor responde 502 y sigue viva; alta-segura termina al agotar los intentos, también sin conexión", fallas];
}

// alta-segura.ts decide qué hacer con lo que recibe (lección 6). Un servidor de mentira en el 18082 (donde
// estaría la pasarela) responde lo que se le dice y cuenta las peticiones: un 4xx sale con 1 y no se repite,
// un Retry-After razonable se obedece y uno largo no se insiste, un servidor mudo se corta por tiempo y el 409
// de «llave en curso» no es final. Corre cuando la pasarela ya está apagada (sinRevisorNiPasarela la apagó).
type Respuesta = [number, Record<string, string>, string] | null; // null: el servidor no contesta
async function altaSeguraDecide(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const problema = (tipo: string, estado: number): string => JSON.stringify({ type: PROBLEMA + tipo, title: tipo, status: estado });
  async function caso(nombre: string, respuestas: Respuesta[], argumentos: string[], limiteMs?: number) {
    let peticiones = 0;
    const servidor = createServer((peticion, respuesta) => {
      peticion.resume();
      const que = respuestas[Math.min(peticiones++, respuestas.length - 1)];
      if (que === null) return; // se queda callado
      respuesta.writeHead(que[0], { "Content-Type": "application/problem+json", ...que[1] });
      respuesta.end(que[2] + "\n");
    });
    await new Promise<void>((listo) => servidor.listen(18082, "127.0.0.1", listo));
    if (limiteMs !== undefined) process.env.ALTA_LIMITE_MS = String(limiteMs);
    const inicio = Date.now();
    const corrida = await correr("alta-segura.ts", [nombre, `${LAB}/codigo/200`, ...argumentos], 20000);
    const ms = Date.now() - inicio;
    delete process.env.ALTA_LIMITE_MS;
    servidor.closeAllConnections();
    await new Promise((listo) => servidor.close(listo));
    return { ...corrida, ms, peticiones };
  }
  // 1. Un 4xx final: sale con 1, lo dice y no repite.
  const rechazo = await caso("rechazada", [[422, {}, problema("datos-invalidos", 422)]], []);
  if (rechazo.codigo !== 1 || rechazo.peticiones !== 1 || !rechazo.salida.includes("respondió 422")) {
    fallas.push(`4xx: salida ${rechazo.codigo}, ${rechazo.peticiones} peticiones`);
  }
  // 2. Retry-After largo: no insiste, y no espera.
  const largo = await caso("larga", [[503, { "Retry-After": "120" }, problema("en-mantenimiento", 503)]], []);
  if (largo.codigo !== 1 || largo.peticiones !== 1 || largo.ms > 3000 || !largo.salida.includes("no se reintenta")) {
    fallas.push(`Retry-After de 120 s: salida ${largo.codigo}, ${largo.peticiones} peticiones, ${largo.ms} ms`);
  }
  // 3. Retry-After razonable: espera lo que se pidió y el segundo intento pasa.
  const corto = await caso("corta", [[503, { "Retry-After": "1" }, problema("en-mantenimiento", 503)], [201, { "Content-Type": "application/json" }, "{}"]], []);
  if (corto.codigo !== 0 || corto.peticiones !== 2 || corto.ms < 1000) {
    fallas.push(`Retry-After de 1 s: salida ${corto.codigo}, ${corto.peticiones} peticiones, ${corto.ms} ms`);
  }
  // 4. Un servidor mudo: cada intento se corta por tiempo y el programa termina con 1.
  const mudo = await caso("muda", [null], [], 200);
  const cortes = mudo.salida.match(/tiempo agotado: 200 ms/g)?.length ?? 0;
  if (mudo.codigo !== 1 || cortes !== 4 || mudo.peticiones !== 4) {
    fallas.push(`servidor mudo: salida ${mudo.codigo}, ${cortes} cortes, ${mudo.peticiones} peticiones`);
  }
  // 5. El 409 de «llave en curso» se reintenta; el de «nombre repetido» no.
  const enCurso = await caso("encurso", [[409, {}, problema("llave-en-curso", 409)], [201, { "Content-Type": "application/json" }, "{}"]], []);
  if (enCurso.codigo !== 0 || enCurso.peticiones !== 2) fallas.push(`409 llave-en-curso: salida ${enCurso.codigo}, ${enCurso.peticiones} peticiones`);
  const repetido = await caso("repetida", [[409, {}, problema("nombre-repetido", 409)]], []);
  if (repetido.codigo !== 1 || repetido.peticiones !== 1) fallas.push(`409 nombre-repetido: salida ${repetido.codigo}, ${repetido.peticiones} peticiones`);
  return ["alta-segura decide: un 4xx sale con 1; obedece Retry-After si es razonable y no insiste si pide mucho; se corta por tiempo ante un servidor mudo; el 409 de llave en curso se reintenta y el de nombre repetido no", fallas];
}

// RFC 9110, sección 15.3.6: un servidor no genera contenido en un 205. El banco responde sin cuerpo, con
// «Content-Length: 0» (el 205 no se enmarca solo, RFC 9112 sección 6.3) y sin «Transfer-Encoding».
async function banco205(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const r = await fetch(`${LAB}/codigo/205`);
  const cuerpo = await r.text();
  if (r.status !== 205) fallas.push(`estado ${r.status}, se esperaba 205`);
  if (cuerpo !== "") fallas.push(`cuerpo de ${Buffer.byteLength(cuerpo)} bytes`);
  if (r.headers.get("transfer-encoding") !== null) fallas.push(`Transfer-Encoding: ${r.headers.get("transfer-encoding")}`);
  if (r.headers.get("content-length") !== "0") fallas.push(`Content-Length: ${r.headers.get("content-length")}`);
  return ["el banco responde el 205 sin cuerpo, con Content-Length: 0 y sin Transfer-Encoding", fallas];
}

// RFC 9110, sección 8.8.1: tras reiniciar el revisor, la etiqueta vieja no coincide aunque el id y la
// versión sean los mismos (el servicio 3 nunca cambia). Reinicia el revisor que levantó esta matriz.
async function etiquetaTrasReinicio(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const B = { Authorization: "Bearer escritura-b" };
  const antes = (await enviar({ metodo: "GET", ruta: "/servicios/3", cabeceras: B })).headers.get("etag") ?? "";
  const viejo = procesos[1];
  const salida = new Promise((listo) => viejo.once("exit", listo));
  viejo.kill();
  await salida;
  procesos[1] = await arrancar("servidor.ts", 18080);
  const r = await enviar({ metodo: "GET", ruta: "/servicios/3", cabeceras: { ...B, "If-None-Match": antes } });
  await r.text();
  const despues = r.headers.get("etag");
  if (r.status !== 200) fallas.push(`If-None-Match: ${antes} tras reiniciar dio ${r.status}, se esperaba 200`);
  if (despues === antes) fallas.push(`la etiqueta se repitió: ${antes}`);
  return ["ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)", fallas];
}

// RFC 9110, secciones 13.1.1, 13.1.2 y 8.8.3.2. Lee la etiqueta vigente del servicio 1 y la usa.
async function condicionales(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const vigente = (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: LEE })).headers.get("etag") ?? "";
  const otra = vigente.replace(/"$/, '9"');
  const leer = async (condicion: string, esperado: number): Promise<void> => {
    const r = await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: { ...LEE, "If-None-Match": condicion } });
    await r.text();
    if (r.status !== esperado) fallas.push(`If-None-Match: ${condicion} dio ${r.status}, se esperaba ${esperado}`);
  };
  await leer(`W/${vigente}`, 304);
  await leer(`"9-9", ${vigente}`, 304);
  await leer("*", 304);
  await leer(`"9-9", W/${otra}`, 200);
  const cambiar = async (condicion: string, esperado: number): Promise<void> => {
    const r = await enviar({ metodo: "PUT", ruta: "/servicios/1", cabeceras: { ...ESCRIBE, ...JSON_, "If-Match": condicion }, cuerpo: alta("catalogo") });
    await r.text();
    if (r.status !== esperado) fallas.push(`If-Match: ${condicion} dio ${r.status}, se esperaba ${esperado}`);
  };
  await cambiar(`W/${vigente}`, 412);
  await cambiar(`"9-9", ${vigente}`, 200);
  await cambiar("*", 200);
  return ["If-None-Match con lista, W/ y * (débil): 304; If-Match con W/: 412; con lista o *: 200", fallas];
}

// La actualización perdida con dos PUT a la vez y la misma If-Match. La lenta manda su cuerpo después
// de que la rápida ya cambió el servicio; el revisor compara la versión otra vez al escribir, así que la
// lenta recibe 412 y el servicio queda como lo dejó la rápida.
async function actualizacionPerdida(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const vigente = (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: ESCRIBE })).headers.get("etag") ?? "";
  const cabeceras = { ...ESCRIBE, ...JSON_, "If-Match": vigente };
  let soltar = (): void => {};
  const suelto = new Promise<void>((listo) => { soltar = listo; });
  // El primer pedazo sale enseguida (fetch no manda las cabeceras hasta tener algo del cuerpo); el resto,
  // hasta que la rápida terminó.
  const texto = JSON.stringify({ nombre: "catalogo", url: `${LAB}/codigo/200`, limite_ms: 1000 });
  const codificar = (pedazo: string): Uint8Array => new TextEncoder().encode(pedazo);
  const cuerpo = new ReadableStream<Uint8Array>({
    async start(control) {
      control.enqueue(codificar(texto.slice(0, 10)));
      await suelto;
      control.enqueue(codificar(texto.slice(10)));
      control.close();
    },
  });
  const lenta = fetch(R + "/servicios/1", { method: "PUT", headers: cabeceras, body: cuerpo, duplex: "half" } as RequestInit);
  await esperar(300);
  const rapida = await enviar({ metodo: "PUT", ruta: "/servicios/1", cabeceras,
    cuerpo: JSON.stringify({ nombre: "catalogo", url: `${LAB}/codigo/200`, limite_ms: 3000 }) });
  await rapida.text();
  soltar();
  const respuestaLenta = await lenta;
  await respuestaLenta.text();
  const final = (await (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: ESCRIBE })).json()) as { limite_ms?: number };
  if (rapida.status !== 200) fallas.push(`la rápida dio ${rapida.status}, se esperaba 200`);
  if (respuestaLenta.status !== 412) fallas.push(`la lenta dio ${respuestaLenta.status}, se esperaba 412`);
  if (final.limite_ms !== 3000) fallas.push(`el servicio quedó con limite_ms ${final.limite_ms}, se esperaba 3000 (el de la rápida)`);
  return ["actualización perdida: dos PUT a la vez con la misma If-Match: 200 y 412, gana la primera que escribe", fallas];
}

// El orden del PUT (RFC 9110, sección 13.2.1): 415 antes que If-Match. Y si otro retira el servicio
// mientras el PUT espera su cuerpo, 410 y no 412.
async function ordenDelPut(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  const texto = { ...ESCRIBE, "Content-Type": "text/plain" };
  for (const condicion of ['"1-0-0"', undefined]) {
    const cabeceras: Record<string, string> = condicion === undefined ? texto : { ...texto, "If-Match": condicion };
    const r = await enviar({ metodo: "PUT", ruta: "/servicios/1", cabeceras, cuerpo: "catalogo" });
    await r.text();
    if (r.status !== 415) fallas.push(`text/plain ${condicion === undefined ? "sin If-Match" : "con etiqueta vieja"}: ${r.status}, se esperaba 415`);
  }
  const alta = await enviar({ metodo: "POST", ruta: "/servicios", cabeceras: { ...ESCRIBE, ...JSON_ },
    cuerpo: JSON.stringify({ nombre: "efimero", url: `${LAB}/codigo/200` }) });
  await alta.text();
  const ruta = alta.headers.get("location") ?? "/servicios/0";
  const etiquetaLeida = alta.headers.get("etag") ?? "";
  const datos = JSON.stringify({ nombre: "efimero", url: `${LAB}/codigo/200` });
  let soltar = (): void => {};
  const suelto = new Promise<void>((listo) => { soltar = listo; });
  const cuerpo = new ReadableStream<Uint8Array>({
    async start(control) {
      control.enqueue(new TextEncoder().encode(datos.slice(0, 10)));
      await suelto;
      control.enqueue(new TextEncoder().encode(datos.slice(10)));
      control.close();
    },
  });
  const lento = fetch(R + ruta, { method: "PUT", headers: { ...ESCRIBE, ...JSON_, "If-Match": etiquetaLeida }, body: cuerpo, duplex: "half" } as RequestInit);
  await esperar(300);
  const retiro = await enviar({ metodo: "DELETE", ruta, cabeceras: ESCRIBE });
  await retiro.text();
  soltar();
  const respuestaLenta = await lento;
  const problema = (await respuestaLenta.json()) as { type?: string };
  if (retiro.status !== 204) fallas.push(`el retiro dio ${retiro.status}`);
  if (respuestaLenta.status !== 410 || problema.type !== PROBLEMA + "servicio-retirado") {
    fallas.push(`el PUT lento dio ${respuestaLenta.status} ${problema.type}, se esperaba 410 servicio-retirado`);
  }
  return ["orden del PUT: text/plain da 415 con etiqueta vieja o sin ella; retirado en el hueco, 410", fallas];
}

// RFC 9110, sección 11.1, y RFC 6750, sección 3.1.
async function esquemas(): Promise<[string, string[]]> {
  const fallas: string[] = [];
  for (const valor of ["bearer lectura-a", "BEARER lectura-a"]) {
    const r = await enviar({ metodo: "GET", ruta: "/servicios", cabeceras: { Authorization: valor } });
    await r.text();
    if (r.status !== 200) fallas.push(`${valor}: ${r.status}, se esperaba 200`);
  }
  const retos: [string, string][] = [
    ["Basic bGVjdHVyYS1hOg==", 'Bearer realm="revisor"'],
    ["Bearer inventada", 'Bearer realm="revisor", error="invalid_token"'],
  ];
  for (const [valor, esperado] of retos) {
    const r = await enviar({ metodo: "GET", ruta: "/servicios", cabeceras: { Authorization: valor } });
    await r.text();
    const reto = r.headers.get("www-authenticate");
    if (r.status !== 401 || reto !== esperado) fallas.push(`${valor}: ${r.status} con ${reto}, se esperaba 401 con ${esperado}`);
  }
  return ["esquema sin distinguir mayúsculas (bearer: 200); Basic: reto sin error=\"invalid_token\"", fallas];
}

// Node responde 400 a un método que no conoce antes de llamar al revisor, así que esto no se puede
// provocar por la red: se llama a atender() directo, con una petición y una respuesta de mentira.
async function metodoHeredado(): Promise<[string, string[]]> {
  const { atender } = await import("./rutas.ts");
  const fallas: string[] = [];
  // rutas.ts escribe cada petición en la terminal del revisor; aquí correría dentro de la matriz.
  const registrar = console.log;
  console.log = () => {};
  for (const method of ["constructor", "toString", "__proto__"]) {
    const peticion = { method, url: "/servicios/1", headers: { authorization: "Bearer escritura-a" } };
    const codigo = await new Promise<string>((listo) => {
      const respuesta = {
        req: peticion,
        statusCode: 0,
        writeHead(codigo: number) { this.statusCode = codigo; return this; },
        end() { listo(String(this.statusCode)); },
      };
      atender(peticion as unknown as IncomingMessage, respuesta as unknown as ServerResponse)
        .catch((error: unknown) => listo(`excepción ${String(error)}`));
    });
    if (codigo !== "405") fallas.push(`${method}: ${codigo}`);
  }
  console.log = registrar;
  return ["método heredado de Object.prototype (constructor, toString, __proto__): 405", fallas];
}

// Si algo ya escucha en los puertos del curso, la corrida mediría a otro programa (o esperaría para siempre a un
// hijo que no pudo arrancar): se detiene en seguida.
for (const puerto of [18080, 18081, 18082]) {
  if (await aceptaConexiones(puerto)) {
    console.error(`puerto ${puerto} ocupado; apaga lo que lo usa`);
    process.exit(2);
  }
}

const procesos: ChildProcess[] = [
  spawn(process.execPath, ["laboratorio.ts"], { cwd: import.meta.dirname, stdio: "ignore" }),
  spawn(process.execPath, ["servidor.ts"], { cwd: import.meta.dirname, stdio: "ignore" }),
];
let fallidos = 0;
let extra = 0;
let abortada = false;
try {
  await esperarPuerto(18081, procesos[0]);
  await esperarPuerto(18080, procesos[1]);
  SIN_CAMBIOS["If-None-Match"] = (await enviar({ metodo: "GET", ruta: "/servicios/1", cabeceras: LEE })).headers.get("etag") ?? "";
  for (const escenario of ESCENARIOS) {
    const fallas = await comprobar(escenario);
    if (fallas.length > 0) fallidos++;
    console.log(`${fallas.length === 0 ? "ok   " : "FALLA"} ${escenario.codigo} ${escenario.nombre}${fallas.length > 0 ? ": " + fallas.join("; ") : ""}`);
  }
  const resultados = await adicionales();
  extra = resultados.length;
  for (const [nombre, fallas] of resultados) {
    if (fallas.length > 0) fallidos++;
    console.log(`${fallas.length === 0 ? "ok   " : "FALLA"} ${nombre}${fallas.length > 0 ? ": " + fallas.join("; ") : ""}`);
  }
} catch (error) {
  // Un proceso no arrancó (o no contestó a tiempo): no hay resumen que dar, y se dice por qué.
  console.error(`no se pudo correr la matriz: ${error instanceof Error ? error.message : String(error)}`);
  abortada = true;
} finally {
  for (const proceso of procesos) proceso.kill();
}
if (abortada) process.exit(1);
const total = ESCENARIOS.length + extra;
console.log(`\n${total - fallidos} de ${total} comprobaciones pasaron (${ESCENARIOS.length} escenarios de código y ${extra} adicionales).`);
process.exitCode = fallidos === 0 ? 0 : 1;

fig08_01/openapi.json (JSON не допуска коментари, така че този файл не започва с обичайния ред):

{
  "openapi": "3.1.1",
  "info": {
    "title": "API del revisor",
    "version": "1.0.0",
    "summary": "Contrato verificable del curso de HTTP de Hábil.",
    "description": "info.version es la versión de ESTE documento, no la de la API. La API vigente vive en la raíz (/servicios); la v1 está desaprobada y la v0 retirada.",
    "license": {
      "name": "CC BY-SA 4.0",
      "identifier": "CC-BY-SA-4.0"
    }
  },
  "servers": [
    {
      "url": "http://127.0.0.1:18080"
    }
  ],
  "security": [
    {
      "credencial": []
    }
  ],
  "paths": {
    "/servicios": {
      "get": {
        "summary": "Listar los servicios del equipo",
        "responses": {
          "200": {
            "description": "La lista.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lista"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          }
        }
      },
      "post": {
        "summary": "Dar de alta un servicio",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DatosServicio"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Creado.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              },
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servicio"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "400": {
            "$ref": "#/components/responses/E400"
          },
          "403": {
            "$ref": "#/components/responses/E403"
          },
          "409": {
            "$ref": "#/components/responses/E409"
          },
          "415": {
            "$ref": "#/components/responses/E415"
          },
          "422": {
            "$ref": "#/components/responses/E422"
          },
          "413": {
            "$ref": "#/components/responses/E413"
          }
        }
      }
    },
    "/servicios/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "summary": "Consultar un servicio",
        "parameters": [
          {
            "name": "If-None-Match",
            "in": "header",
            "schema": {
              "type": "string"
            },
            "description": "«*» o una lista de etiquetas separadas por comas. Se compara en débil (RFC 9110, secciones 13.1.2 y 8.8.3.2): W/\"1-1\" coincide con \"1-1\". Si alguna coincide, 304."
          }
        ],
        "responses": {
          "200": {
            "description": "El servicio.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servicio"
                }
              }
            }
          },
          "304": {
            "description": "No cambió; sin cuerpo.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          }
        }
      },
      "put": {
        "summary": "Cambiar un servicio sin pisar otro cambio",
        "parameters": [
          {
            "name": "If-Match",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "«*» o una lista de etiquetas separadas por comas. Se compara en fuerte (RFC 9110, secciones 13.1.1 y 8.8.3.2): una etiqueta W/ nunca coincide. «*» coincide si el servicio existe. Si ninguna coincide, 412."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DatosServicio"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cambiado.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Servicio"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "400": {
            "$ref": "#/components/responses/E400"
          },
          "403": {
            "$ref": "#/components/responses/E403"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "409": {
            "$ref": "#/components/responses/E409"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          },
          "412": {
            "$ref": "#/components/responses/E412"
          },
          "415": {
            "$ref": "#/components/responses/E415"
          },
          "422": {
            "$ref": "#/components/responses/E422"
          },
          "428": {
            "$ref": "#/components/responses/E428"
          },
          "413": {
            "$ref": "#/components/responses/E413"
          }
        }
      },
      "delete": {
        "summary": "Retirar un servicio",
        "responses": {
          "204": {
            "description": "Retirado; sin cuerpo."
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "403": {
            "$ref": "#/components/responses/E403"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          }
        }
      }
    },
    "/servicios/{id}/revisiones": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "post": {
        "summary": "Pedir una revisión",
        "responses": {
          "202": {
            "description": "Aceptada; el seguimiento está en Location.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Revision"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "403": {
            "$ref": "#/components/responses/E403"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          },
          "422": {
            "$ref": "#/components/responses/E422"
          },
          "429": {
            "$ref": "#/components/responses/E429"
          }
        }
      }
    },
    "/servicios/{id}/salud": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "summary": "Consultar el servicio en este momento (el revisor hace de pasarela)",
        "responses": {
          "200": {
            "description": "Lo que respondió el servicio, sea cual sea su código.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Salud"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          },
          "422": {
            "$ref": "#/components/responses/E422"
          },
          "502": {
            "$ref": "#/components/responses/E502"
          },
          "504": {
            "$ref": "#/components/responses/E504"
          },
          "429": {
            "$ref": "#/components/responses/E429Salud"
          }
        }
      }
    },
    "/servicios/{id}/informe": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "summary": "Resumen de las revisiones (trae un defecto a propósito: sin revisiones responde 500)",
        "responses": {
          "200": {
            "description": "El resumen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Informe"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          },
          "500": {
            "$ref": "#/components/responses/E500"
          }
        }
      }
    },
    "/revisiones/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "summary": "Seguimiento de una revisión",
        "responses": {
          "200": {
            "description": "La revisión; mientras está pendiente trae Retry-After.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Revision"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          }
        }
      }
    },
    "/mantenimiento": {
      "put": {
        "summary": "Encender o apagar el mantenimiento (solo admin)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "activo"
                ],
                "properties": {
                  "activo": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Hecho."
          },
          "400": {
            "$ref": "#/components/responses/E400"
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "403": {
            "$ref": "#/components/responses/E403"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "415": {
            "$ref": "#/components/responses/E415"
          },
          "422": {
            "$ref": "#/components/responses/E422"
          },
          "413": {
            "$ref": "#/components/responses/E413"
          }
        }
      }
    },
    "/viejo/servicios": {
      "get": {
        "summary": "Ruta vieja",
        "security": [],
        "responses": {
          "301": {
            "description": "Se mudó a /servicios. Un POST seguido con 301 suele volverse GET.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              }
            }
          }
        }
      },
      "post": {
        "summary": "Ruta vieja",
        "security": [],
        "responses": {
          "301": {
            "description": "Se mudó a /servicios. Un POST seguido con 301 suele volverse GET.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              }
            }
          }
        }
      }
    },
    "/api/servicios": {
      "get": {
        "summary": "Ruta vieja",
        "security": [],
        "responses": {
          "308": {
            "description": "Se mudó a /servicios; el método y el cuerpo se conservan.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              }
            }
          }
        }
      },
      "post": {
        "summary": "Ruta vieja",
        "security": [],
        "responses": {
          "308": {
            "description": "Se mudó a /servicios; el método y el cuerpo se conservan.",
            "headers": {
              "Location": {
                "$ref": "#/components/headers/Location"
              }
            }
          }
        }
      }
    },
    "/v1/servicios": {
      "get": {
        "summary": "Versión 1, desaprobada",
        "deprecated": true,
        "responses": {
          "200": {
            "description": "La lista en la forma de la v1.",
            "headers": {
              "Deprecation": {
                "$ref": "#/components/headers/Deprecation"
              },
              "Sunset": {
                "$ref": "#/components/headers/Sunset"
              },
              "Link": {
                "$ref": "#/components/headers/Link"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          }
        }
      }
    },
    "/v1/servicios/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "integer",
            "minimum": 1
          }
        }
      ],
      "get": {
        "summary": "Versión 1, desaprobada",
        "deprecated": true,
        "responses": {
          "200": {
            "description": "El servicio en la forma de la v1.",
            "headers": {
              "Deprecation": {
                "$ref": "#/components/headers/Deprecation"
              },
              "Sunset": {
                "$ref": "#/components/headers/Sunset"
              },
              "Link": {
                "$ref": "#/components/headers/Link"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/E401"
          },
          "405": {
            "$ref": "#/components/responses/E405"
          },
          "503": {
            "$ref": "#/components/responses/E503"
          },
          "404": {
            "$ref": "#/components/responses/E404"
          },
          "410": {
            "$ref": "#/components/responses/E410"
          }
        }
      }
    },
    "/v0/servicios": {
      "get": {
        "summary": "Versión 0, retirada",
        "deprecated": true,
        "security": [],
        "responses": {
          "410": {
            "$ref": "#/components/responses/E410"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "credencial": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Patrón documentado por esta API, NO un estándar: draft-ietf-httpapi-idempotency-key-header-07 venció el 18 de abril de 2026 sin publicarse como RFC.",
        "schema": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "headers": {
      "Location": {
        "schema": {
          "type": "string",
          "format": "uri-reference"
        }
      },
      "ETag": {
        "description": "Etiqueta fuerte \"id-versión-arranque\". Cambia con cada cambio del servicio y con cada arranque del revisor (RFC 9110, sección 8.8.1).",
        "schema": {
          "type": "string"
        }
      },
      "Retry-After": {
        "description": "Segundos o fecha HTTP (RFC 9110, sección 10.2.3).",
        "schema": {
          "type": "string"
        }
      },
      "WWW-Authenticate": {
        "schema": {
          "type": "string"
        }
      },
      "Allow": {
        "schema": {
          "type": "string"
        }
      },
      "Accept": {
        "schema": {
          "type": "string"
        }
      },
      "Deprecation": {
        "description": "RFC 9745: fecha de campo estructurado (@segundos).",
        "schema": {
          "type": "string"
        }
      },
      "Sunset": {
        "description": "RFC 8594 (informativo): fecha HTTP; nunca anterior a Deprecation.",
        "schema": {
          "type": "string"
        }
      },
      "Link": {
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "E400": {
        "description": "El cuerpo no se puede leer (type cuerpo-ilegible).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E401": {
        "description": "Falta una credencial válida (type credencial-invalida).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "WWW-Authenticate": {
            "$ref": "#/components/headers/WWW-Authenticate"
          }
        }
      },
      "E403": {
        "description": "La credencial no tiene permiso (type permiso-insuficiente).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E404": {
        "description": "No existe, o es de otro equipo y se esconde a propósito (type servicio-inexistente o about:blank).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E405": {
        "description": "El método no se admite en esta ruta (type about:blank).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "Allow": {
            "$ref": "#/components/headers/Allow"
          }
        }
      },
      "E409": {
        "description": "Choca con el estado actual (type nombre-repetido o llave-en-curso).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E410": {
        "description": "Se retiró y no va a volver (type servicio-retirado o version-retirada).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E412": {
        "description": "Ninguna etiqueta de If-Match coincide con la vigente en comparación fuerte (type etiqueta-vencida).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E415": {
        "description": "El cuerpo no es application/json (type about:blank).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "Accept": {
            "$ref": "#/components/headers/Accept"
          }
        }
      },
      "E422": {
        "description": "Se leyó, pero no se puede cumplir (type datos-invalidos, destino-prohibido o llave-reutilizada).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E428": {
        "description": "Falta If-Match (type falta-condicion).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E429": {
        "description": "Demasiadas revisiones de la misma credencial en el último minuto (type demasiadas-revisiones).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        }
      },
      "E500": {
        "description": "Error interno; instance es urn:uuid:… y sirve para encontrarlo en la bitácora (type error-interno).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E502": {
        "description": "El servicio consultado no dio una respuesta HTTP válida: cortó la conexión, respondió algo que no es HTTP o no se pudo conectar (type respuesta-invalida).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E503": {
        "description": "El revisor está en mantenimiento (type en-mantenimiento).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        }
      },
      "E504": {
        "description": "El servicio consultado no respondió a tiempo (type servicio-lento).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E413": {
        "description": "El cuerpo pasa de 16 KiB (type about:blank). La conexión se cierra al responder.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },
      "E429Salud": {
        "description": "Demasiadas consultas en vivo de la misma credencial en el último minuto (type demasiadas-consultas).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        },
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        }
      }
    },
    "schemas": {
      "DatosServicio": {
        "type": "object",
        "required": [
          "nombre",
          "url"
        ],
        "properties": {
          "nombre": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "http o https; no puede apuntar a direcciones privadas ni reservadas (SSRF)."
          },
          "limite_ms": {
            "type": "integer",
            "minimum": 100,
            "maximum": 10000,
            "default": 2000
          }
        }
      },
      "Servicio": {
        "type": "object",
        "required": [
          "id",
          "nombre",
          "url",
          "limite_ms"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "nombre": {
            "type": "string"
          },
          "url": {
            "type": "string"
          },
          "limite_ms": {
            "type": "integer"
          }
        }
      },
      "Lista": {
        "type": "object",
        "required": [
          "servicios"
        ],
        "properties": {
          "servicios": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Servicio"
            }
          }
        }
      },
      "Resultado": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "estado": {
            "enum": [
              "ARRIBA",
              "LENTO",
              "CAIDO"
            ]
          },
          "codigo": {
            "type": [
              "integer",
              "null"
            ]
          },
          "ms": {
            "type": [
              "integer",
              "null"
            ]
          },
          "detalle": {
            "type": "string"
          }
        }
      },
      "Revision": {
        "type": "object",
        "required": [
          "id",
          "servicio",
          "estado",
          "resultado"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "servicio": {
            "type": "integer"
          },
          "estado": {
            "enum": [
              "pendiente",
              "terminada"
            ]
          },
          "resultado": {
            "$ref": "#/components/schemas/Resultado"
          }
        }
      },
      "Salud": {
        "type": "object",
        "properties": {
          "servicio": {
            "type": "integer"
          },
          "codigo": {
            "type": "integer"
          },
          "ms": {
            "type": "integer"
          }
        }
      },
      "Informe": {
        "type": "object",
        "properties": {
          "servicio": {
            "type": "integer"
          },
          "revisiones": {
            "type": "integer"
          },
          "ultimo_estado": {
            "type": "string"
          }
        }
      },
      "Problema": {
        "type": "object",
        "description": "Problem Details (RFC 9457). type es el identificador estable; title y detail son para personas.",
        "required": [
          "type",
          "title",
          "status",
          "detail",
          "instance"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri-reference",
            "examples": [
              "https://revisor.example/problemas/servicio-inexistente",
              "about:blank"
            ]
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "integer",
            "minimum": 400,
            "maximum": 599
          },
          "detail": {
            "type": "string"
          },
          "instance": {
            "type": "string",
            "format": "uri-reference"
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "detail": {
                  "type": "string"
                },
                "pointer": {
                  "type": "string"
                }
              }
            }
          },
          "existente": {
            "type": "string"
          }
        },
        "additionalProperties": true
      }
    }
  }
}

8.15.2 Файловете, които не се променят

Те са от предишните уроци във вече действащата им версия; променя се само първият ред, който сега казва fig08_01/. package.json е същият както винаги:

{
  "type": "module"
}
// fig08_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" });
// fig08_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;
// fig08_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"';
}
// fig08_01/decisiones.ts — qué hace quien recibe una respuesta: la tabla de decisión del cliente del revisor.
// Decide con el código y, si el cuerpo es un Problem Details con un type conocido, afina con el type.
// Nunca decide con el title ni con el detail: esos son para personas y pueden cambiar o traducirse.

export type Accion = "seguir" | "corregir" | "reautenticar" | "esperar" | "reconciliar" | "rendirse";

export type Decision = { accion: Accion; motivo: string; esperaS: number | null; reintenta: boolean };

// Por código. Lo que no está aquí se trata como el x00 de su familia (RFC 9110, sección 15).
const POR_CODIGO: Record<number, [Accion, string]> = {
  200: ["seguir", "salió bien"],
  201: ["seguir", "se creó; la dirección viene en Location"],
  202: ["seguir", "se aceptó; el seguimiento viene en Location"],
  204: ["seguir", "salió bien y no hay nada que leer"],
  300: ["corregir", "hay varias opciones: elige una dirección"],
  301: ["corregir", "la dirección cambió para siempre: actualiza la configuración"],
  302: ["corregir", "redirección temporal: el cliente no la sigue solo; revisa la dirección de Location"],
  303: ["corregir", "redirección a otro recurso: el cliente no la sigue solo; revisa la dirección de Location"],
  304: ["seguir", "no cambió: usa la copia que ya tienes"],
  307: ["corregir", "redirección temporal que conserva el método: el cliente no la sigue solo; revisa Location"],
  308: ["corregir", "la dirección cambió para siempre: actualiza la configuración"],
  400: ["corregir", "la petición no se puede leer"],
  401: ["reautenticar", "falta una credencial válida"],
  403: ["rendirse", "la credencial no tiene permiso: lo resuelve una persona"],
  404: ["rendirse", "no existe (o no te lo muestran)"],
  405: ["corregir", "ese método no se admite: mira Allow"],
  408: ["esperar", "el servidor no recibió la petición completa a tiempo: repítela tal cual, en una conexión nueva (RFC 9110, 15.5.9)"],
  409: ["reconciliar", "choca con el estado actual: relee y decide"],
  410: ["rendirse", "se retiró y no va a volver: dalo de baja"],
  412: ["reconciliar", "tu etiqueta ya no es la vigente: relee y vuelve a aplicar tu cambio"],
  415: ["corregir", "el formato no se admite: mira Accept"],
  422: ["corregir", "se entendió, pero los datos no se pueden cumplir"],
  425: ["esperar", "el servidor no quiso procesar datos tempranos: repite la petición, esta vez sin ellos (RFC 8470, 5.2)"],
  428: ["corregir", "falta la condición: manda If-Match"],
  429: ["esperar", "demasiadas peticiones: espera lo que diga Retry-After"],
  500: ["rendirse", "falla del servidor: repórtala con su instance"],
  502: ["esperar", "falló lo que está detrás: reintenta solo si la operación es idempotente"],
  503: ["esperar", "no está disponible: espera lo que diga Retry-After"],
  504: ["esperar", "lo de atrás no respondió a tiempo: reintenta solo si es idempotente"],
};

// Por type del revisor: el mismo código puede pedir cosas distintas.
const BASE = "https://revisor.example/problemas/";
const POR_TYPE: Record<string, [Accion, string]> = {
  [BASE + "llave-en-curso"]: ["esperar", "la petición original sigue en curso: espera y repite con la misma llave"],
  [BASE + "nombre-repetido"]: ["reconciliar", "ya existe: mira el miembro existente antes de crear otro"],
  [BASE + "llave-reutilizada"]: ["corregir", "usa una llave nueva para una petición nueva"],
  [BASE + "destino-prohibido"]: ["corregir", "el revisor no se conecta a esa dirección: usa otra"],
  [BASE + "servicio-retirado"]: ["rendirse", "el servicio se retiró: dalo de baja de tu lista"],
  [BASE + "en-mantenimiento"]: ["esperar", "el revisor está en mantenimiento: espera lo que diga Retry-After"],
};

// Sin respuesta HTTP también hay que decidir.
export function decidirSinRespuesta(tipo: "tiempo-agotado" | "sin-conexion" | "respuesta-invalida" | "destino-prohibido"): Decision {
  if (tipo === "destino-prohibido") return { accion: "corregir", motivo: "destino prohibido", esperaS: null, reintenta: false };
  return { accion: "esperar", motivo: tipo, esperaS: null, reintenta: true };
}

export function decidir(codigo: number, retryAfterS: number | null, type: string | null): Decision {
  const familia = codigo < 100 || codigo > 599 ? 500 : Math.floor(codigo / 100) * 100;
  // Object.hasOwn: el type llega del cuerpo ajeno, y POR_TYPE[type] a secas encontraría también lo que
  // hereda de Object.prototype (un type "constructor" devolvería una función, no una decisión).
  const propia = (type !== null && Object.hasOwn(POR_TYPE, type) ? POR_TYPE[type] : undefined) ?? POR_CODIGO[codigo];
  const [accion, motivo] = propia ?? motivoDeFamilia(codigo, familia);
  return { accion, motivo, esperaS: accion === "esperar" ? retryAfterS : null, reintenta: accion === "esperar" };
}

// Un código que la tabla no lista se trata como el x00 de su clase (RFC 9110, sección 15). El motivo lo
// dice, para que nadie lea la frase del x00 como si fuera la del código que llegó.
function motivoDeFamilia(codigo: number, familia: number): [Accion, string] {
  const [accion, motivo] = POR_CODIGO[familia] ?? ["rendirse", "código desconocido"];
  if (codigo < 100 || codigo > 599) {
    return ["rendirse", `${codigo} no es un código de HTTP (el RFC 9110 admite de 100 a 599): se procesa como un 5xx`];
  }
  if (POR_CODIGO[familia] === undefined) return [accion, motivo];
  return [accion, `el ${codigo} no está en la tabla: se trata como un ${familia} (${motivo})`];
}

// Lee el type si el cuerpo es un Problem Details; si no, null. No lee nada más del cuerpo.
export function typeDe(tipoContenido: string | null, cuerpo: string): string | null {
  // El tipo de medio no distingue mayúsculas y puede traer parámetros (RFC 9110, sección 8.3.1).
  if (tipoContenido === null || tipoContenido.split(";")[0]?.trim().toLowerCase() !== "application/problem+json") return null;
  try {
    const problema = JSON.parse(cuerpo) as { type?: unknown };
    return typeof problema.type === "string" ? problema.type : "about:blank";
  } catch {
    return null;
  }
}
// fig08_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));
}
// fig08_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}`);
}
// fig08_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`);
});
// fig08_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) };
}
// fig08_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);
}
// fig08_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}`);
});
// fig08_01/por-type.ts — el mismo código, dos decisiones: lo que agrega el type de un Problem Details.
// Uso: con laboratorio.ts y servidor.ts corriendo, node por-type.ts
import { decidir, typeDe } from "./decisiones.ts";

async function alta(nombre: string, url: string, llave: string): Promise<Response> {
  return fetch("http://127.0.0.1:18080/servicios", {
    method: "POST",
    headers: { "Authorization": "Bearer escritura-a", "Content-Type": "application/json", "Idempotency-Key": llave },
    body: JSON.stringify({ nombre, url }),
  });
}

// Dos altas con la misma llave al mismo tiempo (409 llave-en-curso), y un nombre repetido (409 nombre-repetido).
const [primera, segunda] = await Promise.all([
  alta("correo", "http://127.0.0.1:18081/codigo/200", "llave-1"),
  new Promise<Response>((listo) => setTimeout(() => listo(alta("correo", "http://127.0.0.1:18081/codigo/200", "llave-1")), 100)),
]);
await primera.text();
const repetido = await alta("pagos", "http://127.0.0.1:18081/codigo/200", "llave-2");

for (const respuesta of [segunda, repetido]) {
  const type = typeDe(respuesta.headers.get("content-type"), await respuesta.text());
  const soloCodigo = decidir(respuesta.status, null, null);
  const conType = decidir(respuesta.status, null, type);
  console.log(`${respuesta.status} ${type}`);
  console.log(`  solo con el código: ${soloCodigo.accion} (${soloCodigo.motivo})`);
  console.log(`  con el type:        ${conType.accion} (${conType.motivo})`);
}
// fig08_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);
}
// fig08_01/revisar.ts — el revisor como cliente, de punta a punta: pide su lista a la API, consulta cada
// servicio y dice qué hacer con lo que respondió.
// Uso: con laboratorio.ts y servidor.ts corriendo, node revisar.ts [credencial]
import { consultar, segundosDeEspera } from "./cliente.ts";
import { decidir, decidirSinRespuesta, typeDe, type Decision } from "./decisiones.ts";

const credencial = process.argv[2] ?? "lectura-a";
const TOPE_ESPERA_S = 5;

type Servicio = { id: number; nombre: string; url: string; limite_ms: number };

// 1. La lista. Si la API responde un error, se decide por su type, no por su texto.
async function pedirLista(): Promise<Servicio[] | undefined> {
  for (let intento = 1; intento <= 2; intento++) {
    const respuesta = await fetch("http://127.0.0.1:18080/servicios", {
      headers: { Authorization: `Bearer ${credencial}` },
    });
    const cuerpo = await respuesta.text();
    if (respuesta.ok) return (JSON.parse(cuerpo) as { servicios: Servicio[] }).servicios;
    const type = typeDe(respuesta.headers.get("content-type"), cuerpo);
    const decision = decidir(respuesta.status, segundosDeEspera(respuesta.headers.get("retry-after")), type);
    console.log(`la API respondió ${respuesta.status} (${type}): ${decision.accion}, ${decision.motivo}`);
    if (decision.accion !== "esperar" || decision.esperaS === null) return undefined;
    if (decision.esperaS > TOPE_ESPERA_S) {
      console.log(`  pide esperar ${decision.esperaS} s, más que el tope de ${TOPE_ESPERA_S} s: lo intento más tarde`);
      return undefined;
    }
    console.log(`  espero ${decision.esperaS} s y vuelvo a pedir`);
    await new Promise((listo) => setTimeout(listo, (decision.esperaS ?? 0) * 1000));
  }
  return undefined;
}

const servicios = await pedirLista();
if (servicios === undefined) process.exit(1);

// 2. Cada servicio: qué respondió y qué toca hacer.
console.log("SERVICIO     RESPUESTA          ACCIÓN        MOTIVO");
for (const servicio of servicios) {
  const consulta = await consultar(servicio.url, servicio.limite_ms);
  let respuesta: string;
  let decision: Decision;
  if (consulta.tipo === "respuesta") {
    respuesta = `${consulta.codigo} en ${consulta.ms} ms`;
    decision = decidir(consulta.codigo, consulta.reintentarEn, null);
  } else {
    respuesta = consulta.tipo;
    decision = decidirSinRespuesta(consulta.tipo);
  }
  console.log(`${servicio.nombre.padEnd(13)}${respuesta.padEnd(19)}${decision.accion.padEnd(14)}${decision.motivo}`);
}
// fig08_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 });
}
// fig08_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,
  });
}
// fig08_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}`);
});
// fig08_01/tabla23.ts — la tabla de decisión aplicada a los 23 códigos del curso, contra el laboratorio.
// Uso: con laboratorio.ts corriendo, node tabla23.ts
import { segundosDeEspera } from "./cliente.ts";
import { decidir, typeDe } from "./decisiones.ts";

const CODIGOS = [200, 201, 202, 204, 301, 304, 308, 400, 401, 403, 404, 405, 409, 410, 412, 415, 422, 428, 429, 500, 502, 503, 504];

console.log("CÓDIGO  ACCIÓN        REINTENTA  ESPERA  MOTIVO");
for (const codigo of CODIGOS) {
  const respuesta = await fetch(`http://127.0.0.1:18081/codigo/${codigo}`, { redirect: "manual" });
  const cuerpo = await respuesta.text();
  const decision = decidir(
    respuesta.status,
    segundosDeEspera(respuesta.headers.get("retry-after")),
    typeDe(respuesta.headers.get("content-type"), cuerpo),
  );
  const espera = decision.esperaS === null ? "—" : `${decision.esperaS} s`;
  console.log(
    `${String(respuesta.status).padEnd(8)}${decision.accion.padEnd(14)}${(decision.reintenta ? "sí" : "no").padEnd(11)}${espera.padEnd(8)}${decision.motivo}`,
  );
}
// fig08_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;
}

8.16 Проверка на типовете с 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

Отделните програми на този урок (fig08_02.ts, fig08_04.ts, fig08_05.ts, fig08_06.ts и fig08_07.ts, и вариантите в ejercicios/) се проверяват по същия начин от папката, която ги съдържа, като се сочи към tsc, който си инсталирал във fig08_01/.

Грешката, която ще видиш

Матрицата, която се нагоди към дефекта

Най-съблазнителната грешка при една проверка, която почервенява, е да я оправиш на грешното място. Вторник е, матрицата е червена на 17 реда, има бързане и някой отваря matriz23.ts вместо problemas.ts: „вече не изискваме Retry-After“, „приемаме application/json, където се очакваше application/problem+json“. Два реда и матрицата се връща на зелено. Копие на matriz23.ts с точно тези две промени (на три места, защото проверката на 429 по удостоверение също пита за Retry-After) е това, което е в defectos/matriz-bendice/. Копирай го върху папката rota/ от раздел 8.10.1 (тази на „почистения“ сървър) и го изпълни:

$ node matriz23.ts   # la matriz «arreglada», contra el servidor con el defecto
ok    ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)

41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 0)

Зелено. 41 от 41 и изходен код 0, срещу същия сървър, който допреди малко постави матрицата на 17 червени реда. Сървърът не се е променил; променила се е мярката. Това е, което курсът нарича тест, който благославя дефекта: минава и онова, което минава, е грешката.

Това е по-лошо от тест, който се проваля, по една конкретна причина: един червен тест привлича вниманието на някого, а зеленият го отнема. От този момент таблото казва „всичко е наред“ и интеграторите от сцената в началото пак получават application/json. Дефектът вече няма кой да го следи.

Три навика го избягват, и трите са евтини:

  • Промяната в онова, което тестът очаква, е промяна на обещание. Преглежда се както се преглежда промяна в API-то: кой разбра? документът казва ли същото? Тук openapi.json продължава да казва application/problem+json; нагласената матрица вече не съвпада с документа и това несъответствие трябва да е първото, което някой забележи.
  • Преди да отпуснеш един тест, попитай кое от трите лъже. Сървърът, документът или тестът. При 17 червени реда с почти същото изречение най-вероятно е да се е променило едно нещо в сървъра, а не 17 неща в теста.
  • Върни и провери, че почервенява. Това е правилото на целия урок: тест, който не е виждан да се проваля срещу дефекта, не доказва нищо. Тук изпълнението на оригиналната матрица срещу сървъра с дефекта дава червено; изпълнението на нагласената — зелено. Разликата между двете е цялата информация.

Сървърът, който остана отворен

Втората грешка не е на дизайна, а на забравянето и има тънки последици. Матрицата стартира собствен revisor на порт 18080. Но ако вече има такъв, който слуша там (revisor от предишен урок или копието rota/ от раздел 8.10, което не си спрял), новата програма не може да заеме порта и няма кой да го каже: дъщерните процеси се стартират с stdio: "ignore". Версията на матрицата, която не се защитаваше от това, продължаваше напред и измерваше стария сървър. Тази на урока се защитава: преди да стартира каквото и да е, проверява трите порта. За да го видиш, остави да работи в един терминал сървърът от копието rota/, „почистеният“, и от друг изпълни матрицата от fig08_01/, добрата; после предишната версия на матрицата (defectos/espera-fija/matriz23.ts, която няма вратата):

$ cd rota && node servidor.ts &      # un revisor con el defecto, que se te olvidó apagar
$ cd ../fig08_01 && node matriz23.ts
puerto 18080 ocupado; apaga lo que lo usa
(código de salida: 2)

$ cd ../espera-fija && node matriz23.ts | grep -v "^ok"   # la matriz anterior, sin la puerta
FALLA 400 cuerpo ilegible: tipo application/json, se esperaba application/problem+json
FALLA 401 sin credencial: tipo application/json, se esperaba application/problem+json
FALLA 403 lectura que intenta escribir: tipo application/json, se esperaba application/problem+json
FALLA 404 servicio que no existe: tipo application/json, se esperaba application/problem+json
FALLA 405 método no admitido: tipo application/json, se esperaba application/problem+json
FALLA 409 nombre repetido: tipo application/json, se esperaba application/problem+json
FALLA 410 servicio retirado: tipo application/json, se esperaba application/problem+json
FALLA 412 etiqueta vencida: tipo application/json, se esperaba application/problem+json
FALLA 415 formato no admitido: tipo application/json, se esperaba application/problem+json
FALLA 422 datos imposibles: tipo application/json, se esperaba application/problem+json
FALLA 428 cambio sin If-Match: tipo application/json, se esperaba application/problem+json
FALLA 429 demasiadas revisiones: falta retry-after; tipo application/json, se esperaba application/problem+json
FALLA 500 el defecto a propósito: tipo application/json, se esperaba application/problem+json
FALLA 502 el servicio responde basura: tipo application/json, se esperaba application/problem+json
FALLA 503 mantenimiento: falta retry-after; tipo application/json, se esperaba application/problem+json
FALLA 504 el servicio no responde a tiempo: tipo application/json, se esperaba application/problem+json
ESTADO: la matriz seguía esperando a los 60 s; se detuvo por su PID

Когато приключиш, спри сървъра на rota/ по PID (kill %1, ако си го пуснал в същия терминал с &) и провери с командата по-долу, че портовете са останали свободни. Първата матрица отказва да измерва: казва кой порт е зает и излиза с 2, който е код, различен от 1 на „нещо се провали“. Втората показва какво ставаше преди. Папката fig08_01 няма дефекта, но предишната матрица показва същите шестнайсет червени реда на сценариите от раздел 8.10.1. Който я прочете, без да знае за отворения сървър, би търсил грешката в погрешния код: матрицата не измери собствения си revisor, измери този, който вече беше там.

И обърни внимание как свършваше, защото беше втората половина на проблема: не отпечатваше резюме. Последният ред на изхода по-горе („ESTADO: …“) не го написа матрицата, а скриптът, който я изпълни за този урок: матрицата остана да чака, без да каже нищо, и на 60-ата секунда беше спряна по PID. Оставаше да чака, защото една от допълнителните ѝ проверки спира „своя“ revisor, за да изпробва шлюза, и чака известието, че е приключил; този revisor никога не беше съществувал и известието не идваше. Програма, която мълчи пред зает порт, вместо да каже „портът 18080 е зает“, е дефект и затова матрицата на урока носи вратата, която fig08_03.sh вече имаше (раздел 8.9). Онова, което не се променя, е основното правило, което курсът повтаря от Урок 0: 127.0.0.1:18080 не казва какво има отзад, а само че нещо е отговорило. Ако пишеш собствена програма, която стартира процеси, направи ѝ същата врата, а ако я изпълняваш на ръка, провери преди това, че портовете са свободни:

for p in 18080 18081 18082; do (exec 3<>/dev/tcp/127.0.0.1/$p) 2>/dev/null && echo "ocupado: $p"; done

Ако командата не отпечата нищо, трите порта са свободни.

Честите грешки

Документиране по памет. Един документ, написан по онова, което човек вярва, че прави API-то, свършва с описване на API, което е въобразил. Пише се, като се гледат действителните отговори, и после се проверява с матрицата.

Документиране само на щастливия път. Един документ с успехите и без грешките оставя на клиента половината работа: трябва да открива 4xx и 5xx чрез проба и грешка. Грешките се документират със същата точност като успехите, с типа на съдържанието и заглавките им.

Писане на един и същ отговор с грешка във всяка операция. Разсинхронизира се, щом някой поправи един, а не останалите. Отговорите с грешки се пишат веднъж в components и се преизползват с $ref.

Поставяне на типа на съдържанието като заглавка. Един Content-Type в headers се игнорира; типът е ключ на content.

Бъркане на info.version с версията на API-то. Това е версията на документа. Ако трябва да се каже коя е действащата API и кои са оттеглени, пише се в описанието и се маркира deprecated в маршрутите.

Приемане на документ за добър, защото валидаторът казва „валиден“. Валидаторът отговаря дали е добре написан, не дали е верен (раздел 8.11).

Тестване само на кодовете. Това е сцената от началото. Кодът е най-лесният за тестване и единственият, който проверяваха тестовете от онази сутрин.

Отпускане на един тест, за да стане зелен. Тест, който се нагласява към дефекта, минава и онова, което минава, е дефектът (раздел „Матрицата, която се нагоди към дефекта“).

Измерване на грешния сървър. Порт, зает от стара програма, оставя една програма да измерва онова, което не е нейно; затова матрицата проверява портовете преди стартиране и излиза с 2, ако някой отговори (раздел „Сървърът, който остана отворен“).

Повтаряне на тест, докато мине. Една проверка, която понякога се проваля, има причина. Да я разбереш (раздел 8.14.1) е по-евтино, отколкото да не се доверяваш на всички останали.

Да проверяваш местоназначението и да оставиш друг да разреши името. Това са две разрешавания и между едното и другото името може да смени собственика си. Адресът, който се проверява, е адресът, към който се свързва.

Да вярваш, че документът ще разубеди този, който не бива да го чете. Един документ OpenAPI почти винаги се публикува. Онова, което не може да е в него (имена на вътрешни машини, частни адреси, примери с истински данни), не се пише, дори като пример.

Упражнения

И шестте се коригират сами: всяко завършва с изпълнение, което ти казва дали си познал (в 5 и 6, с контрол, който нарочно се проваля). Запиши предсказанието си в дневника преди да изпълниш каквото и да е. За упражнения 1 до 3 ти трябва папката fig08_01/ и свободни портове 18080, 18081 и 18082 (третият се ползва от шлюза на една от допълнителните проверки); за 4 — 18081 и ::1.

Упражнение 1 — Затвори дупката на каталога

Раздел 8.5 откри, че fig08_02.ts отбелязва една дупка: документът не споменава type revision-inexistente, а маршрутът GET /revisiones/{id} го връща. Поправи копие на openapi.json: добави един преизползваем отговор за 404 на този маршрут, наречи го както искаш и накарай операцията да сочи към него. За да разбереш дали си познал, изпълни трите проверителя с твоето копие: fig08_02.ts трябва да каже „0 con un hueco en el documento“; matriz23.ts трябва да остане на 41 от 41; и redocly lint трябва да продължава да казва «valid». Предскажи преди това: колко предупреждения ще даде валидаторът, повече, по-малко или същите?

Упражнение 2 — Накарай матрицата да вижда detail и instance

Раздел 8.14 каза, че матрицата проверява само три от петте члена на един Problem Details. Добави два реда към comprobar, за да изисква също detail и instance като текст, във всички грешки. Предскажи: с твоята разширена матрица колко червени реда дава добрият revisor? После копирай ejercicios/ej2/defecto-sin-instance/problemas.ts върху копие на fig08_01/ (това е сървър, на който са му забравили instance) и предскажи две неща: колко червени реда дава оригиналната матрица срещу този сървър и колко дава твоята.

Упражнение 3 — Махни 405 от документа

Това беше идеята от раздел 8.4.4. В копие на openapi.json изтрий отговора 405 на GET /servicios/{id} и изпълни матрицата. Предскажи: колко червени реда? кое е точното съобщение? После изпълни redocly lint върху същия документ: валидаторът открива ли го?

Упражнение 4 — Какъв Host получава сървърът?

Раздел 8.12.3 каза, че клиентът се свързва към вече проверения адрес, но заглавката Host запазва името. Промени fig08_04.ts, за да отпечатва „лабораторията“ Host, който е получила всяка заявка. Предскажи: когато изпълниш версията с fetch, сървърът, който отговаря, отпечатва ли нещо? А когато изпълниш тази на урока? Каква стойност има Host в такъв случай?

Упражнение 5 — Възпроизведи надпреварата и двете затваряния

Раздел 8.14.1 разказа една надпревара, първа поправка с нейната граница и затваряне на етапи. Възпроизведи ги ти. Копирай fig08_01/ в папка viejo/, замени respuestas.ts с defectos/cierre-brusco/respuestas.ts, стартирай revisor от viejo/ и изпълни fig08_05.ts три пъти. Предскажи първо: броят на отговорите 413 ще е ли същият и в трите? коя ще е причината за тези, които не получават отговор? После спри този revisor, повтори с defectos/drenar-antes/respuestas.ts и изпълни node fig08_05.ts 100 3000 (3 MB на опит): предскажи колко от 100-те получават своя 413. Накрая повтори тези 3 MB срещу revisor от fig08_01/ така, както е. Спри всеки revisor по PID, преди да стартираш следващия. Отговори с аргументи: защо първата поправка се проваля над 1 MiB, а втората — не? Какво продължава да се плаща във втората? Какво би станало, ако една версия на Node спре да вика socket.destroySoon() при затваряне?

Упражнение 6 — Затвори фиксираното чакане

Раздел 8.14.1 завърши с една уязвимост, която матрицата на урока вече няма: да чака определено време след стартирането на всеки процес. Тук я поправяш ти, тръгвайки от версията, която я има: defectos/espera-fija/matriz23.ts. Първо измери запаса с fig08_06.ts, който стартира шлюза 100 пъти и казва колко време отнема да отговори. После копирай fig08_01/ в папка mia/, постави там defectos/espera-fija/matriz23.ts и я промени така, че вместо да чака определено време, да чака портът да приема връзки (и да се провали със съобщение, ако не го прави). За да разбереш дали си познал, изпълни ejercicios/ej6/arranque-lento.sh върху папка с тази матрица: забавя с 700 ms стартирането на revisor и на шлюза. Предскажи преди това: колко проверки минават с фиксираното чакане и колко с твоето? колко мислиш, че отнема на шлюза да отговори, при най-лошото от 100-те стартирания? Накрая изпълни матрицата си осем пъти поред със заредена машина (например с два пъти повече програми node -e 'while(true){}', отколкото ядра има процесорът ти, които спираш по PID накрая).

Решения

Решение 1

Решението е в ejercicios/ej1/openapi.json. Добавя отговор E404Revision до E404, с описанието „La revisión no existe (type revision-inexistente).“ и същото съдържание, и кара GET /revisiones/{id} да сочи към него:

      "E404Revision": {
        "description": "La revisión no existe (type revision-inexistente).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problema"
            }
          }
        }
      },

Трите изпълнения, с този документ:

$ node fig08_02.ts   # con el openapi.json del ejercicio 1
servicio-lento            504     servicio-lento        sí                        sí

24 tipos en el catálogo; 0 con un hueco en el documento

$ node matriz23.ts   # con el mismo openapi.json
ok    ETag tras reiniciar el revisor: la vieja no coincide (200, no un 304 falso)

41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).

$ npx redocly lint openapi.json --format=stylish   # con el mismo openapi.json
Woohoo! Your API description is valid. 🎉
You have 26 warnings.

fig08_02.ts преминава от една дупка към нула дупки; матрицата остава на 41 от 41, защото промяната не премахва нищо, което матрицата търси; а валидаторът продължава да казва «valid» със същите 26 предупреждения: добавен е отговор, който се ползва, така че няма неизползвани компоненти, нито нови операции. Ако си предсказал повече предупреждения, вероятно си помислил, че всяко добавяне трупа шум; тук не трупа, защото валидаторът брои само онова, което не е завършено.

Решение 2

Двата реда са в ejercicios/ej2/matriz23.ts, точно под този, който иска title:

      if (typeof cuerpo.title !== "string") fallas.push("falta title");
      if (typeof cuerpo.detail !== "string") fallas.push("falta detail");
      if (typeof cuerpo.instance !== "string") fallas.push("falta instance");

Два реда, които сега изискват detail и instance като текст. Трите изпълнения:

$ node matriz23.ts   # la matriz de siempre, contra el servidor al que le falta instance
FALLA 500 el defecto a propósito: instance no es urn:uuid

40 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 1)

$ node matriz23.ts   # la matriz ampliada, contra el servidor bueno

41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).

$ node matriz23.ts   # la matriz ampliada, contra el servidor al que le falta instance
FALLA 400 cuerpo ilegible: falta instance
FALLA 401 sin credencial: falta instance
FALLA 403 lectura que intenta escribir: falta instance
FALLA 404 servicio que no existe: falta instance
FALLA 405 método no admitido: falta instance
FALLA 409 nombre repetido: falta instance
FALLA 410 servicio retirado: falta instance
FALLA 412 etiqueta vencida: falta instance
FALLA 415 formato no admitido: falta instance
FALLA 422 datos imposibles: falta instance
FALLA 428 cambio sin If-Match: falta instance
FALLA 429 demasiadas revisiones: falta instance
FALLA 500 el defecto a propósito: falta instance; instance no es urn:uuid
FALLA 502 el servicio responde basura: falta instance
FALLA 503 mantenimiento: falta instance
FALLA 504 el servicio no responde a tiempo: falta instance

25 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).

Първото изпълнение е важното: оригиналната матрица, срещу сървър, на когото липсва instance във всичките му грешки, дава 40 от 41. Единственият червен ред е този на 500, защото този сценарий вече изискваше instance да е urn:uuid; другите 15 грешки с дефекта минаха. Това означава „тестът не гледа онова, което не гледа“. Второто изпълнение потвърждава, че разширената матрица остава на 41 от 41 с добрия сървър (нищо от добавеното не чупи онова, което вече работеше). Третото е положителният контрол: срещу сървъра с дефекта разширената матрица отбелязва 16-те сценария с грешка, по един за всяка грешка, докато остане на 25 от 41. Това е урокът от раздел 8.10, приложен към твой тест: разшири една мярка и я видя да хапе.

Ако предсказанието ти за третото е било различно, преброй сценариите, които са грешки: шестнайсет от 23-те кода са такива (от 400 до 504), а допълнителните проверки не минават през comprobar и затова не се заразяват.

Решение 3

Документът без 405 се строи с един ред Node върху копие:

node -e 'const fs=require("fs"); const d=JSON.parse(fs.readFileSync("openapi.json","utf8")); delete d.paths["/servicios/{id}"].get.responses["405"]; fs.writeFileSync("openapi.json", JSON.stringify(d,null,2)+"\n");'

Изпълнява се в папката на копието и после матрицата и валидаторът:

$ node matriz23.ts   # sin el 405 de GET /servicios/{id} en el documento
FALLA 405 método no admitido: openapi.json no documenta 405 en GET /servicios/{id}

40 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).

$ npx redocly lint openapi.json --format=stylish   # el mismo documento
Woohoo! Your API description is valid. 🎉
You have 26 warnings.

Един червен ред в матрицата, с точното съобщение от раздел 8.6.3: openapi.json no documenta 405 en GET /servicios/{id}. А валидаторът, който гледа документа, без да знае нищо за сървъра, го приема за валиден: няма никакво правило, което да казва „една операция трябва да декларира 405“. Това е таблицата от 8.11.3 в един ред. Ако при изпълнението на матрицата се появи втори червен ред (проверката на тялото на части), това е надпреварата от раздел 8.14.1 и няма нищо общо с твоята промяна.

Решение 4

Вариантът е в ejercicios/ej4/fig08_04.ts. Променя само едно нещо, „лабораторията“:

const laboratorio = createServer((p, r) => {
  console.log(`  el laboratorio recibió Host: ${p.headers.host ?? "(ninguno)"}`);
  r.writeHead(200);
  r.end();
});

И изпълнението, с версията на урока:

$ node fig08_04.ts fig08_01   # la variante del ejercicio 4
$ consultar("http://rebote.ejemplo:18081/")   con el cliente de fig08_01
  pregunta 1 al DNS: rebote.ejemplo -> 127.0.0.1
  el laboratorio recibió Host: rebote.ejemplo:18081
{"tipo":"respuesta","codigo":200,"ms":4,"reintentarEn":null}
el DNS fue preguntado 1 vez

Лабораторията получи Host: rebote.ejemplo:18081: името от URL, с порта му, макар връзката да е отишла към 127.0.0.1. Това е, което прави опцията lookup: решава накъде да се свърже и не пипа какво се казва, след като се свърже. С клиента на fetch (con-fetch) лабораторията не отпечатва нищо, защото връзката не стигна до нея: стигна до сървъра с 299, който не отпечатва. Ако си предсказал, че Host ще бъде 127.0.0.1:18081, си помислил, че който се свързва към един IP, го ползва и като име; така работи заявка, написана на ръка с IP в URL, и затова клиентът на урока не пише IP в URL.

Решение 5

Числата са в четири файла: defectos/cierre-brusco.salida.txt (рязкото затваряне), defectos/drenar-antes.salida.txt (изхвърляне на тялото преди отговора), fig08_05.salida.txt и fig08_05.limite.salida.txt (затварянето на етапи). Рязкото затваряне:

$ node fig08_05.ts   # contra el revisor con defectos/cierre-brusco/respuestas.ts
  382  respuesta 413
    4  sin respuesta: ECONNRESET
   14  sin respuesta: EPIPE
400 intentos

$ for i in 1 2 3 4 5; do printf "corrida $i:"; node fig08_05.ts | grep "respuesta 413"; done
corrida 1:  356  respuesta 413
corrida 2:  358  respuesta 413
corrida 3:  357  respuesta 413
corrida 4:  363  respuesta 413
corrida 5:  346  respuesta 413

С рязкия код броят на 413 се променя от едно изпълнение на друго (надпреварата зависи от това как се разпределя времето между затварянето на сървъра и записа на клиента) и не стига 400 в нито едно; тези, които липсват, свършват с EPIPE, с някои ECONNRESET. Първата поправка, с 400 опита от 20 KB и после с по-големи тела:

$ node fig08_05.ts   # contra el revisor con defectos/drenar-antes/respuestas.ts
  400  respuesta 413
400 intentos

$ for i in 1 2 3 4 5; do printf "corrida $i:"; node fig08_05.ts | grep "respuesta 413"; done
corrida 1:  400  respuesta 413
corrida 2:  400  respuesta 413
corrida 3:  400  respuesta 413
corrida 4:  400  respuesta 413
corrida 5:  400  respuesta 413
$ node fig08_05.ts 100 1000
  100  respuesta 413
100 intentos
$ node fig08_05.ts 100 1100
    8  respuesta 413
   19  sin respuesta: ECONNRESET
   73  sin respuesta: EPIPE
100 intentos
$ node fig08_05.ts 100 3000
   23  sin respuesta: ECONNRESET
   77  sin respuesta: EPIPE
100 intentos

До 1 MB — 100 от 100; с 1,1 MB — 8 от 100; с 3 MB — 0 от 100. И затварянето на етапи, с обичайните тела и с големите:

$ node fig08_05.ts
  400  respuesta 413
400 intentos

$ for i in 1 2 3 4 5; do printf "corrida $i:"; node fig08_05.ts | grep "respuesta 413"; done
corrida 1:  400  respuesta 413
corrida 2:  400  respuesta 413
corrida 3:  400  respuesta 413
corrida 4:  400  respuesta 413
corrida 5:  400  respuesta 413
$ node fig08_05.ts 100 1000
  100  respuesta 413
100 intentos
$ node fig08_05.ts 100 3000
  100  respuesta 413
100 intentos
$ node fig08_05.ts 100 20000
  100  respuesta 413
100 intentos

400 от 400 във всичките шест изпълнения и 100 от 100 с 1 MB, 3 MB и 20 MB.

За разликите. Първата поправка чете преди да отговори и има таван от 1 MiB: над тавана отговаря и затваря рязко, така че клиентът, който продължава да пише, пак получава RST и губи 413; тавана премести надпреварата, не я затвори. Втората прави онова, което RFC 9112 описва: отговаря веднага, затваря само записа (socket.end()) и продължава да чете и да изхвърля каквото пристигне, така че клиентът може да прочете 413, макар да продължава да изпраща, до 2-та секунда на часовника. Какво продължава да се плаща: през тези 2 секунди сървърът чете каквото клиентът иска да му изпрати (вече няма таван в байтове). Какво зависи от Node: http да вика socket.destroySoon() при затваряне на отговор с Connection: close; ако спре да го прави, заместването няма да се изпълни и измерването ще падне под 400, което е причината да го имаме.

Решение 6

Решението е в самата fig08_01/matriz23.ts (ejercicios/ej6/matriz23.ts е същата): две функции в началото на файла, които заместват четирите фиксирани чакания, които имаше след стартирането на един процес.

// Espera a que algo acepte conexiones en el puerto, hasta 10 s: en lugar de un tiempo fijo después de levantar un
// proceso, que con la máquina cargada se queda corto. Si el proceso que se levantó ya salió (por ejemplo, porque el
// puerto estaba ocupado), falla en seguida en vez de esperar a que conteste.
async function esperarPuerto(puerto: number, proceso?: ChildProcess): Promise<void> {
  for (let i = 0; i < 100; i++) {
    if (proceso !== undefined && proceso.exitCode !== null) throw new Error(`el proceso del puerto ${puerto} salió antes de escuchar`);
    if (await aceptaConexiones(puerto)) return;
    await esperar(100);
  }
  throw new Error(`el puerto ${puerto} no respondió en 10 s`);
}

// Levanta un programa del curso y espera a que escuche. Antes comprueba que el puerto esté libre: si ya lo ocupa
// un proceso viejo, esperarPuerto lo daría por bueno aunque el programa nuevo muriera con EADDRINUSE.
async function arrancar(archivo: string, puerto: number): Promise<ChildProcess> {
  if (await aceptaConexiones(puerto)) throw new Error(`puerto ${puerto} ocupado; apaga lo que lo usa`);
  const proceso = spawn(process.execPath, [archivo], { cwd: import.meta.dirname, stdio: "ignore" });
  await esperarPuerto(puerto, proceso);
  return proceso;
}

await esperar(500) (шлюзът) и трите await esperar(1000) (revisor, два пъти, и матрицата при стартиране) стават arrancar("pasarela.ts", 18082), arrancar("servidor.ts", 18080) и esperarPuerto(18081, …) и esperarPuerto(18080, …) със съответния порт; а в началото на изпълнението има врата, която излиза с 2, ако някой от трите порта вече отговаря. Измерванията, всички запазени в ejercicios/ej6.salida.txt (там espera-fija/ е копие на fig08_01/ с defectos/espera-fija/matriz23.ts отгоре, а портовете са пренаписани както в останалата част на урока):

$ node fig08_06.ts 100
n=100  mediana=60 ms  p95=95 ms  máximo=225 ms
arranques de más de 500 ms: 0   de más de 1000 ms: 0

$ # con 20 procesos que no hacen más que girar (dos por núcleo)
$ node fig08_06.ts 100
n=100  mediana=85 ms  p95=121 ms  máximo=166 ms
arranques de más de 500 ms: 0   de más de 1000 ms: 0

$ # la matriz 8 veces seguidas, con esos mismos procesos girando
$ ./fig08_08.sh 8 espera-fija
corrida 1: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 2: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 3: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 4: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 5: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 6: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 7: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 8: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
espera-fija/matriz23.ts: 0 de 8 corridas fallaron
$ ./fig08_08.sh 8 fig08_01
corrida 1: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 2: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 3: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 4: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 5: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 6: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 7: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
corrida 8: código 0; 41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
fig08_01/matriz23.ts: 0 de 8 corridas fallaron

# el control negativo: el arranque de servidor.ts y pasarela.ts retrasado 700 ms
$ ./arranque-lento.sh espera-fija   # la matriz con esperas fijas (defectos/espera-fija/)
--- espera-fija/matriz23.ts con un arranque lento de 700 ms (lo que importa de su salida):
FALLA la pasarela reenvía Content-Length; sin revisor responde 502 y sigue viva; alta-segura termina al agotar los intentos, también sin conexión: pasarela sin revisor: 0 null
40 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 1)

$ ./arranque-lento.sh fig08_01      # la matriz de la lección, con esperarPuerto
--- fig08_01/matriz23.ts con un arranque lento de 700 ms (lo que importa de su salida):
41 de 41 comprobaciones pasaron (23 escenarios de código y 18 adicionales).
(código de salida: 0)

# y con un revisor de antes que se te olvidó apagar en el 18080
$ node -e 'require("http").createServer((q, r) => r.end("viejo")).listen(18080, "127.0.0.1")' &
$ cd ej6 && node matriz23.ts
puerto 18080 ocupado; apaga lo que lo usa
(código de salida: 2)

Чети я внимателно, защото онова, което казва, и онова, което не казва, имат същото значение. Първата част дава запаса: колко време отне на шлюза да отговори при 100 стартирания и колко надхвърлиха 500-те ms, които чакаше матрицата с фиксирани чакания (и 1000-те, които чакаше revisor). Втората повтаря измерването със зареден процесор. Третата изпълнява двете матрици осем пъти със завъртащи се процеси.

Онова, което беше измерено: със заредена машина нито една от двете матрици не се провали и стартиранията се побраха в чакането; зареждането на процесора не стигна да предизвика повредата (операционната система обикновено дава приоритет на ново създаден процес), така че този опит не различава двете матрици. Онова, което ги различава, е отрицателният контрол и той е възпроизводим: със стартирането, нарочно забавено със 700 ms, матрицата с фиксирани чакания дава 40 de 41, с повредата pasarela sin revisor: 0 null, която се видя по време на урока, а матрицата с esperarPuerto дава 41 de 41. И ако нещо вече заема порт, матрицата го казва и излиза с 2, без да измерва. Едно уточнение: забавянето от 700 ms го избрахме ние, за да попадне между двете фиксирани чакания (500 и 1000 ms). Че чакането по порт издържа, е разсъждение плюс измерване; не доказва, че никое стартиране, на никоя машина, не отнема повече от 10-те секунди, които допуска.

Край на курса: как разбираш, че си приключил

README на курса обеща пет неща, които твоето API трябва да изпълнява в края. С този урок и петте могат да се проверят и ето какво проверява всяко:

Обещание на курса Какво го проверява
Матрицата от 23 сценария се изпълнява с една команда и всеки ред проверява код, заглавки, тип на съдържанието и тяло node matriz23.ts (раздел 8.7)
Никой отговор с грешка не е 200, нито HTML; всички са application/problem+json със стабилен type типът на съдържанието и type на всеки сценарий (8.6.3) и кръстосването на каталога (8.5)
Никое тяло на 5xx не съдържа стекове, заявки, пътища, нито ключове списъкът с течове на 5xx (8.6.4), с уточненията от 8.14
Повторен POST със същия ключ не дублира нищо; с друго тяло — 422; в ход — 409 допълнителната проверка 1 (8.8) и fig06_03.ts от Урок 6
revisor класифицира 23-те случая с една таблица и не повтаря онова, което не бива tabla23.ts от Урок 7, който продължава да дава същото след промяната на cliente.ts

Две предупреждения за честност, защото този курс ги е повтарял: Idempotency-Key и заглавките RateLimit-* продължават да не са стандарт (черновата на първата изтече на 18 април 2026 г., без да бъде публикувана като RFC; тази на RateLimit-* продължава да е активна чернова на IETF), а зелена матрица казва, че онова, което матрицата гледа, е наред. Онова, което не гледа (раздел 8.14), си остава в твоите ръце.

Как разбирам, че съм успял

  • Отваряш openapi.json и посочваш петте му части: info, servers, security, paths и components; и обясняваш защо info.version не е версията на API-то.
  • Обясняваш защо отговорите с грешки се пишат веднъж в components и се преизползват с $ref, и казваш колко препратки има към колко отговора (79 към 18).
  • node fig08_02.ts отбелязва една дупка (revision-inexistente) с документа на урока и нула дупки с този от упражнение 1.
  • node matriz23.ts дава 41 de 41 и излиза с 0; с defectos/sin-problem-json/problemas.ts дава 24 de 41 и излиза с 1; с defectos/sin-429-documentado/openapi.json дава 40 de 41.
  • bash fig08_03.sh дава 23 de 23 и обясняваш какво версията с curl проверява по-малко от тази на TypeScript.
  • С Redocly CLI документът дава «valid» с 26 предупреждения, повреденият документ дава две грешки, а документът без 429 също дава «valid»; и обясняваш защо.
  • node fig08_04.ts con-fetch стига до сървъра с 299 с два въпроса към DNS, а node fig08_04.ts fig08_01 стига до лабораторията с един; и казваш защо сертификатът продължава да се проверява спрямо името.
  • Обясняваш Deprecation, Sunset и Link с rel="deprecation" и какво маркира deprecated: true в документа.
  • Казваш, с два измерени примера, какво матрицата не може да види.
  • fig08_05.ts дава 400 от 400 срещу fig08_01/ и под 400 срещу respuestas.ts от defectos/cierre-brusco/; с 3 MB (node fig08_05.ts 100 3000) дава 100 от 100 срещу fig08_01/ и 0 от 100 срещу defectos/drenar-antes/; и обясняваш защо го затваря отговарянето първо и затварянето само на записа.
  • „Нагласената“ матрица дава зелено срещу сървъра с дефекта и обясняваш защо това е по-лошо от червена матрица.
  • Накрая не остава никоя програма да слуша: curl -i http://127.0.0.1:18080/ и curl -i http://127.0.0.1:18081/ дават грешка 7.

Обобщение

Отговори, без да поглеждаш урока:

  1. Кои са петте части на един документ OpenAPI и какво съдържа всяка? Какво е info.version?
  2. За какво служи $ref и защо отговорите с грешки са в components?
  3. Кои четири неща обещава един отговор и кои от тях наруши „почистването“ от началото?
  4. Какво проверява comprobar във всеки сценарий, по ред? Защо събира всички повреди вместо да спре на първата?
  5. Какво е регресия и колко допълнителни проверки има матрицата?
  6. Какво казва един валидатор на OpenAPI и какво не казва? На какъв въпрос отговаря матрицата?
  7. Какво е тест, който „благославя дефекта“? Как се избягва?
  8. Какво е DNS rebinding? Защо проверката преди всяка връзка не го затваря и какво го затваря?
  9. Какво прави опцията lookup и какво не прави? Защо soloA отговаря по два начина?
  10. Какво казват Deprecation, Sunset и Link с rel="deprecation" и какво маркира deprecated: true?
  11. Назови три неща, които матрицата не вижда.
  12. Защо понякога се провалеше проверката на тялото на части? Как се измери и как се затвори? Защо първата поправка (изхвърляне на тялото преди отговора) имаше граница, а затварянето на етапи — не?

Източници

Всички са прегледани на 21 октомври 2026 г.

  1. OpenAPI Specification v3.1.1 (24 октомври 2024 г.): info.version като версия на документа (4.8.2.1), servers (4.8.1.1), обектът операция, deprecated, security и operationId (4.8.10.1), обектите маршрут (4.8.9.1), параметър (4.8.12.2.1), отговори и отговор (4.8.16 и 4.8.17), препратка (4.8.23.1), схема (4.8.24) и разширенията с x- (4.9).
  2. Migrating from OpenAPI 3.0 to 3.1.0 (P. Sturgeon, 16 февруари 2021 г.): изравняването с JSON Schema 2020-12 и заместването на nullable със списък от типове.
  3. RFC 9457, Problem Details for HTTP APIs (юли 2023 г., път на стандартите; замества RFC 7807): членовете type, title, status, detail и instance (3.1) и членовете-разширения, които потребителят трябва да игнорира, ако не ги разпознава (3.2).
  4. IANA, регистър на application/problem+json: типът на съдържанието и препратката му, RFC 9457.
  5. RFC 9110, HTTP Semantics: Allow при 405 (15.5.6), Retry-After при 503 (15.6.4).
  6. RFC 9112, HTTP/1.1, раздел 9.6 (Tear-down): рискът от незабавно затваряне на една връзка и затварянето на етапи.
  7. RFC 9745, The Deprecation HTTP Response Header Field (март 2025 г., път на стандартите): Deprecation като дата на структурирано поле, връзката със Sunset и релацията deprecation на Link.
  8. RFC 8594, The Sunset HTTP Header Field (май 2019 г., информативен): форматът и че датите са подсказки.
  9. Node.js, net.connect: опцията lookup и autoSelectFamily (включена по подразбиране от версия 20).
  10. Node.js, dns.lookup: опцията all и формата на callback във всеки случай.
  11. Node.js, http.request: опциите agent: false, lookup и signal и смесването на url и options.
  12. Node.js 24.14.1 «Krypton» (LTS) (24 март 2026 г.): версията, с която бяха получени изходите.
  13. OWASP, Server-Side Request Forgery Prevention Cheat Sheet: DNS rebinding и фиксиране на връзката, пренасочвания, списъци с разрешени срещу списъци със забранени.
  14. GHSA-489g-7rxv-6c8q (10 юли 2026 г.): MCP Atlassian, непълна поправка на SSRF, защото не фиксира проверения IP; поправено във версия 0.22.0.
  15. GHSA-9hgc-g3w5-67cm, CVE-2026-53708 (15 юни 2026 г.): ContextForge, надпревара между валидацията и връзката в административна входна точка; поправено във версия 1.0.3. Това е първичният източник, който беше прегледан: страницата на NVD (nvd.nist.gov) за този CVE не върна четимо съдържание при справката.
  16. Redocly CLI, командата lint: какво прави, препоръчаният по подразбиране набор от правила и форматите на изхода; използваната версия е 2.57.0 (30 септември 2026 г.), с лиценз MIT.

Термини на този урок

OpenAPI спецификацията за описване на едно HTTP API в документ, който машините четат; курсът ползва 3.1.1
операция един метод върху един маршрут (GET /servicios/{id}) и всичко, което се казва за него
$ref препратката към част, записана на друго място в документа, обикновено в components
валидатор (linter) програмата, която казва дали един документ е добре написан според спецификацията
матрица таблицата със сценарии, която се обхожда с една команда, като се сравнява всеки отговор с обещаното
регресия тест на нещо, което вече веднъж е било поправено, за да не се върне, без никой да разбере
тестът да „хапе“ да се докаже, че един тест открива дефект, като се отмени поправката и се види в червено
благославяне на дефекта един тест да минава, защото е нагласен към счупеното, вместо да изисква обещаното
DNS rebinding едно име да смени адреса си между проверката и връзката, чрез отговор на DNS, който лъже втория път
TOCTOU time of check, time of use: надпревара между момента, когато нещо се проверява, и момента, когато се използва
deprecated полето на една операция, което предупреждава, че вече не е препоръчително да се ползва
Deprecation, Sunset заглавките, които казват от кога нещо е обявено за остаряло и кога се очаква да спре да отговаря

С този урок курсът завършва; продължи с твоя дневник.

Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx