Съдържание на курса
Урок 5 — Problem Details (RFC 9457)
От Dorian Chávez · основател и главен изпълнителен директор на Hábil ·
Четвъртък е, 03:20 ч., и таблото на екипа по интеграциите показва, че услугата pagos е изчезнала. Не е изчезнала: продължава да си стои в revisor, с обичайния си id 2. Случилото се е по-глупаво и по-скъпо. Преди две седмици някой написа скрипт, който синхронизира списъка с услуги с таблото. Когато revisor му отговори с грешка, скриптът чете тялото и ако намери думите „no existe“, изтрива услугата от таблото. Тази нощ някой друг промени базовия адрес на скрипта и му изпусна една буква. revisor отговори 404 с „no existe esa ruta“, скриптът видя „no existe“ и изтри шестте услуги от таблото, една по една. Междувременно друг скрипт на същия екип се опитва да регистрира услуга, която вече съществува, получава 409 с „ya existe un servicio llamado pagos: /servicios/2“, разделя текста при двоеточието, за да извади id-то… а вчера някой поправи формулировката на това съобщение и му сложи точка накрая. Сега скриптът търси услугата /servicios/2., която не съществува, и опитва отново всяка секунда. Никой не е сгрешил нарочно. Двата скрипта четяха изречения, защото revisor не им даваше нищо друго за четене.
Този урок дава на revisor един-единствен начин да казва какво се е объркало — онзи, който определя RFC 9457, с част за машините, която никога не се променя, и друга за хората, която може да се формулира както пожелаеш.
Време: 90 мин или 2 × 45 мин (прекъсването е между раздели 5.8 и 5.9)
Какво изграждаш: централния обработчик на грешки на revisor, problemas.ts: каталог със стабилен type за всеки проблем и една-единствена функция, fallar, през която минават всички грешки на API; и програма, която проверява, че всяка грешка е правилно оформен Problem Details
Какво научаваш: application/problem+json; петте члена type, title, status, detail и instance и за какво служи всеки от тях; type като стабилен идентификатор; about:blank и защо неговият title е на английски; разширенията и техните правила; какво да се прави, когато в един отговор има няколко проблема; официалния регистър на типовете на IANA; и какво се промени от RFC 7807 към RFC 9457
Откъде идваш. От Урок 4, с revisor, който вече произвежда всички кодове за грешка в курса: тези на входната точка от Урок 2, тези за състоянието от Урок 3 и тези за капацитета и повредите от Урок 4. Всеки код е избран правилно и носи своите заглавия. Но тялото на всяка грешка е ред текст, написан ръчно на мястото, където възниква, и всяко място го пише по свой начин. Този урок не променя нито един код, нито някое от заглавията, които всеки код изисква (Allow, WWW-Authenticate, Retry-After, Accept): той променя тялото на всички грешки наведнъж, а заедно с него и техния Content-Type.
Какво не прави този урок. Не учи как да решиш какво да правиш с една грешка от страна на клиента: тук се проектира онова, което казва revisor, а Урок 7 пише програмата, която го чете и решава. Не документира типовете в OpenAPI и не ги проверява при всяка промяна; това е Урок 8. И не публикува страници с документация на адресите на type: курсът използва домейн, запазен за примери, който не може да бъде посетен, а раздел 5.4 обяснява какво би направил в истински API.
За изпълненията. Linux Mint 22 е референтната система на курса. Изходите на този урок са получени на 21 октомври 2026 г. под macOS с Node 24.14.1 и curl 8.7.1, като към всеки curl е добавено -sS, защото изходът отиваше във файл (-s изключва индикатора за напредък, -S позволява да се виждат грешките и нито едното, нито другото променя отговора). Както в Урок 1, докато се пишеше този урок, други тестове заемаха 18080 и 18081, затова изпълненията бяха направени с копие на програмите, идентично с изключение на портовете, 18580 и 18581, а в изходите, поставени тук, отново са записани 18080 и 18081 — онези, които ще въвеждаш ти. Двете числа имат еднакъв брой цифри, така че нито един Content-Length не се променя. В терминала на revisor папката на машината е заменена с <carpeta>. При твоето изпълнение ще се различават датата в Date, осемте последни цифри на всеки ETag (revisor ги тегли на случаен принцип при стартиране) и препратката urn:uuid: на всяка вътрешна грешка, която е нова при всяко възникване — нарочно (раздел 5.8). revisor държи всичко в паметта: за да получиш същото като в урока, стартирай го отново преди всяка серия заявки. Не е изпълнен на настолна инсталация на Mint и нищо от използваното не зависи от macOS. Програмите са в programas/05-problem-details/, в хранилището на курса: revisor във fig05_01/, проверяващата програма във fig05_02.ts, счупените версии на „Грешката, която ще видиш“ в defectos/, решението на упражнение 4 в ejercicio4/, изпълнението на revisor от Урок 4 в antes.salida.txt, а всяко изпълнение — в своя файл .salida.txt. Всички преминават tsc в строг режим (раздел 5.16).
След урока ще можеш да
- Разпознаваш Problem Details по неговия
Content-Typeи да назоваваш, без да гледаш, петте му члена и какво казва всеки от тях. - Решаваш за нова грешка дали заслужава собствен
type, или е достатъчноabout:blank, и да пишеш записа ѝ в каталога сtype,titleиstatus. - Обясняваш защо
typeникога не се променя — нито между отделните възниквания, нито между езиците — и защо клиентът не бива да взема решения, като четеtitleилиdetail. - Добавяш разширение към тип проблем, като спазваш правилата за имената на RFC 9457, и да проверяваш с изпълнение, че
revisorотхвърля разширение със запазено име. - Отговаряш с няколко проблема от един и същи тип в едно тяло, с
errorsиpointer, и да избираш на кой да отговориш, когато проблемите са от различни типове. - Изпълняваш програма, която проверява, че всички грешки на API са правилно оформени Problem Details, и да разчиташ съобщенията ѝ за неуспех при две версии, счупени нарочно.
Защо, преди как
Предишните четири урока се занимаваха с кода на състоянието, и с право: това е частта от съобщението, която разбира всяка машина по пътя. Но кодът казва малко. 404 казва „не намерих онова, което поиска“, а в revisor това може да означава, че маршрутът не съществува, че услугата не съществува, че услугата е на друг екип или че проверката не съществува. 422 казва „разбрах, но не мога да го изпълня“, и не казва кое поле е сбъркано. 409 казва „влиза в конфликт с текущото състояние“, и не казва с какво. Кодът решава и за много решения той е достатъчен. За останалите тялото обяснява, а въпросът на този урок е как обяснява.
До Урок 4 revisor обясняваше с един ред текст. Това има три цени и сцената в началото ги показва всичките:
- Всяка грешка има собствена форма.
422носи по един ред за всяко поле, разделени с нови редове;409носи маршрута на съществуващата услуга, залепен в края на изречението;500носи препратката в края на друго изречение. Клиент, който иска да използва тази информация, пише различен преводач за всяка грешка. - Онова, от което има нужда машината, е смесено с онова, което чете човекът. Id-то на повторената услуга е вътре в едно изречение. За да го извадиш, трябва да разделиш текста, и в деня, в който някой подобри формулировката, клиентът се чупи, без кодът на състоянието да се е променил.
- Няма стабилно име за проблема. „No existe el servicio 99“ и „no existe esa ruta“ са два различни проблема с един и същи код и единственият начин да ги различиш е да прочетеш изречението. Ако утре
revisorбъде преведен на английски, всички клиенти, които четяха изречения, се чупят едновременно.
RFC 9457 решава и трите неща с JSON обект от пет члена. Един от тях, type, е име на проблема, което никога не се променя, и е единственото, от което машината има нужда, за да вземе решение. Други два, title и detail, са за хората и могат да се формулират и превеждат както пожелаеш. Онова, от което машината има нужда освен името — id-то на повторената услуга, списъкът с полетата, които са сбъркани, — отива в собствени членове, разширенията, никога вътре в изречение. И всичко пътува със собствен тип съдържание, application/problem+json, за да знае всеки какво чете, преди да го отвори.
Урокът има четири части:
- Форматът (5.1 до 5.3): какво се променя в отговора, типът съдържание и петте члена.
- Всеки член в дълбочина (5.4 до 5.8):
typeиabout:blank,titleиdetail,status,instance. - Отвъд петте (5.9 до 5.12): разширения, няколко проблема, регистърът на IANA и историята на формата.
- Пълният
revisor(5.13 до 5.16): централният обработчик, файловете, изпълнението и програмата, която го проверява.
Концепциите
5.1 Същата грешка, преди и след
Започни, като видиш проблема. Тези четири заявки са към revisor от Урок 4, такъв, какъвто го остави:
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 25
Date: Wed, 21 Oct 2026 13:18:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
$ 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: text/plain; charset=utf-8
Content-Length: 35
Date: Wed, 21 Oct 2026 13:18:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
PATCH no se admite en /servicios/1
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"","url":"ftp://archivos","limite_ms":5}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 137
Date: Wed, 21 Oct 2026 13:18:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
nombre: debe ser un texto de 1 a 40 caracteres
url: debe ser una dirección http o https
limite_ms: debe ser un entero entre 100 y 10000
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 409 Conflict
Content-Type: text/plain; charset=utf-8
Content-Length: 50
Date: Wed, 21 Oct 2026 13:18:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya existe un servicio llamado pagos: /servicios/2
Всеки отговор е правилен според онова, което изискваше Урок 4: добър код, добри заглавия, ясно изречение. Но погледни телата така, както би ги видяла една програма. Първото е изречение с число вътре. Второто — друго изречение с метод и маршрут вътре. Третото — три реда със собствен формат, campo: explicación, който не се среща в никоя друга грешка. Четвъртото пъха маршрут в края на изречението, след двоеточие. Четири грешки, четири форми, и всички са text/plain: нищо в отговора не казва на клиента, че има структура, която може да използва.
Сега същата първа заявка към revisor от този урок:
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 13:18:26 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"}
Три неща се промениха. Content-Type вече не е text/plain, а application/problem+json. Появи се Content-Language: es, което казва на какъв език са частите за хората. А тялото е JSON обект с пет члена, винаги едни и същи пет, във всички грешки, с които отговаря revisor. Кодът и фразата му, 404 Not Found, не се промениха. Кодът продължава да решава; тялото сега обяснява със структура, която може да се чете, без да се тълкуват изречения.
Онова, което не се промени, също има значение. Урок 4 показа ясно, че грешката на сървъра не носи стекове, пътища на машината или тайни, и новият формат не отслабва нищо от това: Problem Details е публична повърхност точно като текста отпреди, и самият RFC 9457 го казва в своя раздел 5 (раздел 5.8 от този урок).
5.2 application/problem+json: типът съдържание
RFC 9457 определя модела на един проблем като JSON обект и когато този обект пътува като документ, му дава собствен тип съдържание: application/problem+json (раздел 3). Той е вписан в регистъра на типовете съдържание на IANA, който днес препраща към RFC 9457: няма задължителни параметри, нито незадължителни, а за всичко останало се държи като application/json. Наставката +json е обща конвенция: тя казва „това е JSON с конкретно значение“. Програма, която не познава значението, може да го прочете като JSON; програма, която го познава, знае, преди да отвори тялото, че вътре има проблем с type, title и останалите.
Защо не е достатъчно application/json? Защото типът съдържание е първото, което вижда клиентът, а с application/json той не може да различи грешка от ресурс. Помисли за общ клиент, който получава 404 от API, който не познава. Ако типът е application/problem+json, той знае, че може да потърси type и detail и да ги покаже. Ако е application/json, може да е проблем, може да е празна страница с резултати със собствена форма, може да е каквото и да е. Разделът „Грешката, която ще видиш“ предизвиква точно това: съвършено тяло с грешния тип, и програмата, която проверява грешките, го отхвърля и в тринайсетте случая.
Има и версия на XML, application/problem+xml, която RFC определя в своето приложение B за API, които говорят XML. revisor говори JSON, затова не я използва.
RFC добавя една подробност, която си струва да се знае: вторият му пример, същият с errors и pointer, който използва revisor, изпраща application/problem+json, макар клиентът да е поискал само Accept: application/json, и уточнява, че HTTP го позволява (раздел 3, с препратка към раздел 12.5.1 на RFC 9110). Договарянето на съдържанието е предпочитание на клиента, а не заповед: RFC 9110 казва в своя раздел 12.4.1, че ако никое представяне не е приемливо според дадено заглавие за договаряне, сървърът може да отговори 406 или да пренебрегне това заглавие. При грешка да го пренебрегнеш почти винаги е най-доброто: 406 вместо обяснението на грешката оставя клиента, без да знае какво се е объркало. Упражнение 3 го проверява с revisor.
5.3 Петте члена
Това е тялото на предишния отговор, на няколко реда, за да се чете по-лесно (това е фрагмент: revisor го изпраща на един-единствен ред):
{
"type": "https://revisor.example/problemas/servicio-inexistente",
"title": "El servicio no existe",
"status": 404,
"detail": "no existe el servicio 99",
"instance": "/servicios/99"
}
RFC 9457 определя пет члена в раздел 3.1, а тази таблица казва за кого е всеки от тях и на какъв въпрос отговаря:
| Член | Тип | За кого | На какво отговаря | Променя ли се между възникванията? |
|---|---|---|---|---|
type |
текст (URI) | машини | какъв проблем е? | никога |
title |
текст | хора | как се казва този проблем? | не, освен заради езика |
status |
число | машини, които са изгубили реда на състоянието | какъв код отговори сървърът? | не в revisor, който използва един-единствен код за всеки type (RFC позволява един type да допуска няколко) |
detail |
текст | хора | какво стана този път и как да го поправя? | да |
instance |
текст (URI) | онзи, който дава поддръжка | кое възникване беше? | да |
И петте са незадължителни според RFC: проблем без type се приема като about:blank (раздел 3.1.1), а останалите може да липсват. revisor винаги ги изпраща и петте, в един и същи ред, във всички грешки, с които отговаря той (на 400 за метод с абсурдно име, като -X constructor, отговаря анализаторът на Node, преди revisor да го види, и затова то не носи тяло; раздел 5.14 го показва). Това е проектно решение, а не норма: клиент, който знае, че те винаги са налице, не трябва да пише случая, в който липсват, а една автоматична проверка може да ги изисква (програмата от раздел 5.15 го прави).
RFC има правило за онзи, който чете, което си струва да научиш рано: ако един член носи тип, който не е неговият, той се пренебрегва, сякаш го няма (раздел 3.1). status, който пристига като текст, "404", не е число, така че клиент, който следва RFC, се държи така, сякаш няма status. Това правило е измислено, за да не се чупи клиентът заради небрежен сървър, и е още една причина сървърът да бъде внимателен: грешка в типа не дава съобщение за грешка, а член, който тихо изчезва.
Петте раздела, които следват, разглеждат всеки член поотделно.
5.4 type: стабилният идентификатор
Погледни първите два отговора от изпълнението на този урок. Първият иска услуга, която не съществува; вторият — услуга 3, която съществува, но е на екип b, а пита някой от екип a:
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 13:18:26 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"}
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 172
Date: Wed, 21 Oct 2026 13:18:26 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 3","instance":"/servicios/3"}
Двата имат един и същи type и един и същи title. Променят се само detail и instance, и в двата случая само заради числото. Това е, което Урок 2 поиска от теб, за да не издаваш кои услуги съществуват: 404, който крие нещо нарочно, трябва да е неразличим от 404 за нещо, което не съществува, а сега правилото обхваща и type. Различен type за „е на друг екип“ би бил същото изтичане като 403, само записано на друго място.
type е името на проблема и е единственото, с което машината трябва да взема решение. RFC го казва с трябва (MUST): онзи, който консумира проблем, трябва да използва type като основния идентификатор на типа проблем (раздел 3.1.1). Не title, който се превежда. Не detail, който се променя при всяко възникване. А type.
Оттук идва правилото, което дава името на този раздел: един type никога не се променя. Не се променя от едно възникване към друго: услуга 99 и услуга 3 дават един и същ. Не се променя между езиците: ако revisor бъде преведен на английски, title и detail ще се променят, а type ще продължи да бъде https://revisor.example/problemas/servicio-inexistente, с думите си на испански, защото не е дума, а идентификатор. И не се променя между версиите на API: да промениш един type е промяна, която чупи всички клиенти, които са вземали решения с него, също както промяната на маршрут.
type е URI. RFC позволява всякаква URI препратка, но препоръчва тя да бъде абсолютна (раздел 3.1.1). Относителен type, като servicio-inexistente, би се разрешил спрямо адреса на заявката и една и съща дума би означавала две различни неща в зависимост от това къде е била поискана: RFC дава точно този пример. revisor използва абсолютни адреси под https://revisor.example/problemas/, плюс ключа на проблема.
Ако type е адрес https, при посещението му трябва да има документация за хора, която обяснява проблема и как да се реши (раздел 3.1.1 и раздел 4: „A problem type URI SHOULD resolve to HTML documentation“). В истински API https://tuapi.example.com/problemas/servicio-inexistente би бил страница, която казва кога възниква, какво означава всяко разширение и какво да се направи. Курсът използва revisor.example, защото .example е домейн, запазен за документация (RFC 6761), който не сочи към никоя машина: така нито един пример от курса не съвпада с домейна на истинска компания. В твоя API type стои под домейн, който контролираш.
Още две правила на RFC за type, които могат да се посетят. Първото: клиентът не бива (SHOULD NOT) да посещава type автоматично, освен за да даде информация на разработчика, например с инструмент за отстраняване на грешки (раздел 3.1.1). type се сравнява, а не се изтегля: клиент, който иска страницата при всяка грешка, би удвоил заявките и би зависел от това документацията да е налична. Второто: RFC позволява type, който не може да се посети, като URI tag:, но препоръчва онези, които могат, защото ако един ден искаш да публикуваш документацията, а твоят type не е бил адрес, ще трябва да го промениш, а промяната чупи всички.
И проектният въпрос на този раздел: кога един проблем заслужава собствен type? RFC дава критерия в раздел 4: общите проблеми, онези, които биха могли да се случат на всеки ресурс в уеб, се изразяват по-добре само с кода. Неговият пример е 403 при PUT: тип „не можеш да пишеш“ не добавя нищо, което кодът да не казва. Собствен type си струва, когато проблемът е от твоята предметна област и клиентът има нужда да го различи от други със същия код: „услугата не съществува“ срещу „проверката не съществува“ срещу „маршрутът не съществува“, или „данните не могат да бъдат изпълнени“ срещу „revisor не се свързва с тази цел“, които са двата 422 на revisor.
5.5 about:blank: проблемът, който е само своя код
Четири грешки на revisor нямат собствен type. Ето две от тях:
$ curl -i http://127.0.0.1:18080/servicioss -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 111
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"about:blank","title":"Not Found","status":404,"detail":"no existe esa ruta","instance":"/servicioss"}
$ 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 13:18:26 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"}
Маршрут, който не съществува, и метод, който маршрутът не допуска, са проблеми на HTTP, а не на revisor: могат да се случат на всеки API и кодът вече казва всичко, което трябва да се каже (с Allow, в случая на 405). Затова съществува about:blank. RFC го вписва като единствения тип, който регистърът съдържа от самото начало, и казва, че проблем about:blank няма друго значение освен това на своя код на състоянието (раздел 4.2.1). Другите две са 415, тяло, което не се обявява за JSON, и 413, тяло от повече от 16 KiB, което ще видиш в изпълнението от раздел 5.14.3: на всеки API могат да пристигнат и кодът, със своите заглавия, вече казва всичко.
about:blank е освен това стойността по подразбиране: проблем без type се приема така, сякаш казва about:blank (раздели 3.1.1 и 4.2.1). revisor все пак го записва, заради правилото за петте члена.
При about:blank title е фразата на кода. RFC казва, че title трябва (SHOULD) да бъде препоръчителната фраза за този код: Not Found за 404 и така за всички (раздел 4.2.1). Затова revisor казва Not Found, Method Not Allowed и Unsupported Media Type, на английски, макар всичко останало да е на испански. Причината е, че при about:blank няма собствен тип, който да дава име на проблема: името на проблема е кодът, а кодът има официална фраза, тази от регистъра на кодовете на IANA, която всички разпознават.
Същият ред на RFC добавя, че тази фраза може да бъде преведена според онова, което клиентът поиска с Accept-Language. Това е разрешение, а не задължение, и revisor не го използва: оставя фразата от регистъра такава, каквато е. Онова, което в тези три грешки наистина е на испански, е detail, който обяснява възникването: „no existe esa ruta“, „PATCH no se admite en /servicios/1“. Това е същото разделение както винаги: името — стабилно; обяснението — за онзи, който чете.
А не е ли странно да се изпраща Content-Language: es с title на английски? RFC 9110 го предвижда: Content-Language описва езика на публиката, за която е предназначено представянето, и казва, че това „може да не е равностойно на всички езици, които се използват вътре“ в него (раздел 8.5). Публиката на revisor чете испански; това, че едно собствено име от HTTP се появява на английски, не променя за кого е предназначено.
Една подробност за точност, която revisor решава в respuestas.ts: Node съдържа старите имена на два кода, 413 и 422, а регистърът на IANA има новите, Content Too Large и Unprocessable Content. Редът на състоянието използва тези от регистъра. А 413 на revisor е about:blank, така че неговият title също трябва да е този от регистъра, Content Too Large, а не Payload Too Large, който идва от Node: каталогът го записва ръчно, а програмата от раздел 5.15 го проверява.
5.6 title и detail: онова, което се превежда
title и detail са двата члена за хора и се различават само по едно нещо: title принадлежи на типа, а detail — на възникването.
title е кратко резюме на типа проблем. RFC казва, че той не бива (SHOULD NOT) да се променя от едно възникване към друго, освен за да бъде преведен (раздел 3.1.3). „El servicio no existe“ е едно и също за услуга 99, за услуга 3 и за онази, която поискаш утре. Затова в revisor title живее в каталога, до type, и не го пише онзи, който отговаря: пише го онзи, който проектира типа, веднъж. RFC добавя, че title е само ориентировъчен, предназначен за онзи, който не познава значението на type и не може да го разбере, например при четене на дневник без връзка.
detail обяснява това възникване: „no existe el servicio 99“. RFC иска от него две неща (раздел 3.1.4). Първото — да се съсредоточи върху това да помогне на клиента да поправи проблема, а не да дава информация за отстраняване на грешки: „услуга 5 не отговори за 1000 ms“ му казва какво е станало и му подсказва какво да промени (ограничението на услугата); стекът не му е от никаква полза и показва на света как е направен сървърът. Второто е правило за онзи, който чете: клиентът не бива да тълкува detail, за да извлича информация; за това са разширенията, които са „по-подходящи и по-малко податливи на грешки“.
Това второ правило е сцената в началото, записана като норма. 409 от Урок 4 казваше „ya existe un servicio llamado pagos: /servicios/2“, а скриптът разделяше изречението. Сравни го с този от настоящия урок:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Content-Language: es
Content-Length: 219
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/nombre-repetido","title":"Ya existe un servicio con ese nombre","status":409,"detail":"ya existe un servicio llamado pagos","instance":"/servicios","existente":"/servicios/2"}
Маршрутът излезе от detail и се премести в собствен член, existente. detail вече няма нужда от него и е чисто изречение, което може да бъде подобрено, съкратено или преведено, без да се счупи никой. Клиентът, който има нужда от съществуващата услуга, чете existente и получава /servicios/2, без да разделя текст. Това е разширението от раздел 5.9.
Разделението ти позволява също да подобряваш текстовете, без да искаш разрешение от никого. Представи си, че title на etiqueta-vencida казваше „El servicio cambió desde que lo leíste“ („Услугата се е променила, откакто я прочете“). Това би било невярно в един случай: след рестартиране старият етикет също не съвпада, макар никой да не е променял услугата. Замяната му с „Tu etiqueta ya no es la vigente“ („Твоят етикет вече не е актуалният“), което е вярно и в двата случая и е онова, което използва revisor, не би счупила нито един клиент, защото type, https://revisor.example/problemas/etiqueta-vencida, би останал същият. title се поправя; type не се пипа.
Какво се превежда и какво не, на един ред: превеждат се title и detail; не се превеждат type, status, instance, нито имената, нито стойностите на разширенията. Курсът има издания на други езици, но кодът на програмите се копира без превод: revisor на всички издания отговаря с title и detail на испански, а type е идентичен във всички. Ако някой revisor бъде преведен, тези две полета ще се променят, а type ще остане същият. Клиент, написан спрямо испанското издание, работи по същия начин срещу revisor, който отговаря на български, защото никога не е чел нито една дума.
5.7 status: кодът, повторен нарочно
status е кодът на състоянието, като число, същият като в реда на състоянието. Защо да се повтаря? RFC дава две причини (раздел 3.1.2). Посредник, като кеш или прокси, може да е променил кода по пътя и клиентът може да види кой е бил оригиналният. А един проблем може да бъде съхранен без своя HTTP отговор — в дневник, в опашка, в имейл, — и там вече няма ред на състоянието.
Но има строго правило: онзи, който генерира проблема, трябва да използва в HTTP отговора същия код, който поставя в status. А членът е само ориентировъчен: общият HTTP софтуер — проксита, балансьори на натоварването, защитни стени, антивирусни програми — не го познава и не го зачита, а продължава да използва кода от реда на състоянието (раздели 3.1.2 и 5). Ако двата се различават, за всеки, който не чете тялото, е спечелил редът на състоянието, а това е почти целият път. Това е първият принцип на курса, казан за конкретен случай: тялото никога не противоречи на кода.
RFC го поставя в своя раздел за сигурност и си струва да се цитира изцяло като идея: тъй като status дублира онова, което вече казва редът на състоянието, съществува възможност двете да не съвпаднат и не е ясно кое има предимство. Затова в revisor няма начин да се различават: status идва от каталога заедно с type, а същата функция, която пише тялото, пише реда на състоянието с това число. Онзи, който отговаря с грешка, не избира кода: избира проблема, а проблемът носи своя код.
5.8 instance: кое възникване беше
instance е URI, който идентифицира това възникване на проблема (раздел 3.1.5). RFC оставя отворено какъв URI да се използва, а revisor взема две различни решения според случая.
При грешките на клиента instance е поисканият маршрут, без заявката за търсене: /servicios/99, /servicioss, /servicios. Той казва на онзи, който чете проблема в дневника, към кой ресурс е отивала заявката. Заявката за търсене (онова, което идва след ?) се маха, защото в нея понякога пътуват данни, които не бива да остават копирани в грешка. Внимание: терминалът на revisor наистина записва цялата заявка, със заявката за търсене; в истински API дневникът също изрязва или маскира онова, което не бива да се съхранява. И това е проектен критерий, който е добре да се каже ясно и недвусмислено: той леко се отклонява от RFC. RFC определя instance като идентификатора на това възникване и казва, че ако URI може да бъде посетен, там би трябвало да може да се получи проблемът (раздел 3.1.5). /servicios/99 може да бъде посетен, но връща ресурса (или същото 404), а не това възникване; и две заявки към /servicios/99 дават един и същ instance. Използва се, защото казва на онзи, който чете дневника, към кой ресурс е отивала заявката, а RFC го допуска по форма: собственият му пример използва маршрут (/account/12345/msgs/abc) и препоръчва относителният URI да съдържа пълния маршрут. Там, където наистина е важно да се идентифицира възникването, при 500, revisor използва уникална препратка.
При вътрешната грешка instance е уникална препратка:
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer escritura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Content-Language: es
Content-Length: 244
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/error-interno","title":"Error interno del revisor","status":500,"detail":"ocurrió un error inesperado; si se repite, reporta la referencia","instance":"urn:uuid:d80e7ef5-6cf9-4099-83a4-53c6d6116787"}
urn:uuid:, последвано от универсален уникален идентификатор (RFC 9562), е URI, който не може да бъде посетен и чиято вероятност да се повтори е пренебрежимо малка (122 случайни бита: не е невъзможно, а е абсурдно малко вероятно). Всяко 500 носи нов и същият идентификатор остава в терминала на revisor, заедно със стека:
GET /servicios/1/informe
error d80e7ef5-6cf9-4099-83a4-53c6d6116787 en GET /servicios/1/informe: TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:314:28)
at atender (file://<carpeta>/rutas.ts:107:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:23:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:38:44)
Ето как поддръжката работи с грешка на сървъра: клиентът съобщава „получих urn:uuid:…“, а онзи, който експлоатира системата, търси този текст в своя дневник и намира точния стек, маршрута и часа. Клиентът не получава стека и няма нужда от него. Това е същата препратка, която Урок 4 поставяше в изречението на 500, сега в члена, който RFC е проектирал за това, и с detail, който вече не я повтаря: „ocurrió un error inesperado; si se repite, reporta la referencia“.
RFC предлага, ако instance може да бъде посетен, там да може да се получи пълният проблем (раздел 3.1.5), а в своя раздел за сигурност предупреждава онзи, който предлага тази информация по HTTP, да избягва разкриването на подробности за реализацията, като например дъмп на стека. revisor не предлага такава страница: стекът живее в терминала, който вижда само онзи, който експлоатира системата.
Дотук е първата половина. Ако работиш в две сесии, тук е прекъсването.
5.9 Разширения: онова, което типът добавя
Един тип проблем може да добавя собствени членове към обекта (раздел 3.2). Те се наричат разширения и са мястото за всичко, от което машината има нужда освен името на проблема. revisor има две: existente в 409, което вече видя, и errors в 422, което ще видиш в раздел 5.10.
Разширението принадлежи на типа, а не на възникването. Определя го онзи, който проектира типа, заедно с неговия type, неговия title и неговия status, и се документира на страницата на type. existente винаги присъства в nombre-repetido и винаги е маршрутът на услугата, която вече има това име. Клиент, който познава типа, знае, че може да разчита на него.
Клиентът трябва да пренебрегва разширенията, които не познава (раздел 3.2). Това е правилото, което позволява на типовете да растат: ако утре revisor добави към nombre-repetido разширение sugerencia със свободно име, днешните клиенти продължават да работят, защото не го търсят. По същата причина премахването на разширение, което клиентите използват, наистина чупи: все едно да премахнеш поле от ресурс.
Името на разширението има правила. RFC казва в раздел 4, че името трябва (SHOULD) да започва с буква, да използва само букви, цифри и долна черта и да има три или повече знака. Първите две правила имат причина, която RFC посочва: името да може да се записва и в други формати освен JSON, като XML. Правилото за трите знака RFC го изисква, без да го обяснява; това е препоръка (SHOULD), а не задължение. existente и errors ги изпълняват. ex не би ги изпълнило (има два знака), а nombre-previo — също не (съдържа тире). Упражнение 4 ти възлага да накараш problemas.ts да го проверява.
И името не може да бъде това на стандартен член. Тук има капан на JavaScript. fallar сглобява тялото с { type, title, status, detail, instance, ...extensiones }: първо петте, а след това разширенията. Ако някой подаде разширение с име status, ... би го записало върху status от каталога и тялото би казвало код, различен от този в реда на състоянието: несъответствието, за което раздел 5.7 каза, че не може да се случи. Затова fallar отхвърля петте запазени имена, преди да сглоби тялото, като хвърля грешка. Тази грешка я хваща servidor.ts и я превръща в 500 със своята препратка: това е дефект на програмата, а не на клиента, и така се вижда в терминала, вместо да излезе като отговор, който лъже.
А заглавията? Retry-After в 503, Allow в 405, WWW-Authenticate в 401 и Accept в 415 продължават да бъдат заглавия, а не разширения. RFC го предвижда: дефиницията на един тип може да изисква отговорът да носи Retry-After (раздел 4). Заглавията ги чете общият HTTP софтуер; разширението го чете само онзи, който разбира типа. Онова, което HTTP вече има като заглавие, остава заглавие, и затова fallar получава заглавията отделно от разширенията.
5.10 Няколко проблема в един отговор
Една заявка може да има повече от един проблем. RFC различава два случая и revisor има и двата.
Няколко проблема от един и същи тип: разширение със списъка. Регистрация с три грешни полета има три пъти един и същ проблем, „данните не могат да бъдат изпълнени“:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"","url":"ftp://archivos","limite_ms":5}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 407
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"3 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser un texto de 1 a 40 caracteres","pointer":"#/nombre"},{"detail":"debe ser una dirección http o https","pointer":"#/url"},{"detail":"debe ser un entero entre 100 y 10000","pointer":"#/limite_ms"}]}
Един-единствен проблем datos-invalidos, с разширение errors, което изброява трите грешки. Всяка грешка е обект със своя detail, който обяснява какво не е наред, и pointer, който казва къде. Това е точно формата на втория пример от раздел 3 на RFC 9457, който определя тип за валидиране с разширението errors и казва, че когато типът е проектиран за това, разширенията могат да носят повече от едно възникване на един и същ проблем. detail отгоре само казва колко грешки има: онова, от което клиентът има нужда, е в списъка.
pointer е JSON Pointer (RFC 6901): стандартен начин да се посочи място в JSON документ. #/nombre е членът nombre на кореновия обект; #/perfil/color би бил членът color вътре в perfil; #/servicios/0 би бил първият елемент на списъка servicios. # отпред е начинът указателят да се запише като фрагмент на URI (раздел 6 на RFC 6901), същият, който използва примерът на RFC 9457. С това един формуляр може да постави всяко съобщение до своето поле, без да тълкува изречения: pointer казва кое, detail казва какво.
Сравни с 422 от Урок 4: същото съдържание, три реда campo: explicación. Там клиентът трябваше да разделя текста по новите редове и по двоеточията, а съобщение, което съдържаше двоеточие, го чупеше. Тук всяка част е в своя член.
Няколко проблема от различни типове: отговаря се с най-важния. Сега регистрация с име, което вече съществува, и забранен адрес:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 230
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 169.254.169.254:80: dirección privada o reservada","instance":"/servicios"}
pagos вече съществува, така че тази регистрация има два проблема: nombre-repetido (409) и destino-prohibido (422). revisor отговаря само с втория. RFC казва, че когато един API открие няколко проблема, които не споделят типа, препоръчително е да се отговори с най-важния или най-спешния (раздел 3). И обяснява защо не се обединяват: макар да може да се измисли тип „пакет“, който носи разнородни проблеми, това не пасва на HTTP. Прав е заради кода: един отговор има един-единствен код на състоянието, а 409 и 422 заедно не могат да се изразят с един.
Кой е най-важният? RFC не го решава; решава го твоят API, а в revisor го решава редът, в който той пита. Първо входната точка, със седемте въпроса от Урок 2; след това, в рамките на регистрацията, validar събира всички грешки в данните; после destinoPermitido проверява адреса; и накрая се търси повторено име. Забраненият адрес е по-спешен от повтореното име по практическа причина: ако клиентът смени само името, отново ще се сблъска с адреса; ако смени адреса, може името вече да не го интересува. Упражнение 2 ти възлага да предскажеш още три такива равенства.
5.11 Каталогът и регистърът на IANA
revisor има двайсет и два типа проблеми, всеки със своя ключ, своя type, своя title и своя status. Те са на едно-единствено място, каталога в problemas.ts (раздел 5.13), и това е половината от стойността на формата: вместо двайсет и осем извиквания със собствена фраза, разпръснати в пет файла, една таблица, която може да се прочете, прегледа и документира наведнъж. Урок 8 я превръща в описанието на API в OpenAPI.
RFC 9457 изисква дефиницията на нов тип да документира три неща: URI на типа, кратък title и кода, с който се използва (раздел 4). Това са трите колони на каталога. Тя може освен това да определя разширения и да изисква Retry-After, което в revisor са existente, errors и 503 за поддръжка.
За типовете, които много API биха могли да споделят, RFC 9457 създаде официален регистър в IANA, „HTTP Problem Types“ (раздел 4.2). Политиката му е Specification Required: за да се впише тип, е нужна спецификация, публикувана по стабилен и свободен начин, макар да не е задължително да е стандарт. И има едно изключение, което тук е важно: собствените стойности на производител, на приложение или на инсталация не могат да се вписват. Типовете на revisor са на неговото приложение, така че живеят под собствения му домейн и никога не биха отишли в регистъра.
Към 21 октомври 2026 г. регистърът има шест записа (последно актуализиран на 26 юни 2026 г.):
type |
Име | Препоръчителен код | Определен в |
|---|---|---|---|
about:blank |
See HTTP Status Code | — | RFC 9457 |
…#date |
Date Not Acceptable | 400 |
RFC 9458 |
…#ohttp-key |
Oblivious HTTP key configuration not acceptable | 400 |
RFC 9458 |
…#digest-unsupported-algorithms |
Unsupported Hashing Algorithms | 400 |
draft-ietf-httpapi-digest-fields-problem-types, вече одобрен и в очакване на своя номер на RFC |
…#digest-invalid-values |
Invalid Digest Values | 400 |
същият документ |
…#digest-mismatched-values |
Mismatched Digest Values | 400 |
същият документ |
Многоточието е https://iana.org/assignments/http-problem-types — префиксът, който RFC позволява на вписванията да използват и за който предупреждава, че може да не води до страница (раздел 4.2). Това, че регистърът е толкова малък, казва нещо: почти всички проблеми са на конкретно приложение, а за наистина общите вече съществува кодът на състоянието с about:blank. Преди да измислиш тип, погледни регистъра: ако има подходящ, използвай го повторно: затова съществува регистърът („to promote reuse“, раздел 4.2) и това предлага примерът от раздел 4.1.
5.12 От RFC 7807 към RFC 9457
Форматът има повече история от номера си. Започна като индивидуален проект на Mark Nottingham, датиран 4 юли 2012 г., Indicating Details of Problems to Machines in HTTP, с необичайно предупреждение на първата страница: че не е ясно дали определянето на формата ще подобри нещата достатъчно, за да компенсира вредата, която може да причини, и че използването му, преди да стане RFC, „може да те изложи на присмех“. Рискът, който виждаше, беше обвързването: клиентите да зависят твърде много от подробностите на всеки API. Публикува го въпреки това, защото, както казваше, много хора вече го правеха по свой начин.
Той беше публикуван като RFC 7807 през март 2016 г., от Mark Nottingham и Erik Wilde, и в продължение на седем години беше еталонът. През юли 2023 г. го замени RFC 9457, от същите двама автори и Sanjay Dalal, също предложен стандарт на IETF, който обявява 7807 за остарял. Приложение D изброява промените и те са три:
- Регистърът на типовете (раздел 4.2): 7807 нямаше общо място за споделените типове; 9457 създаде това на IANA и вписа в него
about:blank. - Какво да се прави с няколко проблема (раздел 3): разширението със списъка за онези от един и същи тип и най-важният за онези от различни типове, с примера с
errorsиpointer, който използваrevisor. type, които не могат да бъдат посетени (раздел 3.1.1): кога е допустим URI катоtag:и защо се предпочитат онези, които могат да се посетят.
Онова, което не се промени, е почти всичко: петте члена, техните типове, application/problem+json и application/problem+xml. Проблем, написан според 7807, е валиден според 9457, и клиент на 7807 чете без промени онова, което изпраща revisor. Остават следи от старото име: пространството от имена на XML формата продължава да бъде urn:ietf:rfc:7807, а JSON схемата в приложение A все още е озаглавена „An RFC 7807 problem object“. Ако видиш библиотека, която казва „RFC 7807“, тя почти винаги произвежда същото като този урок; онова, което ѝ липсва, ако изобщо нещо ѝ липсва, е регистърът и насоките за няколко проблема.
5.13 Централният обработчик: problemas.ts
Всичко дотук се побира в един нов файл от шейсет реда:
// fig05_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 },
"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);
}
Прочети го на три части.
Каталогът. CATALOGO е обект, чиито ключове са кратките имена на проблемите (servicio-inexistente, datos-invalidos…), а чиито стойности носят type, title и status. Първите четири използват about:blank с фразата от регистъра; останалите — основата https://revisor.example/problemas/ плюс ключа. satisfies Record<string, Tipo> иска от TypeScript да провери, че всеки запис има трите полета с правилния тип, без да се губят точните имена на ключовете. Затова Clave, типът на втория ред след каталога, е точният списък с ключове: ако някой извика fallar(respuesta, "servicio-inexsitente", …) с разменена буква, tsc го отбелязва, преди да се изпълни каквото и да е.
fallar. Получава отговора, ключа на проблема, detail на това възникване и някои опции: заглавия, разширения и instance. Взема от каталога type, title и status, така че онзи, който извиква, не може да напише status, различен от кода, нито title, различен от този на типа. Отхвърля разширенията със запазено име (раздел 5.9). Изчислява instance като поискания маршрут без заявката за търсене, освен ако онзи, който извиква, не изпрати друг (500). Сглобява тялото първо с петте члена, а след това с разширенията, и пише реда на състоянието със същия status, заглавията на онзи, който извиква, типа application/problem+json, Content-Language: es и точната дължина в байтове. Content-Length се изчислява с Buffer.byteLength, а не с .length, защото буквите с ударение заемат два байта в UTF-8: „revisión“ има осем букви и девет байта.
Онова, което го няма. fallar не решава нищо за проблема: не избира кода, не избира типа, не превежда. Само превръща вече взето решение (ключа) в отговор. Това е идеята на централния обработчик: всяко място в програмата решава какъв проблем е имало, а едно-единствено място решава как се казва. Ако утре revisor трябва да договаря езика или да изпраща application/problem+xml, ще се промени този файл и никой друг.
respuestas.ts губи своя текстов fallar и експортира escribirCabecera, която сега използва и problemas.ts, за да бъде фразата в реда на състоянието тази от регистъра:
// fig05_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);
});
}
В останалите файлове всяко извикване се променя от код към ключ, а онова, което бяха заглавия, преминава в опцията cabeceras. Например 405 в rutas.ts (фрагмент):
// Lección 4
fallar(respuesta, 405, `${peticion.method} no se admite en ${url.pathname}`, { Allow: permitidos(ruta) });
// Lección 5
fallar(respuesta, "metodo-no-admitido", `${peticion.method} no se admite en ${url.pathname}`, {
cabeceras: { Allow: permitidos(ruta) },
});
Три извиквания променят нещо повече от формата, защото информацията, от която машината има нужда, излиза от изречението. 409 премества маршрута в разширението existente; 422 заменя редовете със списъка errors; а 500 премества препратката в instance. Ще ги видиш изцяло в раздел 5.14.
5.14 Пълният revisor
5.14.1 Файловете, които се променят
revisor от този урок живее в ~/curso-http/fig05_01/. Ако имаш папката от Урок 4, копирай я с това име: освен problemas.ts и respuestas.ts, които вече видя изцяло, се променят пет файла. Във всички тях извикванията на fallar преминават от код към ключ, а import на fallar сега идва от problemas.ts.
servidor.ts: 500 носи препратката в instance.
// fig05_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}`);
});
rutas.ts: входната точка, с about:blank за маршрута и метода и собствените типове за удостоверението и разрешението.
// fig05_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 });
}
servicios.ts: rechazarDatos сглобява списъка errors, а двата 409 носят existente.
// fig05_01/servicios.ts — lo que hace cada ruta de la API del revisor.
import type { IncomingMessage, ServerResponse } from "node:http";
import {
buscarPorNombre, buscarRevision, buscarServicio, cambiarServicio, crearRevision, crearServicio,
equipoDeRetirado, etiqueta, listarServicios, retirarServicio, terminarRevision,
revisionesDe, type Revision, type Servicio,
} from "./almacen.ts";
import type { Credencial } from "./autenticacion.ts";
import { revisarDestino } from "./destinos.ts";
import { coincideDebil, coincideFuerte, hayCoincidencia, interpretarLista } from "./etiquetas.ts";
import { consultar as consultarAhora, revisar } from "./cliente.ts";
import { permitir } from "./limitador.ts";
import { 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;
// 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;
const validos = validar(datos);
if (Array.isArray(validos)) {
rechazarDatos(respuesta, validos);
return;
}
if (!(await destinoPermitido(validos.url, respuesta))) {
return;
}
const existente = buscarPorNombre(credencial.equipo, validos.nombre);
if (existente !== undefined) {
fallar(respuesta, "nombre-repetido", `ya existe un servicio llamado ${validos.nombre}`, {
extensiones: { existente: `/servicios/${existente.id}` },
});
return;
}
const servicio = crearServicio({ ...validos, equipo: credencial.equipo });
enviarJson(respuesta, 201, representar(servicio), {
Location: `/servicios/${servicio.id}`,
ETag: etiqueta(servicio),
});
}
export function consultar({ peticion, respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const actual = etiqueta(servicio);
// If-None-Match trae «*» o una lista, y se compara en débil (RFC 9110, secciones 13.1.2 y 8.8.3.2).
const condicion = peticion.headers["if-none-match"];
if (condicion !== undefined && hayCoincidencia(condicion, actual, coincideDebil)) {
enviarVacio(respuesta, 304, { ETag: actual });
return;
}
enviarJson(respuesta, 200, representar(servicio), { ETag: actual });
}
// PUT: cambiar sin pisar el cambio de otra persona. Exige If-Match con la etiqueta vigente.
export async function cambiar({ peticion, respuesta, parametros, credencial }: Contexto): Promise<void> {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
// Primero el formato (415), después la condición (428 y 412), después la lectura (400).
if (!esJson(peticion, respuesta)) return;
const condicion = peticion.headers["if-match"];
if (condicion === undefined) {
fallar(respuesta, "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 revisar(servicio.url, servicio.limiteMs));
}, ESPERA_EN_COLA_MS);
enviarJson(respuesta, 202, representarRevision(revision), { Location: `/revisiones/${revision.id}` });
}
export function consultarRevision({ respuesta, parametros, credencial }: Contexto): void {
const revision = buscarRevision(Number(parametros[0]));
const servicio = revision === undefined ? undefined : buscarServicio(revision.servicio);
const equipo = servicio?.equipo ?? (revision === undefined ? undefined : equipoDeRetirado(revision.servicio));
if (revision === undefined || equipo === undefined || !delEquipo(equipo, credencial)) {
fallar(respuesta, "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,
});
}
operacion.ts: 503 запазва своя Retry-After като заглавие; 403 за онзи, който не е admin, използва същия тип като този на входната точка.
// fig05_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);
}
versiones.ts: оттеглената версия има собствен тип, version-retirada, различен от servicio-retirado, защото клиентът трябва да прави различни неща: в единия случай — да спре да иска тази услуга; в другия — да премине към друга версия на API.
// fig05_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;
}
5.14.2 Файловете, които не се променят
Останалите файлове са тези от Урок 4, без нито една промяна. Дадени са изцяло, за да може revisor да бъде сглобен от този урок:
// fig05_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" });
// fig05_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"';
}
// fig05_01/cliente.ts — la cara cliente del revisor: consulta un servicio y clasifica lo que respondió.
import type { Resultado } from "./almacen.ts";
import { revisarDestino } from "./destinos.ts";
// Lo que puede pasar al consultar: o hubo una respuesta HTTP, o no la hubo por una de tres razones.
export type Consulta =
| { tipo: "respuesta"; codigo: number; ms: number; reintentarEn: number | null }
| { tipo: "tiempo-agotado" }
| { tipo: "sin-conexion" }
| { tipo: "respuesta-invalida"; motivo: string }
| { tipo: "destino-prohibido"; motivo: string };
// Retry-After trae segundos («120») o una fecha HTTP, y nada más (RFC 9110, sección 10.2.3):
// Retry-After = HTTP-date / delay-seconds, con delay-seconds = 1*DIGIT. Si no es ninguna de las dos,
// se ignora (null) y quien llama usa su espera por omisión. No se usa Date.parse: acepta «1.5», «-1»
// o «12 30» como fechas del pasado (esperar 0 s y reintentar de inmediato) y lee la forma asctime en
// la hora local de la máquina, cuando HTTP siempre habla en GMT.
export function segundosDeEspera(valor: string | null): number | null {
if (valor === null) return null;
if (/^\d+$/.test(valor)) return Number(valor);
const fecha = fechaHttp(valor);
return fecha === null ? null : Math.max(0, Math.ceil((fecha - Date.now()) / 1000));
}
const MESES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const MES = MESES.join("|");
// Las tres formas de HTTP-date (RFC 9110, sección 5.6.7). Quien recibe debe aceptar las tres.
const IMF_FIXDATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${MES}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
const RFC850 = new RegExp(
`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${MES})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`,
);
const ASCTIME = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${MES}) ( \\d|\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
// Milisegundos desde 1970 de una HTTP-date, siempre en GMT (Date.UTC, nunca la hora local); null si no lo es.
function fechaHttp(texto: string): number | null {
let partes: [string, string, number, string, string, string] | null = null;
const imf = IMF_FIXDATE.exec(texto);
const viejo = RFC850.exec(texto);
const asc = ASCTIME.exec(texto);
if (imf !== null) partes = [imf[1], imf[2], Number(imf[3]), imf[4], imf[5], imf[6]];
else if (asc !== null) partes = [asc[2].trim(), asc[1], Number(asc[6]), asc[3], asc[4], asc[5]];
else if (viejo !== null) {
// Año de dos cifras: si queda más de 50 años en el futuro, es del siglo anterior (sección 5.6.7).
const actual = new Date().getUTCFullYear();
let anio = Math.floor(actual / 100) * 100 + Number(viejo[3]);
if (anio > actual + 50) anio -= 100;
partes = [viejo[1], viejo[2], anio, viejo[4], viejo[5], viejo[6]];
}
if (partes === null) return null;
const [dia, mes, anio, horas, minutos, segundos] = partes;
const m = MESES.indexOf(mes);
const ms = Date.UTC(anio, m, Number(dia), Number(horas), Number(minutos), Number(segundos));
// Date.UTC acepta el 32 de octubre o las 25:00 y los pasa al día siguiente: se rechazan.
const comprobada = new Date(ms);
const exacta = comprobada.getUTCDate() === Number(dia) && comprobada.getUTCMonth() === m
&& comprobada.getUTCHours() === Number(horas) && comprobada.getUTCMinutes() === Number(minutos)
&& comprobada.getUTCSeconds() === Number(segundos);
return exacta ? ms : null;
}
export async function consultar(url: string, limiteMs: number): Promise<Consulta> {
// Antes de CADA conexión se vuelve a revisar el destino: el DNS pudo cambiar desde el alta.
// LÍMITE DECLARADO: esto NO cierra el DNS rebinding. fetch vuelve a resolver el nombre, y entre
// esta revisión y la conexión el nombre puede apuntar a otra IP. La lección 8 lo completa:
// se conecta a la IP ya validada, con http.request y su opción lookup.
const veredicto = await revisarDestino(url);
if (!veredicto.permitido) return { tipo: "destino-prohibido", motivo: veredicto.motivo };
const inicio = performance.now();
try {
const respuesta = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(limiteMs) });
await respuesta.body?.cancel();
return {
tipo: "respuesta",
codigo: respuesta.status,
ms: Math.round(performance.now() - inicio),
reintentarEn: segundosDeEspera(respuesta.headers.get("retry-after")),
};
} catch (error) {
if (error instanceof DOMException && error.name === "TimeoutError") return { tipo: "tiempo-agotado" };
const causa = (error as { cause?: { code?: string; message?: string } }).cause;
if (causa?.code === "ECONNREFUSED" || causa?.code === "ENOTFOUND") return { tipo: "sin-conexion" };
return { tipo: "respuesta-invalida", motivo: causa?.message ?? String(error) };
}
}
export async function revisar(url: string, limiteMs: number): Promise<Resultado> {
const consulta = await consultar(url, limiteMs);
switch (consulta.tipo) {
case "tiempo-agotado":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "tiempo agotado" };
case "sin-conexion":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "sin conexión" };
case "respuesta-invalida":
return { estado: "CAIDO", codigo: null, ms: null, detalle: "respuesta inválida" };
case "destino-prohibido":
return { estado: "CAIDO", codigo: null, ms: null, detalle: `destino prohibido: ${consulta.motivo}` };
case "respuesta": {
const { codigo, ms, reintentarEn } = consulta;
if (codigo >= 400) {
const espera = reintentarEn === null ? "" : ` (reintentar en ${reintentarEn} s)`;
return { estado: "CAIDO", codigo, ms, detalle: `${codigo}${espera}` };
}
return { estado: ms >= limiteMs / 2 ? "LENTO" : "ARRIBA", codigo, ms, detalle: String(codigo) };
}
}
}
// fig05_01/destinos.ts — a dónde puede conectarse el revisor. Defensa contra SSRF.
//
// SSRF (server-side request forgery): quien da de alta un servicio escribe una URL, y el revisor
// se conecta a ella DESDE SU RED. Sin esta revisión, cualquiera con permiso de escritura podría hacer
// que el revisor consulte 127.0.0.1, la red interna o 169.254.169.254, donde las nubes publican los
// metadatos de la máquina (y a veces sus credenciales). Un revisor de servicios es el caso típico:
// conectarse a URLs ajenas es justo su trabajo, así que no se puede quitar; se acota.
//
// La regla: se resuelve el nombre y CADA dirección debe estar en la lista de destinos permitidos
// (ip:puerto) o ser pública. Se valida al registrar la URL Y antes de cada conexión, porque el DNS
// puede cambiar entre una y otra.
//
// LÍMITE DECLARADO: aun validando antes de cada fetch, el DNS puede cambiar entre la validación y la
// conexión (DNS rebinding). La defensa completa es conectarse a la IP ya validada (con http.request
// y su opción lookup), no volver a resolver el nombre. La lección 8 la hace en cliente.ts; hasta
// entonces, el límite queda escrito aquí.
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
// Destinos permitidos aunque sean privados: el laboratorio del curso. Se cambian con REVISOR_DESTINOS.
const PERMITIDOS = new Set((process.env.REVISOR_DESTINOS ?? "127.0.0.1:18081").split(",").map((d) => d.trim()));
// Qué direcciones son «privadas o reservadas». Fuente: los registros de direcciones de uso especial
// de la IANA (IPv4 e IPv6, que actualizan al RFC 6890) y la OWASP SSRF Prevention Cheat Sheet.
//
// IPv4 va con LISTA NEGRA: no existe un bloque «global» que se pueda permitir entero, así que se
// rechazan los bloques del registro que no son alcanzables desde internet, más el multicast.
const IPV4_PROHIBIDAS = new BlockList();
IPV4_PROHIBIDAS.addSubnet("0.0.0.0", 8, "ipv4"); // «esta red», incluye 0.0.0.0
IPV4_PROHIBIDAS.addSubnet("10.0.0.0", 8, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("100.64.0.0", 10, "ipv4"); // espacio compartido (CGNAT); Alibaba Cloud publica ahí sus metadatos
IPV4_PROHIBIDAS.addSubnet("127.0.0.0", 8, "ipv4"); // loopback
IPV4_PROHIBIDAS.addSubnet("169.254.0.0", 16, "ipv4"); // enlace local, incluye los metadatos de la nube
IPV4_PROHIBIDAS.addSubnet("172.16.0.0", 12, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("192.0.0.0", 24, "ipv4"); // asignaciones de protocolo del IETF
IPV4_PROHIBIDAS.addSubnet("192.0.2.0", 24, "ipv4"); // documentación (TEST-NET-1)
IPV4_PROHIBIDAS.addSubnet("192.88.99.0", 24, "ipv4"); // relevo 6to4, retirado
IPV4_PROHIBIDAS.addSubnet("192.168.0.0", 16, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("198.18.0.0", 15, "ipv4"); // pruebas de rendimiento
IPV4_PROHIBIDAS.addSubnet("198.51.100.0", 24, "ipv4"); // documentación (TEST-NET-2)
IPV4_PROHIBIDAS.addSubnet("203.0.113.0", 24, "ipv4"); // documentación (TEST-NET-3)
IPV4_PROHIBIDAS.addSubnet("224.0.0.0", 4, "ipv4"); // multicast
IPV4_PROHIBIDAS.addSubnet("240.0.0.0", 4, "ipv4"); // reservado, incluye 255.255.255.255 (difusión)
// IPv6 va con LISTA BLANCA: solo 2000::/3, el unicast global. Así quedan fuera de un golpe ::1, ::,
// fc00::/7, fe80::/10, el multicast ff00::/8, 100::/64 y 64:ff9b:1::/48 (traducción local, RFC 8215),
// sin nombrarlos uno por uno. Dentro de 2000::/3 se rechazan los bloques que no son de internet.
const IPV6_GLOBAL = new BlockList();
IPV6_GLOBAL.addSubnet("2000::", 3, "ipv6");
const IPV6_PROHIBIDAS = new BlockList();
IPV6_PROHIBIDAS.addSubnet("2001::", 23, "ipv6"); // protocolos del IETF: Teredo (2001::/32), pruebas, ORCHID…
IPV6_PROHIBIDAS.addSubnet("2001:db8::", 32, "ipv6"); // documentación
IPV6_PROHIBIDAS.addSubnet("3fff::", 20, "ipv6"); // documentación
IPV6_PROHIBIDAS.addSubnet("5f00::", 16, "ipv6"); // segment routing (SRv6)
// Cuatro formatos IPv6 llevan una IPv4 adentro. La IPv4 se saca y se revisa contra la lista IPv4:
// [64:ff9b::a9fe:a9fe] es 169.254.169.254 por dentro. Se revisan ANTES que la lista blanca, porque
// ::ffff:8.8.8.8 o 2002:808:808:: llevan una dirección pública y deben pasar.
type Embebida = { prefijo: BlockList; ipv4: (g: number[]) => number[] };
function prefijo(direccion: string, bits: number): BlockList {
const lista = new BlockList();
lista.addSubnet(direccion, bits, "ipv6");
return lista;
}
const ultimos32 = (g: number[]): number[] => [g[6], g[7]];
const EMBEBIDAS: Embebida[] = [
{ prefijo: prefijo("::ffff:0:0", 96), ipv4: ultimos32 }, // IPv4 mapeada (RFC 4291)
{ prefijo: prefijo("::", 96), ipv4: ultimos32 }, // IPv4 compatible, obsoleta; incluye :: y ::1 (0.0.0.0 y 0.0.0.1)
{ prefijo: prefijo("64:ff9b::", 96), ipv4: ultimos32 }, // NAT64 (RFC 6052)
{ prefijo: prefijo("2002::", 16), ipv4: (g) => [g[1], g[2]] }, // 6to4 (RFC 3056): la IPv4 va en los bits 16 a 47
];
// Los ocho grupos de 16 bits de una IPv6 («::» rellena con ceros; acepta la IPv4 escrita al final).
function grupos(ipv6: string): number[] {
let texto = ipv6;
const cola = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(texto);
if (cola !== null) {
const [a, b, c, d] = cola.slice(1).map(Number);
texto = texto.slice(0, cola.index) + ((a << 8) | b).toString(16) + ":" + ((c << 8) | d).toString(16);
}
const [izquierda, derecha] = texto.split("::");
const antes = izquierda === "" ? [] : izquierda.split(":");
const despues = derecha === undefined || derecha === "" ? [] : derecha.split(":");
const ceros = derecha === undefined ? [] : Array<string>(8 - antes.length - despues.length).fill("0");
return [...antes, ...ceros, ...despues].map((grupo) => parseInt(grupo, 16));
}
function privada(direccion: string): boolean {
if (isIP(direccion) === 4) return IPV4_PROHIBIDAS.check(direccion, "ipv4");
const ipv6 = direccion.replace(/%.*$/, ""); // sin el índice de zona (fe80::1%en0)
for (const { prefijo, ipv4 } of EMBEBIDAS) {
if (prefijo.check(ipv6, "ipv6")) {
const [alto, bajo] = ipv4(grupos(ipv6));
return IPV4_PROHIBIDAS.check([alto >> 8, alto & 0xff, bajo >> 8, bajo & 0xff].join("."), "ipv4");
}
}
return !IPV6_GLOBAL.check(ipv6, "ipv6") || IPV6_PROHIBIDAS.check(ipv6, "ipv6");
}
export type Veredicto = { permitido: true } | { permitido: false; motivo: string };
export async function revisarDestino(texto: string): Promise<Veredicto> {
let url: URL;
try {
url = new URL(texto);
} catch {
return { permitido: false, motivo: "no es una URL" };
}
if (url.protocol !== "http:" && url.protocol !== "https:") return { permitido: false, motivo: "solo http o https" };
const puerto = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
const nombre = url.hostname.replace(/^\[|\]$/g, "");
let direcciones: string[];
try {
direcciones = isIP(nombre) !== 0 ? [nombre] : (await lookup(nombre, { all: true })).map((d) => d.address);
} catch {
return { permitido: false, motivo: `el nombre ${nombre} no se pudo resolver` };
}
// Basta con que UNA de las direcciones caiga en un rango prohibido para rechazar el destino.
const prohibidas = direcciones
.map((direccion) => (isIP(direccion) === 6 ? `[${direccion}]:${puerto}` : `${direccion}:${puerto}`))
.filter((destino, i) => !PERMITIDOS.has(destino) && privada(direcciones[i]));
if (prohibidas.length > 0) {
// Una dirección escrita por el cliente se repite tal cual (es suya). Un NOMBRE no enumera lo que resolvió:
// la lista cambia según el sistema (macOS devuelve IPv6 e IPv4, Linux a veces solo IPv4) y revelarla
// le da al cliente un mapa de la red interna. Para depurar, la dirección concreta (`prohibidas`) puede
// registrarla el servidor en su bitácora; aquí no se imprime para que la salida de la matriz sea igual en todo sistema.
if (isIP(nombre) !== 0) return { permitido: false, motivo: `${prohibidas.join(", ")}: dirección privada o reservada` };
return { permitido: false, motivo: `${nombre} resuelve a una dirección privada o reservada` };
}
return { permitido: true };
}
// fig05_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));
}
// fig05_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`);
});
// fig05_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) };
}
{
"type": "module"
}
5.14.3 Пълното изпълнение
Отвори два терминала в ~/curso-http/fig05_01/. В първия — лабораторията; във втория — revisor:
$ node laboratorio.ts
laboratorio en http://127.0.0.1:18081/codigo/NNN
$ node servidor.ts
revisor en http://127.0.0.1:18080
В трети терминал ето заявките на урока, по ред. Тези от предишните раздели са тук отново, заедно, а след тях идват тези за 401, за 503 при поддръжка, един успешен отговор, двете отхвърляния на SSRF и едно твърде голямо тяло:
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: application/problem+json
Content-Language: es
Content-Length: 172
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/credencial-invalida","title":"Falta una credencial válida","status":401,"detail":"falta la credencial","instance":"/servicios"}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Content-Language: es
Content-Length: 219
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/nombre-repetido","title":"Ya existe un servicio con ese nombre","status":409,"detail":"ya existe un servicio llamado pagos","instance":"/servicios","existente":"/servicios/2"}
$ curl -i http://127.0.0.1:18080/servicios/1/informe -H 'Authorization: Bearer escritura-a'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Content-Language: es
Content-Length: 244
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/error-interno","title":"Error interno del revisor","status":500,"detail":"ocurrió un error inesperado; si se repite, reporta la referencia","instance":"urn:uuid:d80e7ef5-6cf9-4099-83a4-53c6d6116787"}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"","url":"ftp://archivos","limite_ms":5}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 407
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"3 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser un texto de 1 a 40 caracteres","pointer":"#/nombre"},{"detail":"debe ser una dirección http o https","pointer":"#/url"},{"detail":"debe ser un entero entre 100 y 10000","pointer":"#/limite_ms"}]}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 230
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 169.254.169.254:80: dirección privada o reservada","instance":"/servicios"}
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":true}'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: application/problem+json
Content-Language: es
Content-Length: 188
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/en-mantenimiento","title":"El revisor está en mantenimiento","status":503,"detail":"el revisor está en mantenimiento","instance":"/servicios"}
$ curl -i -X PUT http://127.0.0.1:18080/mantenimiento -H 'Authorization: Bearer admin' -H 'Content-Type: application/json' -d '{"activo":false}'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 200 OK
ETag: "1-1-f5a91511"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"metadatos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 230
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 169.254.169.254:80: dirección privada o reservada","instance":"/servicios"}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"yo-mismo","url":"http://localhost:18080/mantenimiento"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 235
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: localhost resuelve a una dirección privada o reservada","instance":"/servicios"}
$ printf '{"nombre":"%017000d"}' 0 | curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
HTTP/1.1 413 Content Too Large
Connection: close
Content-Type: application/problem+json
Content-Language: es
Content-Length: 129
Date: Wed, 21 Oct 2026 23:42:15 GMT
{"type":"about:blank","title":"Content Too Large","status":413,"detail":"el cuerpo pasa de 16384 bytes","instance":"/servicios"}
Обърни внимание на пет неща. 401 запазва своя WWW-Authenticate, а 503 — своя Retry-After: заглавията, които всеки код изисква, не бяха преместени в тялото. 413 накрая, регистрация с име от 17 000 знака, е about:blank с фразата от регистъра и носи Connection: close: revisor спира да чете веднага щом тялото надхвърли 16 KiB и затваря връзката, когато отговаря, за да не продължава да получава онова, което клиентът искаше да изпрати. 204 продължават да са без тяло. Успешният отговор, GET /servicios/1, продължава да бъде application/json, без Content-Language: форматът е само за грешките. А двете отхвърляния на SSRF от Урок 1 сега имат свой тип, destino-prohibido, със същия detail както винаги, който не изброява адресите, към които се разрешава едно име.
5.14.4 Всичко останало продължава да работи
Условните заявки, схемата на удостоверението, методът с име от Object.prototype, тялото, което не е обект, диапазоните на SSRF и етикетът след рестартиране не са променили поведението си: само тялото си, когато са грешки. Това е същото изпълнение, продължено. То съдържа и случая с изгубената актуализация с два едновременни PUT, който видя Урок 3: един бавен, който изпраща тялото си секунда по-късно (curl -T - го изпраща на части), и един бърз със същия етикет. Между предпоследната и последната заявка revisor беше рестартиран:
$ ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'); echo "ETAG=$ETAG"
ETAG="1-1-f5a91511"
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H "If-None-Match: W/$ETAG"
HTTP/1.1 304 Not Modified
ETag: "1-1-f5a91511"
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H "If-None-Match: \"9-9\", $ETAG"
HTTP/1.1 304 Not Modified
ETag: "1-1-f5a91511"
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H 'If-None-Match: *'
HTTP/1.1 304 Not Modified
ETag: "1-1-f5a91511"
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H 'If-None-Match: "9-9", W/"8-8"'
HTTP/1.1 200 OK
ETag: "1-1-f5a91511"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: W/$ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
Content-Language: es
Content-Length: 263
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/etiqueta-vencida","title":"Tu etiqueta ya no es la vigente","status":412,"detail":"If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó","instance":"/servicios/1"}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: \"9-9\", $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-2-f5a91511"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'If-Match: *' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-3-f5a91511"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-3-f5a91511"
$ ( sleep 1; printf '%s' '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1000}' ) | curl -w 'lenta: %{http_code}\n' -X PUT -T - http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" & LENTA=$!; sleep 0.3; curl -o /dev/null -w 'rápida: %{http_code}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}'; wait $LENTA
rápida: 200
{"type":"https://revisor.example/problemas/etiqueta-vencida","title":"Tu etiqueta ya no es la vigente","status":412,"detail":"el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo","instance":"/servicios/1"}
lenta: 412
$ curl http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}
$ curl -o /dev/null -w 'text/plain con etiqueta vieja: %{http_code}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -H 'If-Match: "1-0-0"' -d 'catalogo'
text/plain con etiqueta vieja: 415
$ curl -o /dev/null -w 'text/plain sin If-Match: %{http_code}\n' -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'catalogo'
text/plain sin If-Match: 415
$ ID=$(curl -o /dev/null -w '%header{location}' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"efimero","url":"http://127.0.0.1:18081/codigo/200"}'); ETAG=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080$ID -H 'Authorization: Bearer escritura-a'); echo "ID=$ID ETAG=$ETAG"
ID=/servicios/7 ETAG="7-1-f5a91511"
$ ( sleep 1; printf '%s' '{"nombre":"efimero","url":"http://127.0.0.1:18081/codigo/200"}' ) | curl -w ' <- PUT lento: %{http_code}\n' -X PUT -T - http://127.0.0.1:18080$ID -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" & LENTO=$!; sleep 0.3; curl -o /dev/null -w 'DELETE en el hueco: %{http_code}\n' -X DELETE http://127.0.0.1:18080$ID -H 'Authorization: Bearer escritura-a'; wait $LENTO
DELETE en el hueco: 204
{"type":"https://revisor.example/problemas/servicio-retirado","title":"El servicio se retiró","status":410,"detail":"el servicio 7 se retiró y no va a volver","instance":"/servicios/7"}
<- PUT lento: 410
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 454
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/lento/1500","limite_ms":2000},{"id":5,"nombre":"reportes","url":"http://127.0.0.1:18081/lento/3000","limite_ms":1000},{"id":6,"nombre":"facturacion","url":"http://127.0.0.1:18081/basura","limite_ms":2000}]}
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Basic bGVjdHVyYS1hOg=='
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: application/problem+json
Content-Language: es
Content-Length: 185
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/credencial-invalida","title":"Falta una credencial válida","status":401,"detail":"solo se admite el esquema Bearer","instance":"/servicios"}
$ curl -i -X constructor http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 400 Bad Request
Connection: close
$ head -c 20000 /dev/zero | tr '\0' 'a' | curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
HTTP/1.1 413 Content Too Large
Connection: close
Content-Type: application/problem+json
Content-Language: es
Content-Length: 129
Date: Wed, 21 Oct 2026 23:42:15 GMT
{"type":"about:blank","title":"Content Too Large","status":413,"detail":"el cuerpo pasa de 16384 bytes","instance":"/servicios"}
$ head -c 20000 /dev/zero | tr '\0' 'a' | curl -o /dev/null -w 'por partes: %{http_code}\n' -X POST -T - http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json'
por partes: 413
$ head -c 16384 /dev/zero | tr '\0' 'a' | curl -o /dev/null -w 'en el tope: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
en el tope: 400
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d 'null'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Content-Language: es
Content-Length: 177
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/cuerpo-ilegible","title":"El cuerpo no se puede leer","status":400,"detail":"el cuerpo no es un objeto JSON","instance":"/servicios"}
$ curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-1","url":"http://100.100.100.200/latest/meta-data/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 100.100.100.200:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-2","url":"http://[64:ff9b::a9fe:a9fe]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [64:ff9b::a9fe:a9fe]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-3","url":"http://224.0.0.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 224.0.0.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-4","url":"http://[ff02::1]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [ff02::1]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-5","url":"http://[2002:a9fe:a9fe::]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [2002:a9fe:a9fe::]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-6","url":"http://[::7f00:1]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [::7f00:1]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-7","url":"http://[64:ff9b:1::a9fe:a9fe]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [64:ff9b:1::a9fe:a9fe]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-8","url":"http://240.0.0.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 240.0.0.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-9","url":"http://198.18.0.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 198.18.0.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-10","url":"http://192.0.2.1/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: 192.0.2.1:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-11","url":"http://[2001:db8::1]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [2001:db8::1]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ curl -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-12","url":"http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/"}'
{"type":"https://revisor.example/problemas/destino-prohibido","title":"El revisor no se conecta a ese destino","status":422,"detail":"destino prohibido: [2001:0:4136:e378:8000:63bf:3fff:fdd2]:80: dirección privada o reservada","instance":"/servicios"}
[422]
$ for i in 1 2 3 4; do curl -o /dev/null -w '%{http_code} %header{retry-after}\n' http://127.0.0.1:18080/servicios/1/salud -H 'Authorization: Bearer lectura-a'; done
200
200
200
429 60
$ for i in 1 2 3 4 5 6; do curl -o /dev/null -w '%{http_code} ' -X POST http://127.0.0.1:18080/servicios/$i/revisiones -H 'Authorization: Bearer escritura-b'; done; echo
404 404 202 429 429 429
$ ID=$(curl -o /dev/null -w '%header{location}' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"cortado","url":"http://127.0.0.1:18081/cortar"}'); echo "ID=$ID"
ID=/servicios/8
$ curl -i http://127.0.0.1:18080$ID/salud -H 'Authorization: Bearer admin'
HTTP/1.1 502 Bad Gateway
Content-Type: application/problem+json
Content-Language: es
Content-Length: 278
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/respuesta-invalida","title":"El servicio no dio una respuesta HTTP válida","status":502,"detail":"el servicio 8 no dio una respuesta HTTP válida: cortó la conexión o respondió algo que no es HTTP","instance":"/servicios/8/salud"}
$ TZ=America/Mexico_City node --input-type=module -e 'import { segundosDeEspera } from "./cliente.ts"; for (const v of ["120", "1.5", "-1", "12 30"]) console.log(JSON.stringify(v), "->", segundosDeEspera(v)); const imf = segundosDeEspera("Fri, 01 Jan 2100 00:00:00 GMT"), asc = segundosDeEspera("Fri Jan 1 00:00:00 2100"); console.log("asctime menos IMF-fixdate (misma fecha):", asc - imf, "s")'
"120" -> 120
"1.5" -> null
"-1" -> null
"12 30" -> null
asctime menos IMF-fixdate (misma fecha): 0 s
$ ANTES=$(curl -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'); echo "ANTES=$ANTES"
ANTES="3-1-f5a91511"
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b' -H "If-None-Match: $ANTES"
HTTP/1.1 200 OK
ETag: "3-1-b32db61c"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
Двата 412 — този на слабия етикет и този на бавната заявка, която загуби надпреварата между заявките, — носят един и същ type и един и същ title, „Tu etiqueta ya no es la vigente“, и само техният detail разказва какво е станало във всеки случай; промяната, която остана, е тази на бързата, "limite_ms":3000. 401 при схемата Basic вече носи своя тип; 400 при null е cuerpo-ilegible; дванайсетте диапазона дават destino-prohibido с [422]. 400 при -X constructor продължава да е без тяло, защото на него не отговаря revisor, а Node, преди да го извика, както обясни Урок 2. Промяната с text/plain дава 415, дори да носи стар етикет или никакъв: за формата се пита преди условието, защото RFC 9110 казва, че предусловията не се оценяват, ако без тях отговорът не би бил нито 2xx, нито 412 (раздел 13.2.1). Бавен PUT към услуга, която друга заявка междувременно оттегля, дава 410 servicio-retirado, сякаш е пристигнал след оттеглянето. Телата от 20 000 байта дават 413, със Content-Length или на части, а едно от точно 16 384 байта се прочита (и дава 400, защото не е JSON). Заявката на живо има собствено ограничение, demasiadas-consultas, с Retry-After: 60, отделно от това на проверките. 502 на услуга, която прекъсва връзката, сега казва в своя title какво наистина е станало: „El servicio no dio una respuesta HTTP válida“ („Услугата не даде валиден HTTP отговор“), което е вярно както ако е прекъснала, така и ако е отговорила с нещо, което не е HTTP. А старият етикет, след рестартиране, дава 200, а не фалшив 304.
Терминалът на revisor в края на цялото изпълнение, с двете препратки към вътрешна грешка (тази от раздел 5.8 и тази от програмата от 5.15):
revisor en http://127.0.0.1:18080
GET /servicios/99
GET /servicios/3
GET /servicioss
PATCH /servicios/1
GET /servicios
POST /servicios
GET /servicios/1/informe
error d80e7ef5-6cf9-4099-83a4-53c6d6116787 en GET /servicios/1/informe: TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:314:28)
at atender (file://<carpeta>/rutas.ts:107:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:23:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:38:44)
POST /servicios
POST /servicios
PUT /mantenimiento
operación: mantenimiento encendido
GET /servicios
PUT /mantenimiento
operación: mantenimiento apagado
GET /servicios/1
POST /servicios
POST /servicios
POST /servicios
GET /servicios/99
GET /servicios/3
GET /revisiones/99
GET /servicioss
PATCH /servicios/1
GET /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
PUT /servicios/1
GET /v0/servicios
GET /servicios/1/informe
error 260fe541-4a99-4e15-a886-6907979f5e48 en GET /servicios/1/informe: TypeError: Cannot read properties of undefined (reading 'resultado')
at Object.informe [as manejador] (file://<carpeta>/servicios.ts:314:28)
at atender (file://<carpeta>/rutas.ts:107:16)
at atenderSinFiltrar (file://<carpeta>/servidor.ts:23:11)
at Server.<anonymous> (file://<carpeta>/servidor.ts:38:44)
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /servicios/1
PUT /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /servicios/1
POST /servicios
GET /servicios/7
PUT /servicios/7
DELETE /servicios/7
GET /servicios
GET /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios/1
POST /servicios
GET /servicios/1
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios/1/salud
GET /servicios/1/salud
GET /servicios/1/salud
GET /servicios/1/salud
POST /servicios/1/revisiones
POST /servicios/2/revisiones
POST /servicios/3/revisiones
POST /servicios/4/revisiones
POST /servicios/5/revisiones
POST /servicios/6/revisiones
POST /servicios
GET /servicios/8/salud
GET /servicios/3
revisor en http://127.0.0.1:18080
GET /servicios/3
5.15 Проверка с програма
Да гледаш тринайсет отговора на око, за да видиш дали спазват формата, е скучно и води до грешки. Тази програма го прави вместо теб: иска тринайсет различни грешки от revisor и проверява във всяка от тях онова, което казват предишните раздели.
// fig05_02.ts — comprueba que cada error del revisor sea un Problem Details bien formado (RFC 9457).
// Uso: node fig05_02.ts [base] (por omisión, http://127.0.0.1:18080)
import { STATUS_CODES } from "node:http";
const BASE = process.argv[2] ?? "http://127.0.0.1:18080";
const MIEMBROS = ["type", "title", "status", "detail", "instance"];
// Node trae los nombres viejos de estos dos códigos; el registro de la IANA, los nuevos.
const FRASES: Record<number, string> = { 413: "Content Too Large", 422: "Unprocessable Content" };
type Caso = { nombre: string; metodo: string; ruta: string; cabeceras?: Record<string, string>; cuerpo?: string };
const escritor = { Authorization: "Bearer escritura-a" };
const json = { ...escritor, "Content-Type": "application/json" };
const CASOS: Caso[] = [
{ nombre: "servicio que no existe", metodo: "GET", ruta: "/servicios/99", cabeceras: escritor },
{ nombre: "servicio de otro equipo", metodo: "GET", ruta: "/servicios/3", cabeceras: escritor },
{ nombre: "revisión que no existe", metodo: "GET", ruta: "/revisiones/99", cabeceras: escritor },
{ nombre: "ruta que no existe", metodo: "GET", ruta: "/servicioss", cabeceras: escritor },
{ nombre: "método que la ruta no admite", metodo: "PATCH", ruta: "/servicios/1", cabeceras: escritor },
{ nombre: "sin credencial", metodo: "GET", ruta: "/servicios" },
{ nombre: "cuerpo en texto", metodo: "POST", ruta: "/servicios", cabeceras: { ...escritor, "Content-Type": "text/plain" }, cuerpo: "x" },
{ nombre: "cuerpo de más de 16 KiB", metodo: "POST", ruta: "/servicios", cabeceras: json, cuerpo: `{"nombre":"${"a".repeat(17_000)}"}` },
{ nombre: "datos imposibles", metodo: "POST", ruta: "/servicios", cabeceras: json, cuerpo: '{"nombre":"","url":"ftp://x"}' },
{ nombre: "nombre repetido", metodo: "POST", ruta: "/servicios", cabeceras: json, cuerpo: '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}' },
{ nombre: "cambio sin If-Match", metodo: "PUT", ruta: "/servicios/1", cabeceras: json, cuerpo: "{}" },
{ nombre: "versión retirada", metodo: "GET", ruta: "/v0/servicios", cabeceras: escritor },
{ nombre: "error interno", metodo: "GET", ruta: "/servicios/1/informe", cabeceras: escritor },
];
// Lo que el revisor ya respondió para cada type: el title y el status no deben cambiar entre ocurrencias.
const vistos = new Map<string, { title: unknown; status: unknown }>();
async function comprobar(caso: Caso): Promise<string[]> {
const respuesta = await fetch(BASE + caso.ruta, { method: caso.metodo, headers: caso.cabeceras, body: caso.cuerpo });
const fallas: string[] = [];
const tipo = (respuesta.headers.get("content-type") ?? "").split(";")[0].trim();
if (tipo !== "application/problem+json") fallas.push(`tipo ${tipo || "(ninguno)"}, se esperaba application/problem+json`);
let cuerpo: unknown;
try {
cuerpo = JSON.parse(await respuesta.text());
} catch {
return [...fallas, "el cuerpo no es JSON"];
}
if (typeof cuerpo !== "object" || cuerpo === null || Array.isArray(cuerpo)) return [...fallas, "el cuerpo no es un objeto"];
const problema = cuerpo as Record<string, unknown>;
for (const miembro of MIEMBROS) {
if (!Object.hasOwn(problema, miembro)) fallas.push(`falta ${miembro}`);
}
// status: un número, y el mismo que la línea de estado (RFC 9457, sección 3.1.2).
if (problema.status !== respuesta.status) fallas.push(`status ${JSON.stringify(problema.status)} en el cuerpo y ${respuesta.status} en la línea`);
// type: about:blank o un URI absoluto (sección 3.1.1 recomienda absolutos).
const type = problema.type;
if (typeof type !== "string") {
fallas.push("type no es un texto");
} else if (type !== "about:blank" && !URL.canParse(type)) {
fallas.push(`type ${type} no es un URI absoluto`);
}
// Con about:blank, el title es la frase del código (sección 4.2.1).
const frase = FRASES[respuesta.status] ?? STATUS_CODES[respuesta.status];
if (type === "about:blank" && problema.title !== frase) fallas.push(`title ${JSON.stringify(problema.title)} con about:blank; se esperaba ${frase}`);
// El mismo type, el mismo title y el mismo status en cada ocurrencia (sección 3.1.3).
if (typeof type === "string" && type !== "about:blank") {
const antes = vistos.get(type);
if (antes === undefined) vistos.set(type, { title: problema.title, status: problema.status });
else if (antes.title !== problema.title || antes.status !== problema.status) fallas.push(`${type} cambió de title o de status entre ocurrencias`);
}
return fallas;
}
let bien = 0;
for (const caso of CASOS) {
const fallas = await comprobar(caso);
if (fallas.length === 0) bien++;
console.log(`${fallas.length === 0 ? "ok " : "FALLA"} ${caso.metodo} ${caso.ruta} (${caso.nombre})${fallas.map((f) => "\n " + f).join("")}`);
}
console.log(`\n${bien} de ${CASOS.length} respuestas son Problem Details bien formados`);
process.exitCode = bien === CASOS.length ? 0 : 1;
За всеки случай проверява шест неща: типът съдържание да е application/problem+json; тялото да е JSON обект; петте члена да са налице; status да е равен на кода от реда на състоянието; type да е about:blank или абсолютен URI (URL.canParse казва дали даден текст може да се прочете като пълен адрес); и при about:blank title да е фразата на кода. Освен това води сметка какво е видяла за всеки type: ако един и същ type се появи два пъти с различен title или различен status, тя го отбелязва. Завършва с 0, ако всичко е наред, и с 1, ако нещо се провали, за да може да се използва в автоматична проверка.
Докато revisor върви, в друг терминал (програмата е в горната папка, ~/curso-http/):
$ node fig05_02.ts
ok GET /servicios/99 (servicio que no existe)
ok GET /servicios/3 (servicio de otro equipo)
ok GET /revisiones/99 (revisión que no existe)
ok GET /servicioss (ruta que no existe)
ok PATCH /servicios/1 (método que la ruta no admite)
ok GET /servicios (sin credencial)
ok POST /servicios (cuerpo en texto)
ok POST /servicios (cuerpo de más de 16 KiB)
ok POST /servicios (datos imposibles)
ok POST /servicios (nombre repetido)
ok PUT /servicios/1 (cambio sin If-Match)
ok GET /v0/servicios (versión retirada)
ok GET /servicios/1/informe (error interno)
13 de 13 respuestas son Problem Details bien formados
Тринайсет от тринайсет. Тази програма е семето на матрицата от Урок 8, която прави същото с 23-те кода на курса и проверява също заглавията.
5.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
Не отпечатва нищо: няма грешки. fig05_02.ts преминава същата команда заедно с файловете на revisor, както и двете счупени версии от следващия раздел и решението на упражнение 4. Ако смениш една буква на ключ в извикване на fallar, tsc го отбелязва: това е предимството Clave да бъде точният списък с ключовете на каталога.
Грешката, която ще видиш
title, който разказва възникването
Най-честата грешка с този формат е да се използва title, сякаш е detail: да се постави в заглавието съобщението за това възникване. Изглежда съвсем естествено, защото много библиотеки наричат едно и също нещо „съобщение“ или „заглавие“. За да я предизвикаш, копирай fig05_01/ като rota/ и замени в rota/problemas.ts началото на fallar с това (това е фрагмент; пълната версия е в defectos/title-por-ocurrencia/problemas.ts):
// DEFECTO A PROPÓSITO (lección 5): el title lleva el mensaje de esta ocurrencia, como el detail.
const { type, status } = CATALOGO[clave];
const title = detail;
Стартирай лабораторията и revisor от rota/ и поискай двете услуги от раздел 5.4:
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 177
Date: Wed, 21 Oct 2026 13:18:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/servicio-inexistente","title":"no existe el servicio 99","status":404,"detail":"no existe el servicio 99","instance":"/servicios/99"}
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 13:18:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/servicio-inexistente","title":"no existe el servicio 3","status":404,"detail":"no existe el servicio 3","instance":"/servicios/3"}
Всеки отговор, сам по себе си, изглежда наред: петте члена са налице, type е обичайният, кодът е правилен. Проблемът се появява, когато ги сравниш: един и същ type носи два различни title, а RFC казва, че title не бива да се променя между възникванията. Клиент, който групира грешките си по title в табло, би видял по един ред за всяка услуга, която не е намерил, вместо само един. А title вече не служи за онова, заради което съществува: да каже на онзи, който чете дневника, какъв тип проблем е. Програмата от 5.15 го открива:
$ node fig05_02.ts
ok GET /servicios/99 (servicio que no existe)
FALLA GET /servicios/3 (servicio de otro equipo)
https://revisor.example/problemas/servicio-inexistente cambió de title o de status entre ocurrencias
ok GET /revisiones/99 (revisión que no existe)
FALLA GET /servicioss (ruta que no existe)
title "no existe esa ruta" con about:blank; se esperaba Not Found
FALLA PATCH /servicios/1 (método que la ruta no admite)
title "PATCH no se admite en /servicios/1" con about:blank; se esperaba Method Not Allowed
ok GET /servicios (sin credencial)
FALLA POST /servicios (cuerpo en texto)
title "el cuerpo debe ser application/json, no text/plain" con about:blank; se esperaba Unsupported Media Type
FALLA POST /servicios (cuerpo de más de 16 KiB)
title "el cuerpo pasa de 16384 bytes" con about:blank; se esperaba Content Too Large
ok POST /servicios (datos imposibles)
ok POST /servicios (nombre repetido)
ok PUT /servicios/1 (cambio sin If-Match)
ok GET /v0/servicios (versión retirada)
ok GET /servicios/1/informe (error interno)
8 de 13 respuestas son Problem Details bien formados
Пет неуспеха, от два вида. Първият е този на title, който се промени между услуга 99 и услуга 3. Другите четири са about:blank, чийто title вече не е фразата на кода. И погледни какво не откри: осем случая преминаха, макар всички да имат същия дефект. Програмата може да види, че един title се променя, само когато един и същ type се появи два пъти, а в нейния списък се повтаря само servicio-inexistente. Една проверка вижда само онова, което сравнява. Поправката е да се върнеш към каталога: title принадлежи на типа и го пише онзи, който проектира типа, веднъж. Спри програмите от rota/, когато приключиш.
Проблемът, който твърди, че е JSON
Втората грешка е по-малка и по-вредна. Копирай отново fig05_01/ като rota2/ и промени в rota2/problemas.ts типа съдържание (пълната версия е в defectos/json-a-secas/problemas.ts):
// DEFECTO A PROPÓSITO (lección 5): el cuerpo es un Problem Details, pero el tipo dice JSON a secas.
"Content-Type": "application/json",
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 13:18:32 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"}
Тялото е идентично, байт по байт, с това от раздел 5.1. Промени се само един ред от заглавията. Но общ клиент, който решава „това проблем ли е?“, като гледа типа съдържание — а точно това раздел 5.2 каза, че собственият тип позволява, — сега не го разпознава: за него това е какъв да е JSON документ, може би представянето на ресурс. Програмата го отхвърля във всички случаи:
$ node fig05_02.ts
FALLA GET /servicios/99 (servicio que no existe)
tipo application/json, se esperaba application/problem+json
FALLA GET /servicios/3 (servicio de otro equipo)
tipo application/json, se esperaba application/problem+json
FALLA GET /revisiones/99 (revisión que no existe)
tipo application/json, se esperaba application/problem+json
FALLA GET /servicioss (ruta que no existe)
tipo application/json, se esperaba application/problem+json
FALLA PATCH /servicios/1 (método que la ruta no admite)
tipo application/json, se esperaba application/problem+json
FALLA GET /servicios (sin credencial)
tipo application/json, se esperaba application/problem+json
FALLA POST /servicios (cuerpo en texto)
tipo application/json, se esperaba application/problem+json
FALLA POST /servicios (cuerpo de más de 16 KiB)
tipo application/json, se esperaba application/problem+json
FALLA POST /servicios (datos imposibles)
tipo application/json, se esperaba application/problem+json
FALLA POST /servicios (nombre repetido)
tipo application/json, se esperaba application/problem+json
FALLA PUT /servicios/1 (cambio sin If-Match)
tipo application/json, se esperaba application/problem+json
FALLA GET /v0/servicios (versión retirada)
tipo application/json, se esperaba application/problem+json
FALLA GET /servicios/1/informe (error interno)
tipo application/json, se esperaba application/problem+json
0 de 13 respuestas son Problem Details bien formados
Нула от тринайсет, заради един ред. Това е често срещан дефект, защото много фреймуърци слагат application/json на всичко, което сериализират. Поправката е тази от fig05_01/: типа го пише fallar, на едно-единствено място, за всички грешки. Спри програмите от rota2/, когато приключиш.
Какво се прави зле
Да се взема решение по detail или по title. Точно това правеха двата скрипта от сцената. Изреченията се поправят, съкращават и превеждат и всяка промяна чупи клиента, без кодът или type да са се променили. RFC иска от клиента да не тълкува detail и му дава type, за да взема решения.
Да се пъха в изречение онова, от което машината има нужда. Id, маршрут, количество, поле: ако клиентът може да има нужда от него, то отива в разширение със собствено име. Изречението е за хората.
type за всяко възникване. …/servicio-inexistente/99 или type с датата. type е името на клас проблеми; ако се променя при всяко възникване, клиентът не може да го сравни с нищо. Онова, което се променя при всяко възникване, отива в detail и instance.
Да се промени публикуван type. Дори думата да е написана грешно или вече да не ти харесва. Да го промениш означава да промениш API: всички клиенти, които са вземали решения с него, престават да разпознават проблема. Ако наистина е необходимо, това се третира като всяка промяна на версия, с предупреждение.
status, различен от кода, или като текст. Ако е различен, противоречи на реда на състоянието, а почти целият път ще повярва на реда. Ако е текст, "404", клиент, който следва RFC, го пренебрегва, сякаш го няма. В revisor и двете неща са невъзможни, защото status идва от каталога.
application/json в грешките. Тялото може да е съвършено, а общият клиент не знае, че е проблем. Това е разделът „Грешката, която ще видиш“.
Да се използва форматът за отстраняване на грешки. Стекове, SQL заявки, имена на класове, пътища на машината в detail или в разширение. RFC го казва в раздел 4: Problem Details не са инструмент за отстраняване на грешки в реализацията, а начин интерфейсът HTTP да се обяснява по-добре. За отстраняване на грешки има препратката в instance и дневника на онзи, който експлоатира системата.
Собствен тип за онова, което кодът вече казва. type „методът не е позволен“ или „не е удостоверен“ не добавя нищо към 405 или 401 с техните заглавия. За това е about:blank.
Да се обединяват проблеми от различни типове в „пакет“. Един отговор има един код, а 409 с 422 вътре няма код, който да ги представя. Отговаря се с най-важния; клиентът поправя и опитва отново.
Упражнения
И четирите се проверяват сами: всяко завършва с изпълнение, което ти казва дали си познал. Запиши предвиждането си в дневника преди да изпълниш каквото и да е. За първите три ти трябват лабораторията и revisor от 5.14, току-що стартирани.
Упражнение 1 — Какъв проблем е?
Без да изпълняваш нищо, предвиди кода, type и title на тези шест отговора и дали някой от тях носи разширение:
curl -i http://127.0.0.1:18080/revisiones/99 -H 'Authorization: Bearer escritura-a'
curl -i -X DELETE http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200"}'
curl -i http://127.0.0.1:18080/v0/servicios
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{}'
curl -i http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer escritura-a'
Подсказка: ключовете на каталога в problemas.ts са картата. След това ги изпълни и сравни.
Упражнение 2 — Два проблема наведнъж
Всяка от тези три заявки има два проблема от различни типове. Предвиди с кой от двата отговаря revisor и защо точно с него:
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"ftp://pagos"}'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"","url":"http://169.254.169.254/"}'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a' -H 'Content-Type: text/plain' -d 'pagos'
Първата повтаря име и носи адрес ftp; втората носи празно име и забранен адрес; третата идва от удостоверение само за четене и е в текст. Изпълни и трите.
Упражнение 3 — Какво иска клиентът
Предвиди с какво отговаря revisor на тези три заявки. Първата иска HTML и казва, че предпочита английски; втората иска английски за маршрут, който не съществува; третата използва -I, което изпраща HEAD:
curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a' -H 'Accept: text/html' -H 'Accept-Language: en'
curl -i http://127.0.0.1:18080/servicioss -H 'Authorization: Bearer escritura-a' -H 'Accept-Language: en'
curl -I http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
Отговаря ли с 406? Променя ли се езикът на нещо? Какво носи HEAD? Изпълни и трите и обясни всеки отговор с раздела на RFC, който го позволява.
Упражнение 4 — Формата на едно разширение
Копирай fig05_01/ като ejercicio4/ и добави към fallar, в ejercicio4/problemas.ts, проверка на правилото за имената от раздел 5.9: името на разширение започва с буква, използва само букви, цифри и _ и има три или повече знака. Ако не го изпълнява, fallar хвърля грешка, както вече прави със запазените имена.
За да разбереш дали си познал, направи три неща. Първо, стартирай лабораторията и revisor от ejercicio4/ и изпълни node fig05_02.ts: трябва да даде тринайсет от тринайсет, защото existente и errors изпълняват правилото. След това промени в ejercicio4/servicios.ts разширението existente: на регистрацията на ex: , рестартирай revisor и опитай отново да регистрираш pagos. Предвиди с какъв код отговаря и какво се появява в терминала. Накрая промени ex: на status: , рестартирай и повтори регистрацията: предвиди какво отговаря и какво казва терминалът.
Решения
Решение 1
$ curl -i http://127.0.0.1:18080/revisiones/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 177
Date: Wed, 21 Oct 2026 13:18:34 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 -X DELETE http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: es
Content-Length: 231
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/permiso-insuficiente","title":"La credencial no tiene permiso","status":403,"detail":"tu credencial solo puede leer; para escribir pide una con rol de escritor","instance":"/servicios/1"}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 428 Precondition Required
Content-Type: application/problem+json
Content-Language: es
Content-Length: 202
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/falta-condicion","title":"Falta la condición If-Match","status":428,"detail":"para cambiar un servicio manda If-Match con su ETag","instance":"/servicios/1"}
$ 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 13:18:34 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 POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 333
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"2 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser un texto de 1 a 40 caracteres","pointer":"#/nombre"},{"detail":"debe ser una dirección http o https","pointer":"#/url"}]}
$ curl -i http://127.0.0.1:18080/servicios/5/salud -H 'Authorization: Bearer escritura-a'
HTTP/1.1 504 Gateway Timeout
Content-Type: application/problem+json
Content-Language: es
Content-Length: 200
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/servicio-lento","title":"El servicio no respondió a tiempo","status":504,"detail":"el servicio 5 no respondió en 1000 ms","instance":"/servicios/5/salud"}
И шестте са собствени типове на revisor и всеки казва своя ключ в type. Проверката, която не съществува, има собствен тип, revision-inexistente, различен от този на услугата: клиентът търси различни неща. Удостоверението само за четене, което се опитва да изтрие, получава permiso-insuficiente, без разширения, и обърни внимание на онова, което не казва неговият detail: „tu credencial solo puede leer“ („твоето удостоверение може само да чете“), без да повтаря кое е то. В учебния revisor името на удостоверението е същият текст като токена, който пътува в Authorization, и повтарянето му в тялото би оставило тайната копирана във всеки дневник на клиента или на посредник: точно това раздел 5 на RFC изисква да се проверява във всеки detail. Промяната без If-Match е falta-condicion (428), а revisor дори не е прочел тялото: след като види, че то се обявява за JSON, за условието се пита, преди да бъде прочетено. Оттеглената версия не изисква удостоверение и отговаря version-retirada (410). Празната регистрация е datos-invalidos с разширението errors и две грешки, а не три: limite_ms не е изпратено, а стойността му по подразбиране е валидна. А състоянието на услуга 5 е servicio-lento (504), което пристигна след секунда, защото revisor изчака ограничението на услугата, преди да се откаже.
Решение 2
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"ftp://pagos"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 260
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"1 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser una dirección http o https","pointer":"#/url"}]}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"","url":"http://169.254.169.254/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: es
Content-Length: 265
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/datos-invalidos","title":"Los datos no se pueden cumplir","status":422,"detail":"1 dato(s) no se pueden cumplir","instance":"/servicios","errors":[{"detail":"debe ser un texto de 1 a 40 caracteres","pointer":"#/nombre"}]}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a' -H 'Content-Type: text/plain' -d 'pagos'
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: es
Content-Length: 229
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/permiso-insuficiente","title":"La credencial no tiene permiso","status":403,"detail":"tu credencial solo puede leer; para escribir pide una con rol de escritor","instance":"/servicios"}
Нито една не отговаря с 409 или с 422 за целта, които може би очакваше. Първата дава datos-invalidos с една-единствена грешка, тази за url: validar се изпълнява преди търсенето на повтореното име, а с адрес ftp регистрацията дори не стига до въпроса за името. Втората също дава datos-invalidos, с грешката за nombre: validar се изпълнява преди проверката на целта и тъй като адресът http://169.254.169.254/ е валиден http адрес, validar не го отбелязва; би го проверила destinoPermitido, която така и не се изпълнява. Третата дава 403, а не 415: разрешението е четвъртият въпрос на входната точка, а форматът е от въпросите на обработчика. И в трите случая най-важният е онзи, който revisor открива пръв, а какво открива пръв, решава редът, в който пита. Затова този ред се проектира и документира.
Решение 3
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a' -H 'Accept: text/html' -H 'Accept-Language: en'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 13:18:35 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"}
$ curl -i http://127.0.0.1:18080/servicioss -H 'Authorization: Bearer escritura-a' -H 'Accept-Language: en'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 111
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"about:blank","title":"Not Found","status":404,"detail":"no existe esa ruta","instance":"/servicioss"}
$ curl -I http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: es
Content-Length: 174
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Нито един не е 406 и нищо не сменя езика си. revisor не договаря съдържанието на своите грешки: пренебрегва Accept и Accept-Language и изпраща application/problem+json на испански. RFC 9110 го позволява: ако никое представяне не е приемливо според дадено заглавие за договаряне, сървърът може да отговори 406 или да третира отговора така, сякаш няма договаряне (раздел 12.4.1); а RFC 9457 изпраща втория си пример като application/problem+json на клиент, който е поискал само application/json (раздел 3). При грешка това е най-доброто: клиентът получава обяснението вместо друга грешка. title на about:blank остава на английски, защото така е винаги в revisor, а не защото клиентът го е поискал; RFC би позволил да се преведе с Accept-Language, а revisor решава да не го прави (раздел 5.5). А HEAD носи същите заглавия като GET, с Content-Length: 174, но без тяло: Node не го изпраща при HEAD, а дължината казва колко би било то.
Решение 4
Проверката, в ejercicio4/problemas.ts (фрагмент; пълният файл е в ejercicio4/problemas.ts в хранилището):
for (const nombre of Object.keys(extensiones)) {
if (RESERVADOS.has(nombre)) throw new Error(`la extensión ${nombre} usa un nombre reservado`);
// RFC 9457, sección 4: empieza con letra, solo letras, dígitos y «_», y tres caracteres o más.
if (!/^[A-Za-z][A-Za-z0-9_]{2,}$/.test(nombre)) throw new Error(`la extensión ${nombre} no cumple la forma del RFC 9457`);
}
Изразът казва: една буква ([A-Za-z]), след това две или повече букви, цифри или долни черти ([A-Za-z0-9_]{2,}) и нищо повече (^ и $). Това са букви от английската азбука, нарочно: това е, което RFC нарича ALPHA, а ñ не е такава. Изпълнението:
$ node fig05_02.ts
ok GET /servicios/99 (servicio que no existe)
ok GET /servicios/3 (servicio de otro equipo)
ok GET /revisiones/99 (revisión que no existe)
ok GET /servicioss (ruta que no existe)
ok PATCH /servicios/1 (método que la ruta no admite)
ok GET /servicios (sin credencial)
ok POST /servicios (cuerpo en texto)
ok POST /servicios (cuerpo de más de 16 KiB)
ok POST /servicios (datos imposibles)
ok POST /servicios (nombre repetido)
ok PUT /servicios/1 (cambio sin If-Match)
ok GET /v0/servicios (versión retirada)
ok GET /servicios/1/informe (error interno)
13 de 13 respuestas son Problem Details bien formados
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Content-Language: es
Content-Length: 244
Date: Wed, 21 Oct 2026 13:18:37 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/error-interno","title":"Error interno del revisor","status":500,"detail":"ocurrió un error inesperado; si se repite, reporta la referencia","instance":"urn:uuid:c0160764-27de-42f7-866a-28ea3f622e08"}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
Content-Language: es
Content-Length: 244
Date: Wed, 21 Oct 2026 13:18:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"type":"https://revisor.example/problemas/error-interno","title":"Error interno del revisor","status":500,"detail":"ocurrió un error inesperado; si se repite, reporta la referencia","instance":"urn:uuid:6e53e149-c257-402b-8ea7-2e74df849fb6"}
С правилото revisor продължава да дава тринайсет от тринайсет, защото двете му разширения го изпълняват. С ex повторната регистрация не отговаря с 409, а с 500: името на разширението е дефект на програмата, а не на клиента, и fallar отказва да изпрати тяло, което не спазва формата. Терминалът го казва със същата препратка, която е получил клиентът:
POST /servicios
error c0160764-27de-42f7-866a-28ea3f622e08 en POST /servicios: Error: la extensión ex no cumple la forma del RFC 9457
at fallar (file://<carpeta>/problemas.ts:54:59)
at Object.darDeAlta [as manejador] (file://<carpeta>/servicios.ts:150:5)
at async atender (file://<carpeta>/rutas.ts:107:3)
at async atenderSinFiltrar (file://<carpeta>/servidor.ts:23:5)
Със status става същото, но заради другото правило: формата на името е правилна, а то е едно от петте запазени. Без тази проверка ...extensiones би записало върху status от каталога (раздел 5.9). Терминалът след второто рестартиране:
POST /servicios
error 6e53e149-c257-402b-8ea7-2e74df849fb6 en POST /servicios: Error: la extensión status usa un nombre reservado
at fallar (file://<carpeta>/problemas.ts:52:39)
at Object.darDeAlta [as manejador] (file://<carpeta>/servicios.ts:150:5)
at async atender (file://<carpeta>/rutas.ts:107:3)
at async atenderSinFiltrar (file://<carpeta>/servidor.ts:23:5)
Идеята е дефектът да излезе като 500, а не като неправилно оформен отговор: грешка, която се забелязва при първия тест, а не в клиента на някой друг. Върни на existente името му, когато приключиш.
Как знам, че съм успял
- Казваш по памет петте члена на Problem Details, за кого е всеки и кои се променят между възникванията.
-
curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-a'дава404сContent-Type: application/problem+json,Content-Language: esи"type":"https://revisor.example/problemas/servicio-inexistente";/servicios/3дава същияtypeи същияtitle. -
/serviciossдава"type":"about:blank","title":"Not Found", аPATCH /servicios/1даваabout:blank,Method Not AllowedиAllow: GET, HEAD, PUT, DELETE. - Регистрация с три грешни полета дава
422сerrorsи триpointer(#/nombre,#/url,#/limite_ms); регистрация с повторено име дава409с"existente":"/servicios/2". -
GET /servicios/1/informeдава500сinstanceurn:uuid:…и намираш същата препратка в терминала наrevisor, до стека. - Обясняваш защо клиентът взема решения с
type, а не сtitleили сdetail, и защоtitleнаabout:blankе на английски. - Разширение с име
statusдава500, а терминалът казваusa un nombre reservadoсъс същата препратка, която е получил клиентът. -
node fig05_02.tsдава13 de 13, а срещуdefectos/json-a-secas/дава0 de 13. - Когато приключиш, не остава нито една програма да слуша:
curl -i http://127.0.0.1:18080/дава грешка 7.
Резюме
Отговори, без да гледаш урока:
- Какъв тип съдържание носи Problem Details в JSON и какво печели клиентът от това, че не е
application/json? - Кои са петте члена? Кой е основният идентификатор на проблема според RFC и с какъв глагол го казва?
- Какво означава
about:blank? Какъвtitleноси и защо? - Какво се превежда и какво не се превежда в Problem Details?
- Какво правило свързва
statusс реда на състоянието и защо вrevisorте не могат да се различават? - Какво е
instanceпри404наrevisorи какво е при500? За какво служи второто? - Какво трябва да направи клиентът с разширение, което не познава? Какви правила за имената има едно разширение?
- Как се отговаря на няколко проблема от един и същи тип? А на няколко от различни типове?
- Кои три неща се промениха от RFC 7807 към RFC 9457?
Източници
Всички са консултирани на 21 октомври 2026 г.
- RFC 9457, Problem Details for HTTP APIs (юли 2023 г., предложен стандарт на IETF), раздел 3: обектът,
application/problem+json, примерът сerrorsсpointerи правилото за няколко проблема. - RFC 9457, раздел 3.1: петте члена, правилото да се пренебрегва член с грешния тип,
typeкато основен идентификатор (3.1.1),status(3.1.2),title(3.1.3),detail(3.1.4) иinstance(3.1.5). - RFC 9457, раздел 3.2: разширенията и задължението да се пренебрегват онези, които не са познати.
- RFC 9457, раздел 4: кога да се определи тип, какво да се документира,
Retry-Afterи правилата за имената на разширенията. - RFC 9457, раздели 4.2 и 4.2.1: регистърът на типовете и
about:blank, сtitle, равен на фразата на кода. - RFC 9457, раздел 5 и приложение D: съображенията за сигурност и промените спрямо RFC 7807.
- IANA, HTTP Problem Types: официалният регистър, с шест записа и актуализиран на 26 юни 2026 г.
- IANA, регистрация на типа съдържание
application/problem+json: без параметри, с препратка към RFC 9457. - RFC 7807, Problem Details for HTTP APIs (март 2016 г.), предишната версия, днес остаряла.
- Indicating Details of Problems to Machines in HTTP, draft-nottingham-http-problem-00 (4 юли 2012 г.), оригиналният проект и неговото предупреждение.
- RFC 9110, HTTP Semantics, раздели 8.5, 12.1, 12.4.1, 12.5.1, 13.2.1 и 15.5.14:
Content-Languageи езиците на едно представяне, свободата на сървъра да не договаря, защо415е преди предусловието, и413. - RFC 6901, JavaScript Object Notation (JSON) Pointer, раздели 3 и 6: синтаксисът на
pointerи формата му като фрагмент на URI (#/nombre). - RFC 9562, Universally Unique IDentifiers (UUIDs): идентификаторът на препратката
urn:uuid:на вътрешните грешки. - RFC 6761, Special-Use Domain Names, раздел 6.5:
.example, запазен за документация. - IANA, HTTP Status Codes: официалните фрази на кодовете, които са
titleнаabout:blank.
Термини в този урок
| Problem Details | JSON обектът от RFC 9457, който обяснява грешка на HTTP API |
| тип проблем | клас проблеми със собствено име: type, неговият title, неговият код и неговите разширения |
| възникване | конкретният случай, в който даден проблем се е случил; описват го detail и instance |
about:blank |
типът, който не добавя нищо към кода на състоянието; неговият title е фразата на кода |
| разширение | собствен член на тип проблем, в допълнение към петте стандартни |
| JSON Pointer | стандартният начин да се посочи място в JSON, като #/nombre |
| каталог | таблицата с всички типове проблеми на един API |
| централен обработчик | единствената функция, през която минават всички отговори с грешка |
Предпочитате имейл? Пишете ни на hola@habil.mx