Съдържание на курса
Урок 3 — Клиентът сгреши (II): 409, 410, 412, 422 и 428
От Dorian Chávez · основател и главен изпълнителен директор на Hábil ·
17:40 ч. в петък е и Ана от екипа по плащанията току-що е преместила услугата за плащания на новия ѝ адрес. Старият се изключва в 18:00. Отваря картата на услугата в revisor, сменя URL адреса, запазва и получава 200 OK. Тръгва си спокойна. Това, което не знае, е, че Бето, от същия екип, е отворил същата карта в 17:30, за да увеличи времевото ограничение от 2 на 3 секунди, станал е за кафе и се е върнал в 17:45. Бето сменя ограничението, запазва и също получава 200 OK. Но онова, което Бето запази, беше неговото пълно копие, това от 17:30, което все още съдържаше стария адрес. В 18:00 старият адрес се изключва, revisor го проверява, получава грешка при свързване и отбелязва услугата за плащания като CAIDO цяла нощ. Новата услуга работи перфектно; никой не я наблюдава. И нито един от двамата не получи грешка: и двамата видяха 200.
Това се нарича изгубена актуализация (lost update) и този урок я предотвратява с един етикет и една заглавка. Мимоходом дава име и на другите четири начина, по които добре оформена заявка все пак не може да бъде изпълнена.
Време: 2 × 45 мин (разделянето е между раздели 3.4 и 3.5)
Какво изграждаш: промяната на услуга (PUT /servicios/{id}), която не презаписва промяната на друг човек; валидацията, която отделя онова, което не може да се прочете, от онова, което не може да се изпълни; паметта за премахнатите услуги; и предишната версия на API-то, която предупреждава, че си отива, преди да си отиде
Какво научаваш: да различаваш конфликт на състоянието (409), бизнес правило, което данните не спазват (422), и предусловие, което не е изпълнено (412); какво е изгубената актуализация и как ETag с If-Match я предотвратява, включително когато две заявки пристигнат едновременно; 412 срещу 428; 410 срещу 404; писаната политика на курса за 400 срещу 422; и как се обявява извеждането от употреба на версия на API-то с Deprecation и Sunset, преди да дойде 410
Откъде идваш. От Урок 2, с revisor, който има входна точка: знае кой му говори, какво може да прави всеки набор идентификационни данни, кой маршрут и кой метод съществуват и в какъв формат приема тялото. Този revisor все още не позволява промяна на услуга: само регистриране, проверка, премахване и инспекция. Отговаря с 400 на всичко от тялото, което не му върши работа, независимо дали защото не може да се прочете, или защото се прочита и няма смисъл. А когато премахнеш услуга, нейният идентификатор остава с 404, сякаш никога не е съществувала.
Какво не прави този урок. Не преподава PATCH: revisor променя цяла услуга с PUT, а PATCH продължава да отговаря с 405. Не преподава заглавката If-Unmodified-Since, сестрата на If-Match, която сравнява дати вместо етикети; курсът използва само етикети, защото HTTP датите имат разделителна способност от една секунда и две промени в една и съща секунда не биха могли да се различат. А телата на грешките остават един ред текст; Урок 5 ги превръща в application/problem+json и там списъкът с нарушения в 422 става масив, който програма може да обходи.
За изпълненията. Linux Mint 22 е референтната система за курса. Изходите в този урок са получени на 21 октомври 2026 г. на macOS с Node 24.14.1 и curl 8.7.1, и нито един не зависи от нещо специфично за macOS. При твоето изпълнение ще се различават датата в заглавката Date и последните осем цифри на всеки ETag ("1-1-c6e922ee" в основното изпълнение на урока): revisor ги генерира на случаен принцип при всяко стартиране, така че твоите ще са други и ще се променят при всяко рестартиране. Когато команда се нуждае от етикет, копирай онзи, който си получил ти в предишния отговор, или го запази в променлива, както прави урокът с ETAG=$(…). revisor пази всичко в паметта, така че всеки отговор зависи от това, което са направили предишните заявки. Затова изпълненията в урока са групирани в серии, а текстът отбелязва къде започва всяка: серия A е основната, на току-що стартирания revisor от fig03_01/, и върви от раздел 3.2.3 до края на 3.8 без рестартиране; другите (програмата с двамата редактори, всяка счупена версия и упражненията) са кратки и всяка започва с току-що стартиран revisor. За да получиш същото като в урока, стартирай revisor наново там, където започва всяка серия, и не го рестартирай по средата на серия. Програмите са в programas/03-errores-del-cliente-estado/, в хранилището на курса: revisor е в fig03_01/, двамата редактори — в fig03_02.ts, четецът на предупреждения — в fig03_03.ts, счупените версии от „Грешката, която ще видиш“ — в defectos/, а всяко изпълнение — в своя файл .salida.txt. Всички минават tsc в строг режим със същата команда от раздел 1.11 на Урок 1.
След урока ще можеш да
- Класифицираш неуспешна заявка като конфликт на състоянието, бизнес правило или предусловие и да отговаряш с
409,422или412според въпроса, който решава. - Предизвикваш изгубена актуализация с двама редактори и да доказваш с изпълнение, че
If-Matchя превръща в412. - Предизвикваш същата загуба с две едновременни заявки, които изпращат
If-Match, и да обясняваш защо версията се сравнява отново в същата стъпка, в която се записва, катоUPDATE … WHERE version = N. - Обясняваш защо
If-Matchсе сравнява силно, какво става с етикетW/, със списък и с*, и да цитираш раздела от RFC 9110, който го изисква. - Различаваш
412от428и да казваш какво трябва да направи клиентът във всеки случай. - Отговаряш с
410за ресурс, който е премахнат нарочно, и с404за такъв, който никога не е съществувал, без да издаваш на друг екип какво е съществувало. - Обявяваш извеждането от употреба на версия на API-то с
Deprecation,SunsetиLinkи да проверяваш с програма, че датата наSunsetне е преди тази наDeprecation.
Защо, преди как
В Урок 2 всички неуспехи можеха да се открият, като се погледне само заявката: маршрутът, методът, идентификационните данни, типът на тялото. Ако бяха сгрешени, бяха сгрешени по всяко време и срещу всеки сървър. Неуспехите в този урок са различни: заявката е написана правилно, прочита се изцяло и въпреки това не може да бъде изпълнена. За да го разбере, сървърът трябва да погледне нещо повече от заявката: текущото състояние на ресурса, бизнес правилата или онова, което клиентът е смятал, когато я е писал.
Затова петте кода в този урок отговарят на въпроси, които в Урок 2 не съществуваха:
| Въпрос | Ако отговорът е „не“… | Код | Какво трябва да направи онзи, който е поискал |
|---|---|---|---|
| Имат ли данните смисъл, всяка стойност сама по себе си? | прочетено е, но някоя стойност е невъзможна | 422 |
да поправи тези стойности |
| Съвместими ли са с онова, което вече има? | сблъскват се с текущото състояние на сървъра (например друга услуга вече използва това име) | 409 |
да разреши сблъсъка и да изпрати отново |
| Такъв ли е ресурсът, какъвто клиентът е смятал? | променил се е, откакто го е прочел | 412 |
да прочете отново, да направи промяната си наново и да изпрати отново |
| Каза ли клиентът каква версия смята, че има? | не го каза, а сървърът го изисква | 428 |
да прочете и да изпрати етикета |
| Още ли е там ресурсът? | премахнат е нарочно и няма да се върне | 410 |
да спре да го иска и да изтрие връзката |
Всеки ред отново е различно действие за онзи, който получава грешката, а почти винаги този някой е програма. 422 казва „данните ти са грешни; поправи ги“. 409 казва „данните ти са наред, но се сблъскват с нещо, което вече съществува“. 412 казва „данните ти може да са наред, но си ги написал, мислейки за версия, която вече не съществува“. Ако revisor отговаряше и на трите с 400, програмата на Бето нямаше как да разбере, че онова, което трябваше да направи, е да прочете отново, а това е точно единственото, което би спасило промяната на Ана.
Урокът има четири части:
- Три въпроса, които се объркват (3.1): конфликт на състоянието, бизнес правило и предусловие.
- Изгубената актуализация (3.2 до 3.4): да я предизвикаш в червено, да я предотвратиш в зелено с
ETagиIf-Match(412), да затвориш надпреварата между две заявки едновременно, като сравняваш в същата стъпка, в която се записва, и да изискваш условието (428). - Конфликт, правило и ресурс, който си е отишъл (3.5 до 3.7):
409, политиката за400срещу422и410срещу404. - Версии на API-то и пълният
revisor(3.8 и 3.9):Deprecation,Sunsetи обявеният410; файловете и изпълнението.
Понятията
3.1 Конфликт на състоянието, бизнес правило и предусловие
3.1.1 Три неуспеха, които изглеждат еднакви
Представи си три заявки от екип a, които revisor отхвърля. И трите са валиден JSON, и трите идват с идентификационни данни за писане и трите отиват към маршрут, който съществува:
- Регистрация с
{"nombre":"","url":"ftp://archivos","limite_ms":5}. - Регистрация с
{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}, когато екипaвече има услуга с имеpagos. - Промяна на услуга 1, направена въз основа на копие, прочетено преди десет минути, когато някой друг вече я е променил.
Ако попиташ начинаещ какво общо имат, ще каже „това са грешки при валидацията“ и ще отговори с 400 и на трите. Но виж какво би трябвало да направи онзи, който ги е изпратил, за да проработи следващата:
- При първата — да поправи данните. Няма значение колко пъти я повтаря, нито какво се случва междувременно на сървъра: празно име никога няма да бъде валидно. Неуспехът е вътре в заявката.
- При втората данните са наред. Име
pagosе напълно валидно; проблемът е, че вече има друго такова. Ако утре някой премахне услугатаpagosна екип a, същата тази заявка, без промяна на нито един байт, ще проработи. Неуспехът е в сблъсъка между заявката и текущото състояние на сървъра. - При третата може данните да са наред и да не се сблъскват с нищо. Проблемът е друг: онзи, който я е изпратил, е написал промяната си върху версия на услугата, която вече не съществува. Ако сървърът я приеме, ще изтрие промяната на друг човек, без никой да разбере. Неуспехът е в едно предположение на клиента, което е престанало да бъде вярно.
Това са три различни неуспеха и HTTP има код за всеки:
| Неуспех | Къде е проблемът | Код | Раздел |
|---|---|---|---|
| Бизнес правило: данните не могат да бъдат изпълнени | вътре в заявката | 422 Unprocessable Content |
RFC 9110, 15.5.21 |
| Конфликт на състоянието: данните се сблъскват с онова, което вече има | между заявката и текущото състояние | 409 Conflict |
RFC 9110, 15.5.10 |
| Предусловие: клиентът е предполагал състояние, което вече не е такова | в онова, което клиентът е смятал | 412 Precondition Failed |
RFC 9110, 15.5.13 |
3.1.2 Какво казва спецификацията за всеки от тях
Дефинициите в RFC 9110 са кратки и си струва да се прочетат внимателно, защото всяка носи въпроса, който я решава.
422 (раздел 15.5.21) казва, че сървърът разбира типа на съдържанието (затова не е 415), че синтаксисът е правилен (затова не е 400), но че не е могъл да обработи инструкциите в него. Примерът в спецификацията е добре оформен XML с инструкции, които нямат смисъл. Въпросът, който го решава, е: успях ли да го прочета, но това, което казва, е невъзможно?
409 (раздел 15.5.10) казва, че заявката не е могла да бъде изпълнена заради конфликт с текущото състояние на ресурса и че се използва, когато потребителят би могъл да разреши конфликта и да изпрати отново заявката. Добавя едно би трябвало: сървърът би трябвало да отговори с достатъчно информация, за да може потребителят да разпознае откъде идва конфликтът. Въпросът, който го решава, е: би ли било това валидно, ако сървърът беше в друго състояние?
412 (раздел 15.5.13) казва, че едно или повече условия, дошли в заглавките на заявката, са се оказали неверни при оценяването им на сървъра и че той съществува, за да може клиентът да попречи методът да бъде приложен, ако ресурсът е в неочаквано състояние. Въпросът, който го решава, е: постави ли клиентът условие и то не беше изпълнено?
Обърни внимание на последното изречение: 412 съществува само ако клиентът е поставил условие. Сървърът не измисля предусловия; той ги оценява. Това е най-фината разлика в урока и тя се връща в раздел 3.5.
3.1.3 В какъв ред се задават
Входната точка от Урок 2 имаше седем въпроса: маршрут, метод, идентификационни данни, разрешение, съществуване, формат и четене. Този урок добавя въпроси между тях и след тях, и редът им също е важен. От съществуването нататък една промяна (PUT) пита следното:
- Съществува ли ресурсът — за теб? Ако е премахнат и е бил твой,
410; ако не,404. - (Както в Урок 2) Твърди ли тялото, че е JSON? Ако заглавката
Content-Typeказва друго,415. Това се разбира, без да се чете тялото. - Само при промяна: изпрати ли условието? Ако не,
428. Изпълнено ли е? Ако не,412. - (Както в Урок 2) Може ли тялото да се прочете? Ако не,
400. А ако надхвърля 16 KiB, ограничението, коетоrevisorприема,413(раздел 3.6.1). - Могат ли данните да бъдат изпълнени? Ако не,
422. - Сблъскват ли се със състоянието на сървъра? Ако се сблъскват,
409. - Само при промяна, при записването: още ли е версията тази, която съвпадна в стъпка 3? Ако друга заявка я е променила междувременно,
412; ако друга я е премахнала,410. Тази стъпка изглежда повторена, но не е: раздел 3.3.4 показва какво става без нея.
Защо форматът е преди условието, а условието — преди четенето на тялото? Защото така изисква спецификацията. Раздел 13.2.1 на RFC 9110 казва, че сървърът трябва да оценява предусловията след обичайните си проверки на заявката и точно преди да обработи съдържанието или да изпълни метода. И добавя другата половина на правилото: сървърът трябва да пренебрегне предусловията, ако без тях и преди обработката на съдържанието отговорът би бил нещо различно от 2xx или 412. С други думи: грешките, които се откриват, преди да започне истинската работа, имат предимство пред условието. Маршрут, който не съществува, липсващи идентификационни данни и премахнат ресурс са от тях. Такъв е и Content-Type, който не се приема: 415 идва от заглавка, без да се прочете нито един байт от тялото (раздел 15.5.16 казва, че 415 може да се дължи на Content-Type, който заявката обявява). Затова PUT с text/plain получава 415, дори етикетът му да е стар, а PUT с If-Match: * към премахната услуга получава 410, а не 412, както ще видиш в 3.7. Онова, което наистина чака условието, е четенето на тялото: 400 и 422 се разбират едва след обработването му.
428 не е предусловие, което се оценява; той е липсата на такова. Въпреки това revisor го проверява на същото място като 412, след формата: така двата отговора, които пращат клиента да прочете етикета, идват от една и съща стъпка, а клиент, който изпраща грешен формат и никакво условие, научава първо онова, което се знае, без да се чете нищо.
И има практическа причина условието да е преди четенето: ако етикетът е стар, всичко, което казва тялото, е написано върху версия, която вече не съществува. Няма смисъл да се проверява поле по поле и да се казва на клиента „името ти е грешно“, когато онова, което трябва да направи първо, е да прочете отново. Упражненията го проверяват с изпълнения: упражнение 1 — с text/plain без условие и с друг със стар етикет; упражнение 3 — със заявка, която носи едновременно стар етикет и невъзможни данни.
3.2 Изгубената актуализация
3.2.1 Двама редактори и по един 200 за всеки
Сцената от началото не изисква лош късмет, нито програмна грешка; достатъчни са двама души и един PUT, който не пита нищо. Проблемът има име в спецификацията: RFC 9110, в раздел 13.1.1, го нарича lost update и казва, че If-Match съществува преди всичко, за да я предотврати, когато няколко клиента действат паралелно върху един и същ ресурс.
В най-честата си форма тя се случва в последователност от четири стъпки (с трима редактори или с автоматичен процес по средата идеята е същата, с повече участници):
- Ана прочита услугата. Има копие.
- Бето прочита услугата. Има идентично копие.
- Ана сменя адреса в своето копие и го запазва. Сървърът остава с новия адрес.
- Бето сменя ограничението в своето копие, което все още има стария адрес, и го запазва. Сървърът остава с новото ограничение и стария адрес.
Стъпка 4 е тази, която боли. PUT заменя целия ресурс с онова, което носи тялото: раздел 9.3.4 на RFC 9110 казва, че той иска състоянието на ресурса да бъде създадено или заменено с това, което идва в заявката. Бето не е искал да пипа адреса; просто е изпратил онзи, който е имал. За сървъра няма начин да различи „не го пипнах“ от „искам го така“.
Програмата fig03_02.ts възпроизвежда четирите стъпки срещу revisor. Ана слага адреса …/codigo/204; Бето — ограничението от 3000 ms. Накрая услугата се прочита и програмата казва дали промяната на Ана е оцеляла:
// fig03_02.ts
// Dos editores: Ana y Beto leen el mismo servicio, cada uno cambia algo distinto y lo guarda.
// Ana cambia la dirección; Beto, que leyó antes de que Ana guardara, cambia el límite.
// Al final se lee el servicio y se dice si el cambio de Ana sobrevivió.
// Uso: node fig03_02.ts
const BASE = "http://127.0.0.1:18080/servicios/1";
const AUTORIZACION = { Authorization: "Bearer escritura-a" };
type Copia = { etiqueta: string; datos: Record<string, unknown> };
async function leer(quien: string): Promise<Copia> {
const respuesta = await fetch(BASE, { headers: AUTORIZACION });
const datos = (await respuesta.json()) as Record<string, unknown>;
const etiqueta = respuesta.headers.get("etag") ?? "";
console.log(`${quien.padEnd(8)} lee: ${respuesta.status} ${etiqueta} ${JSON.stringify(datos)}`);
return { etiqueta, datos };
}
// Guarda la copia completa (PUT reemplaza el servicio entero), con la etiqueta que leyó.
async function guardar(quien: string, copia: Copia): Promise<number> {
const { id: _id, ...cuerpo } = copia.datos;
const respuesta = await fetch(BASE, {
method: "PUT",
headers: { ...AUTORIZACION, "Content-Type": "application/json", "If-Match": copia.etiqueta },
body: JSON.stringify(cuerpo),
});
const texto = (await respuesta.text()).trim();
const nueva = respuesta.headers.get("etag");
console.log(`${quien.padEnd(8)} guarda: ${respuesta.status} ${nueva === null ? "" : `${nueva} `}${texto}`);
return respuesta.status;
}
const DIRECCION_NUEVA = "http://127.0.0.1:18081/codigo/204";
const ana = await leer("Ana");
const beto = await leer("Beto");
ana.datos.url = DIRECCION_NUEVA;
beto.datos.limite_ms = 3000;
await guardar("Ana", ana);
if ((await guardar("Beto", beto)) === 412) {
// El 412 le dice a Beto que su copia es vieja: vuelve a leer, repite SU cambio y guarda.
const otraVez = await leer("Beto");
otraVez.datos.limite_ms = 3000;
await guardar("Beto", otraVez);
}
const final = await leer("Al final");
console.log(final.datos.url === DIRECCION_NUEVA ? "el cambio de Ana sigue ahí" : "SE PERDIÓ el cambio de Ana, y nadie recibió un error");
Ако сървърът отговори на Бето с 412, програмата прави онова, което трябва да направи добре написан клиент: прочита отново, прилага своята промяна (само ограничението) върху новото копие и запазва отново. Но това се случва само ако сървърът отговори с 412. Първо — червеното.
3.2.2 В червено: PUT, който не пита
Това е PUT, който почти всеки пише първия път: намира услугата, прочита тялото, валидира го и го запазва. Работи, минава всички тестове с един потребител и губи данни веднага щом потребителите станат двама. За да го видиш, копирай папката fig03_01/ от раздел 3.9 като rota/ и изтрий от cambiar, в rota/servicios.ts, целия блок от const condicion = … до изчисляването на versionLeida, и подай null на cambiarServicio. Остава така (това е фрагмент; пълната версия е в defectos/sin-condicion/servicios.ts):
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 datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
// … validar, destino y nombre repetido, igual que antes
const cambiado = cambiarServicio(servicio.id, null, validos);
Серия C: стартирай лабораторията и revisor от rota/. Първо PUT без If-Match, който в revisor от този урок дава 428:
$ 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","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-2-dedb68a8"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:25 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
200. Нищо не го спира. А сега двамата редактори. Копирай fig03_02.ts до rota/ и го изпълни:
$ node fig03_02.ts
Ana lee: 200 "1-2-dedb68a8" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
Beto lee: 200 "1-2-dedb68a8" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
Ana guarda: 200 "1-3-dedb68a8" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/204","limite_ms":1500}
Beto guarda: 200 "1-4-dedb68a8" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}
Al final lee: 200 "1-4-dedb68a8" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}
SE PERDIÓ el cambio de Ana, y nadie recibió un error
Ето я сцената от началото, цялата. Ана запази новия адрес (…/codigo/204) и получи 200. Бето запази старото си копие с новото ограничение и също получи 200, а с това върна адреса на …/codigo/200. Обърни внимание, че Бето е изпратил If-Match, с етикета "1-2-…", който е прочел; сървърът просто не го е погледнал. Условна заглавка, която сървърът пренебрегва, не защитава нищо, а клиентът няма как да го разбере: спецификацията позволява на посредник да пренебрегне If-Match, но изходният сървър, който я получи, трябва да я оцени, преди да изпълни метода (раздел 13.1.1).
Онова, което не е наред, не се вижда в нито една заглавка: това е самият 200. Един 200 каза на Бето „промяната ти е приложена върху онова, което си прочел“, а това беше лъжа: тя беше приложена върху нещо, което Бето никога не е виждал. Спри програмите от rota/; ще използваш тази папка отново в 3.3.4.
3.2.3 В зелено: етикетът като доказателство коя версия си прочел
Решението има две части, които вече познаваш от Урок 1:
ETag: всеки път, когатоrevisorпредава услуга, ѝ слага етикет, който се променя при всяка промяна на услугата. Вrevisorтой е"id-версия-стартиране": id на услугата, номер на версия, който се увеличава с всяка промяна, и осемте цифри, които се генерират при стартиране.- Условна заглавка: в Урок 1
If-None-Matchказваше „изпрати ми го само ако се е променило“. СегаIf-Matchказва обратното: „направи това само ако не се е променило“, или по-точно — само ако текущият етикет е онзи, който съм прочел аз.
С това всеки клиент изпраща заедно с промяната си етикета на версията, върху която я е написал. Ако някой е променил услугата между четенето и записването, етикетът вече не съвпада и сървърът отказва: отговаря с 412 и не прилага нищо. Клиентът не е загубил данни; загубил е надпревара и го знае.
Първо същата програма с двамата редактори, която в 3.2.2 загуби промяната на Ана. Серия B: спри revisor от rota/, стартирай лабораторията и revisor от fig03_01/ и изпълни fig03_02.ts веднага щом стартира:
$ node fig03_02.ts
Ana lee: 200 "1-1-c0466c5e" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
Beto lee: 200 "1-1-c0466c5e" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
Ana guarda: 200 "1-2-c0466c5e" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/204","limite_ms":2000}
Beto guarda: 412 tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
Beto lee: 200 "1-2-c0466c5e" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/204","limite_ms":2000}
Beto guarda: 200 "1-3-c0466c5e" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/204","limite_ms":3000}
Al final lee: 200 "1-3-c0466c5e" {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/204","limite_ms":3000}
el cambio de Ana sigue ahí
Ана запази (200, версия 2). Бето получи 412, прочете отново (сега вижда адреса на Ана), приложи само своята промяна и запази (200, версия 3). Накрая са налице и двете промени. Никой не трябваше да се координира по чата, нито да заключва услугата, докато я редактира: достатъчно беше сървърът да откаже да записва върху стара версия. Тази техника се нарича оптимистичен контрол на конкурентността (optimistic concurrency): приема се, че сблъсъци почти никога няма да има, всички се оставят да работят и се спира само закъснелият.
Сега същото, стъпка по стъпка, с curl. Тук започва серия A, основната в урока: спри revisor и го стартирай отново от fig03_01/, за да бъде услуга 1 такава, каквато е в началото. Оттук до края на раздел 3.8 всички заявки към правилния revisor са от тази серия; не го рестартирай между тях, защото всяка тръгва от онова, което е оставила предишната. Първо се прочита услуга 1 и етикетът ѝ се запазва в променливата ETAG с опцията -w '%header{etag}' на curl, която отпечатва само тази заглавка. Това четене играе ролята на „Ана и Бето четат“:
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 200 OK
ETag: "1-1-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ ETAG=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-1-c4dd49c4"
Ана запазва първа, с If-Match и етикета, който е прочела. Сменя ограничението на 1500 ms:
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-2-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
200 и нов етикет: версията стана от 1 на 2. Сега идва Бето с същия етикет отпреди, този на версия 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: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/500","limite_ms":2000}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 115
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
412 Precondition Failed. Промяната на Бето не беше приложена, а тази на Ана остава непокътната. Тялото казва какво се е случило и какво да се направи: етикетът му вече не е актуалният, защото услугата се е променила (или защото revisor е рестартиран, което също обезсилва етикетите), и той трябва да я прочете отново, преди да я промени. Не повтаря етикета, който е изпратил Бето: Бето вече го има, а връщането на клиента на текст, който самият той е написал, само уголемява отговора, без да му казва нищо ново.
3.3 If-Match и 412
3.3.1 Защо If-Match се сравнява силно
Урок 1 показа, че има два начина за сравняване на етикети, а раздел 8.8.3.2 на RFC 9110 ги дефинира с таблица:
| Етикет 1 | Етикет 2 | Силно сравнение | Слабо сравнение |
|---|---|---|---|
W/"1" |
W/"1" |
не съвпада | съвпада |
W/"1" |
W/"2" |
не съвпада | не съвпада |
W/"1" |
"1" |
не съвпада | съвпада |
"1" |
"1" |
съвпада | съвпада |
Слабият етикет (с W/ отпред) обещава малко: че две представяния са „еквивалентни“, макар байтовете да не са идентични. Той служи за кеша и затова If-None-Match се сравнява слабо. Силният етикет обещава, че байтовете са точно същите.
If-Match се сравнява силно и раздел 13.1.1 го изисква с трябва: изходният сървър трябва да използва силното сравнение, защото клиентът иска да попречи методът да бъде приложен, ако е имало каквато и да е промяна в данните на представянето. Слаб етикет не може да гарантира това: две „еквивалентни“ версии може да се различават в нещо, което за клиента има значение. Затова при силно сравнение етикет W/ никога не съвпада с нищо, дори със своя близнак.
Спецификацията казва и какво носи заглавката: * или списък от етикети, разделени със запетаи (If-Match = "*" / #entity-tag). И дава три стъпки за оценяването ѝ:
- Ако е
*, условието е изпълнено, ако ресурсът има текущо представяне, тоест ако съществува. - Ако е списък, е изпълнено, ако някой от етикетите съвпада с актуалния.
- Във всеки друг случай не е изпълнено.
etiquetas.ts, който идва без промени от Урок 1, вече прави всичко това: interpretarLista чете списъка, като спазва кавичките, coincideFuerte сравнява два етикета, както изисква таблицата, а hayCoincidencia съединява двете неща. Новото е кой я извиква с coincideFuerte: манипулаторът cambiar в servicios.ts.
3.3.2 Трите форми, които съвпадат, и тази, която не
За да се провери, в същата серия A се прочита актуалният етикет (сега версия 2, след промяната на Ана) и се запазва във VIGENTE. После се изпраща промяна със същия етикет, но отбелязан като слаб:
$ VIGENTE=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "VIGENTE=$VIGENTE"
VIGENTE="1-2-c4dd49c4"
$ 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/$VIGENTE" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 109
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó
412, макар текстът в кавичките да е точно актуалният. Обърни внимание на тялото: не казва, че услугата се е променила, защото не се е променила. Казва, че слаб етикет не става за промяна и че трябва да се изпрати така, както е пристигнал. revisor го знае, защото когато силното сравнение се провали, опитва и слабото: ако то съвпадне, проблемът не е, че услугата се е променила, а че клиентът е добавил W/ към етикета (може да се случи, без клиентът да го е написал: от версия 1.7.3, от 2014 г., уеб сървърът nginx превръща силен етикет в слаб, когато променя отговора, например когато го компресира, според неговия списък с промени; този случай има собствено усложнение, което се обяснява два абзаца по-долу). Да му се каже, че услугата се е променила, би било невярно и би пратило клиента да чете отново в цикъл, който никога не свършва.
Има случай, в който съветът от тялото, „изпрати ETag така, както е пристигнал“, не е достатъчен: този с nginx. Ако посредник е отслабил етикета, на клиента той е пристигнал слаб и изпращането му така, както е пристигнал, повтаря 412 при всеки опит, докато посредникът продължава да отслабва етикета. Този проблем не може да бъде поправен от клиента: онзи, който управлява API-то, трябва да попречи на посредника да променя тези отговори (например да не компресира онези, които носят етикет, който ще се използва за запис) или да използва етикети, които оцеляват при промяната. Затова тялото казва точната причина, а не общото „нещо се обърка“: човекът, който го прочете, знае, че проблемът е в слабия етикет, и кого да уведоми. Кодът командва, а тялото обяснява, и двете казват едно и също: и двете казват „условието ти не беше изпълнено“, а тялото добавя защо.
Сега списък, който носи актуалния етикет заедно с друг, който не съществува, а после *:
$ 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\", $VIGENTE" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-3-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 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-4-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
И двете дават 200. Списъкът съвпада, защото някой етикет съвпада. * съвпада, защото услугата съществува. И всяка промяна увеличи версията: "1-3-…", а после "1-4-…".
За какво служи If-Match: *, ако не защитава от чужда промяна? За друго: казва „направи го само ако ресурсът съществува“. В API, където PUT може да създаде ресурс, If-Match: * предотвратява създаването на такъв по погрешка, когато намерението е било да бъде променен; а обратното му, If-None-Match: *, предотвратява презаписването на вече съществуващ, когато намерението е било да бъде създаден. В revisor PUT никога не създава (услуга, която не съществува, дава 404), така че * само спестява на клиента четенето на етикета. Затова който изпраща *, се отказва от защитата срещу изгубената актуализация. Това е валидно, спецификацията го позволява, но е решение на клиента и е добре да го взема съзнателно.
3.3.3 Кодът на cambiar
Това е манипулаторът, изцяло в servicios.ts (раздел 3.9). Важното е в първите редове:
// PUT: cambiar sin pisar el cambio de otra persona. Exige If-Match con la etiqueta vigente.
export async function cambiar({ peticion, respuesta, parametros, credencial }: Contexto): Promise<void> {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
// Primero el formato (415), después la condición (428 y 412), después la lectura (400).
if (!esJson(peticion, respuesta)) return;
const condicion = peticion.headers["if-match"];
if (condicion === undefined) {
fallar(respuesta, 428, "para cambiar un servicio manda If-Match con su ETag");
return;
}
// If-Match se compara en fuerte (RFC 9110, secciones 13.1.1 y 8.8.3.2): una etiqueta W/ nunca coincide.
// El detail no repite la cabecera que mandó el cliente: dice qué pasó y qué hacer.
if (!hayCoincidencia(condicion, etiqueta(servicio), coincideFuerte)) {
// Si solo coincide en débil, no es que el servicio haya cambiado: la etiqueta W/ no sirve para escribir.
const detalle = hayCoincidencia(condicion, etiqueta(servicio), coincideDebil)
? "If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó"
: "tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo";
fallar(respuesta, 412, detalle);
return;
}
// La versión que coincidió. Entre aquí y la escritura hay dos await (leer el cuerpo y consultar el DNS),
// y en ese hueco otra petición puede cambiar el servicio: cambiarServicio vuelve a comparar al escribir.
const versionLeida = interpretarLista(condicion) === "*" ? null : servicio.version;
const datos = await leerJson(peticion, respuesta);
// …
Чете се в реда от 3.1.3: първо encontrar (съществува или не, за тези идентификационни данни), после esJson, който гледа само заглавката Content-Type (415), после условието и едва след това тялото. Преди да прочете тялото, cambiar записва коя версия е съвпаднала, във versionLeida; с If-Match: * записва null, защото * не говори за никаква версия, а само иска услугата да съществува. Този запис изглежда излишен, защото сравнението вече е направено. Следващият раздел показва защо не е.
3.3.4 Две заявки едновременно: сравняване и записване в една стъпка
Между сравнението на етикета и записването cambiar чака два пъти: await leerJson(…), докато пристигне тялото, и await destinoPermitido(…), докато DNS разреши името от URL адреса. При всеки await Node спира да обслужва тази заявка и обслужва други. Ако в тази пролука пристигне друг PUT със същия етикет, той също минава сравнението, защото никой още не е записал. Двете заявки продължават напред, убедени, че услугата е такава, каквато са я прочели, и онази, която запише последна, презаписва другата. Това отново е изгубената актуализация, този път с двама клиенти, които са изпратили If-Match към сървър, който го е сравнил.
Първо в червено. За да се види, без да се разчита на късмета, едната от двете заявки трябва нарочно да остане да чака в пролуката. curl -T - изпраща тялото, което чете от входа си, докато то пристига; ако входът се бави, заявката изпраща заглавките си веднага и остава да чака тялото, точно в await leerJson. „Бавната“ иска да сложи ограничението на 1000 ms и тялото ѝ пристига секунда по-късно; след 0,3 секунди „бързата“ иска да го сложи на 3000 ms, със същия етикет. Счупената версия е отделна серия, D, и прекъсва серия A. Ако предпочиташ да не я прекъсваш, пропусни това червено и го изпълни в края на урока; ако го изпълниш сега, когато се върнеш, ще трябва да стартираш отново revisor от fig03_01/ и да повториш серия A от 3.2.3 дотук, за да съвпадат отговорите ти с тези от урока.
За счупената версия създай копието от нулата: rm -r rota && cp -R fig03_01 rota. rm не е излишен: ако rota/ вече съществува, cp -R слага папката вътре (rota/fig03_01/), а rota/ остава с версията от 3.2.2. После направи една-единствена промяна в rota/servicios.ts: в cambiar подай на cambiarServicio null вместо versionLeida. С това сравнението в началото остава на мястото си, но при записване вече не се сравнява нищо (пълната версия е в defectos/una-sola-comparacion/servicios.ts).
Серия D: спри revisor, който ти работи, и стартирай лабораторията и revisor от rota/. Преди надпреварата провери, че изпълняваш правилното копие, защото червеният резултат от надпреварата е еднакъв с двете счупени версии и не би те предупредил за грешката. PUT с измислен етикет трябва да даде 412, защото сравнението в началото още работи; ако даде 200, изпълняваш копието от 3.2.2:
$ 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: "1-9-00000000"' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 115
Date: Wed, 21 Oct 2026 13:18:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
412: първото сравнение работи. А сега надпреварата:
$ ETAG=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-1-4ac4936c"
$ ( sleep 1; printf '%s' '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1000}' ) | curl -sS -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 -sS -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
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1000}
lenta: 200
$ curl -sS 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":1000}
И двете получиха 200; бавната отпечата услугата, която ѝ върнаха. А стойността, която остана, е тази на бавната, 1000, макар бързата да запази първа и също да получи 200. Клиентът на бързата смята, че ограничението е 3000; сървърът казва 1000; никой не получи грешка. Сравнението в началото беше направено (проверката по-горе го доказва) и не помогна, защото когато бавната записа, то вече не беше вярно.
Ако напишеш надпреварата в своя терминал, ще видиш и ред като [1] 12345 при пускането на бавната и още един, който съобщава, че е приключила ([1]+ Done … в bash, [1] + done … в zsh): те са от shell-а, който те уведомява за задачите във фонов режим, а не от revisor.
После в зелено. Поправката е да се сравни отново, в момента на записването, без нито един await между сравнението и записването. Това прави cambiarServicio в almacen.ts:
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;
}
Това е синхронна функция: няма нито един await, така че JavaScript я изпълнява от началото до края, без да обслужва друга заявка по средата. Никой не може да се вмъкне между if, който сравнява версията, и Object.assign, който записва. Ако версията вече не е тази, която cambiar е записал, не записва нищо и връща "conflicto", а cambiar отговаря с 412. А ако услугата вече я няма, защото друга заявка я е премахнала в пролуката, връща "retirado", а cambiar отговаря с 410; раздел 3.7.3 го предизвиква:
const cambiado = cambiarServicio(servicio.id, versionLeida, validos);
if (cambiado === "conflicto") {
fallar(respuesta, 412, "el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo");
return;
}
if (cambiado === "retirado") {
// Otra petición lo retiró mientras ésta esperaba: 410, como si hubiera llegado después del retiro.
fallar(respuesta, 410, `el servicio ${servicio.id} se retiró y no va a volver`);
return;
}
enviarJson(respuesta, 200, representar(cambiado), { ETag: etiqueta(cambiado) });
}
Същата надпревара срещу revisor от този урок, в серия A, точно след заявките от 3.3.2 (ако си изпълнил серия D, стартирай отново fig03_01/ и повтори преди това серия A от 3.2.3; ако не я повториш, етикетът ти ще казва "1-1-…" вместо "1-4-…" и онова, което следва в 3.4 до 3.8, няма да съвпада с урока):
$ ETAG=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-4-c4dd49c4"
$ ( sleep 1; printf '%s' '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1000}' ) | curl -sS -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 -sS -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
el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo
lenta: 412
$ curl -sS 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}
Бързата — 200; бавната — 412, с тяло, различно от това при стар етикет: услугата се е променила, докато заявката ѝ е била обслужвана. А стойността, която остана, е тази на бързата, 3000, същата, която клиентът ѝ смята, че е останала. Нито един запис не се загуби: единият беше приложен, другият беше отхвърлен и клиентът му го знае.
Защо да не се премести цялото сравнение в края и да се сравнява само веднъж? Това би затворило пролуката по същия начин, но би накарало сървъра да чака цялото тяло и да пита DNS за етикет, за който от първия ред се знае, че е стар. Затова cambiar сравнява два пъти: в началото, за да отхвърли евтино онова, което е пристигнало вече изтекло; при записването, за да не приеме онова, което е изтекло по пътя.
Еквивалентът в база данни. Истинско API не пази данните си в Map, но проблемът и решението са същите. Четенето на версията със SELECT и после записването с отделен UPDATE оставя същата пролука между двете заявки. Обичайният начин е версията да се сложи в условието на записа: UPDATE servicios SET … , version = version + 1 WHERE id = 1 AND version = 4. Базата данни сравнява и записва в една операция и отговаря колко реда е засегнала; ако е засегнала нула, друг е пристигнал преди това и отговорът е 412. cambiarServicio е точно това, написано в паметта. И едно честно ограничение: атомарността на синхронна функция важи в рамките на един процес на Node. Ако revisor работеше в няколко инстанции (процеси, реплики зад балансьор на натоварването), всяка със собствен Map, вече няма едно-единствено място, където да се сравнява и записва; това сравнение трябва да живее там, където данните са споделени, в базата данни, с UPDATE … WHERE version = N.
3.4 412 срещу 428
3.4.1 Когато клиентът не изпрати условието
Всичко дотук работи, ако клиентът изпрати If-Match. А ако не го изпрати? За сървъра PUT без условие е точно PUT-ът от сцената в началото: записване, без да се знае върху коя версия. Може да го приеме и тогава защитата зависи от това всички клиенти да не забравят да изпратят заглавката. Или може да я изисква.
За това съществува 428 Precondition Required, който не е в RFC 9110, а в RFC 6585, раздел 3, от април 2012 г. Дефиницията му е от един ред: изходният сървър изисква заявката да бъде условна. А следващото изречение казва за какво: типичната му употреба е да се предотврати изгубената актуализация, когато клиент прочита състоянието на ресурс, променя го и го връща с PUT, докато трета страна вече го е променила. RFC добавя две правила: отговорът би трябвало да обяснява как заявката да бъде изпратена отново успешно (собственият му пример казва „опитай с If-Match“) и не трябва да се запазва в кеш.
$ 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","limite_ms":1500}'
HTTP/1.1 428 Precondition Required
Content-Type: text/plain; charset=utf-8
Content-Length: 52
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
para cambiar un servicio manda If-Match con su ETag
Тялото казва какво да се направи, както изисква RFC: изпрати If-Match с ETag. За да знае кой, клиентът трябва първо да прочете услугата, а точно това е пропуснал да направи.
3.4.2 Разликата в таблица
412 Precondition Failed |
428 Precondition Required |
|
|---|---|---|
| Дойде ли условието? | да | не |
| Какво се случи? | условието беше оценено и се оказа невярно | няма какво да се оценява |
| Какво казва? | „ресурсът се е променил, откакто си го прочел“ (или етикетът ти не става) | „не записвам, без да знам коя версия си прочел“ |
| Какво прави клиентът? | прочита отново, прави промяната си наново върху новото копие, изпраща новия етикет | прочита и изпраща етикета |
| Може ли да повтори същата заявка без промяна? | не: отново ще получи 412 |
не: отново ще получи 428 |
| Дефиниран в | RFC 9110, раздел 15.5.13 | RFC 6585, раздел 3 |
И двата отговора пращат клиента да чете, но по различни причини, и програмата ги третира различно: след 412 програмата трябва да слее промяната си с онова, което е намерила, защото някой друг е записал; след 428 просто ѝ е липсвала една стъпка, а онова, което намери при четенето, е същото, което вече е имала. Затова не се обединяват в един код.
Защо revisor изисква условието, вместо само да го препоръчва? Защото в API, което управлява споделена конфигурация, като списъка с услуги, които наблюдава един екип, изгубената актуализация не предупреждава: открива се часове по-късно, както в сцената. Изискването на If-Match струва на клиента едно четене; неизискването му може да му струва нощ без наблюдение. Това е дизайнерско решение, а не задължение на HTTP: RFC 6585 позволява да се изисква, не задължава да се изисква. Има API-та, които решават различно, и ще го видиш в 3.5.3.
3.5 Конфликт на състоянието: 409
3.5.1 Повтореното име
revisor има правило: в рамките на един екип две услуги не могат да се казват еднакво. Това е разумно правило (името е онова, което се чете в таблата и в известията, а две „pagos“ биха объркали всекиго) и е правило за състоянието: не зависи от самото име, а от това кои други услуги съществуват. Ето как изглежда, когато екип a се опита да регистрира втора pagos:
$ 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:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya existe un servicio llamado pagos: /servicios/2
409 Conflict. Тялото изпълнява би трябвало от раздел 15.5.10: казва с какво се сблъсква (/servicios/2), за да може клиентът да разпознае източника на конфликта и да реши: да смени името или да провери вече съществуващата услуга, защото може би тя е тази, която е търсил. Урок 5 ще сложи този адрес в отделно поле на тялото, existente, за да може програма да го прочете, без да тълкува изречението.
Същото се случва при промяна, която се опитва да даде на една услуга името на друга. Тук клиентът изпраща If-Match: *, така че условието е изпълнено; онова, което се проваля, е следващото:
$ 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":"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:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya existe un servicio llamado pagos: /servicios/2
А доказателството, че това е конфликт на състоянието, а не правило за данните: същата заявка за регистрация, байт по байт, с идентификационните данни на екип b, който няма никаква pagos:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-b' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/4
ETag: "4-1-c4dd49c4"
Content-Type: application/json
Content-Length: 85
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":4,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
201. Данните бяха валидни; промени се състоянието, с което бяха сравнени. Това е решаващата проверка за 409: ако същата заявка би могла да проработи в друг момент или в друг контекст, без да се промени нищо в нея, неуспехът е на състоянието, а не на данните.
3.5.2 409, а не 422; 409, а не 412
Защо не 422? Защото 422 казва „това, което изпрати, не може да бъде изпълнено“, а клиент, който го получи, поправя данните си. Тук в данните няма нищо за поправяне. Има големи API-та, които решават различно: това на GitHub отговаря с 422 и кода already_exists, когато друг ресурс вече използва същата уникална стойност, като името на етикет (документация на неговото REST API). Това е последователно решение в рамките на неговото API и е документирано; курсът предпочита 409, защото спецификацията го описва именно за конфликта, който потребителят може да разреши и да изпрати отново.
Защо не 412? Защото клиентът не е поставил никакво условие. 412 отговаря на условна заглавка, която клиентът е изпратил и която се е оказала невярна. Клиентът, който регистрира pagos, не е казал „създай я само ако няма друга с това име“; сървърът има това правило сам по себе си. Когато правилото е на сървъра, а сблъсъкът е със състоянието, кодът е 409.
3.5.3 Когато версията пътува в тялото
Има случай, в който границата между 409 и 412 се измества, и самият раздел 15.5.10 го споменава: казва, че конфликтите са по-вероятни при PUT, и дава за пример API с версии, в което изпратеното представяне носи промени, които се сблъскват с тези от по-ранна заявка на трета страна. Това е изгубената актуализация. Тогава изгубената актуализация 409 ли е, или 412?
Зависи от това къде пътува условието:
- Ако версията, която клиентът е прочел, пътува в условна заглавка (
If-Match), сървърът оценява HTTP предусловие и ако то не е изпълнено, отговорът е412. Това правиrevisor. - Ако версията пътува в тялото (поле
"version": 7в JSON), за HTTP няма никакво предусловие: тя е част от данните. Сървърът открива сблъсъка, като сравни тялото със своето състояние, и отговорът е409.
И двете форми съществуват в реални API-та. Ръководствата за дизайн на Google (AIP-154) слагат етикета като поле на ресурса и искат да се отговаря с ABORTED, когато не съвпада, грешка, която тяхната таблица на съответствията превежда в 409 Conflict; същата таблица праща INVALID_ARGUMENT и FAILED_PRECONDITION към 400 (google.rpc.Code). И AIP-154 не изисква етикета: оставя заявката без него да мине, освен при услуги, които се нуждаят от строга консистентност. Ръководствата за дизайн на API на Zalando, от своя страна, предвиждат ETag с If-Match като възможност (тяхното правило 182 е „може“, а не „трябва“), а таблицата им с кодове присвоява 412 на условието, което не е изпълнено, и 428 — на условието, което се изисква (Zalando RESTful API Guidelines). Курсът следва втората форма, защото използва механизма, който HTTP вече има за това, и защото всеки посредник по пътя разбира стандартна заглавка; поле version в тялото разбира само твоят клиент.
Още една бележка за 409, за да не го объркаш по-нататък: в Урок 6 се появява друг 409, когато пристигне заявка със заглавка Idempotency-Key, докато оригиналната със същия ключ все още се изпълнява. Това поведение идва от чернова на IETF, draft-ietf-httpapi-idempotency-key-header-07, която изтече на 18 април 2026 г., без да бъде публикувана като RFC: тя не е стандарт и Урок 6 я третира като това, което е. Това е друг конфликт с текущото състояние и затова черновата е избрала същия код.
3.6 Бизнес правило: 422 и политиката за 400 срещу 422
3.6.1 Писаната политика
В Урок 2 всичко от тялото, което не вършеше работа, получаваше 400. От този урок нататък revisor разделя два случая и го прави с писано правило, защото това е едно от двете решения, които курсът взема като политика, а не по интуиция:
400 Bad Request: не може да се прочете. Тялото не е JSON или е JSON, но не е обект (null, число, текст, списък). Няма начин дори да се разбере какви полета е носило. (Тяло над 16 KiB дори не се дочита:revisorспира да го пази веднага щом надхвърли това ограничение и отговаря с413 Content Too Large, за да не може никой да му напълни паметта с гигантско тяло.)422 Unprocessable Content: прочита се, но не може да бъде изпълнено. Тялото е JSON обект, разбира се какви полета носи, но някое липсва, има грешен тип или стойност извън диапазона, или адресът сочи към забранена дестинация.
Правилата на revisor са четири: nombre е текст от 1 до 40 знака; url е адрес http или https; limite_ms, ако го има, е цяло число от 100 до 10 000 (ако го няма, е 2000); а дестинацията на url не може да бъде частен или резервиран адрес (SSRF от Урок 1).
Средното правило си заслужава: събират се всички нарушения, а не само първото. Клиент, който получи „името е грешно“, поправи го, получи „адресът е грешен“, поправи го и получи „ограничението е грешно“, е направил три обиколки за нещо, което можеше да се каже в една. Ето как изглеждат трите нарушения заедно, и 400 за тяло, което не е обект:
$ 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:29 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 '["inventario"]'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
По един ред за всяко поле, всеки с името на полето отпред. Урок 5 превръща тези редове в списък errors с по един pointer за поле ("#/nombre"), както в примера от раздел 3 на RFC 9457, за да може формуляр да отбележи всяко поле, без да тълкува текст.
Грешният тип също е 422, макар да прилича на проблем с четенето. Тук limite_ms е пристигнал като текст ("2000"), а не като число:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":"2000"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 48
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
limite_ms: debe ser un entero entre 100 y 10000
JSON беше прочетен без проблем; онова, което не спазва правилото, е „цяло число от 100 до 10 000“. Ако revisor превръщаше текста в число сам, би приел и "2000abc" или " 2000 " в зависимост от това как го преобразува, и всеки клиент би научил различно правило. По-добре е да се каже точно какво се е очаквало.
3.6.2 Забранената дестинация също е 422
Защитата срещу SSRF от Урок 1 остава същата, но сменя кода си. Преди отговаряше с 400, защото 422 още не съществуваше; сега URL адресът се прочита отлично, той е валиден http адрес, а онова, което не може да бъде изпълнено, е правилото „revisor не се свързва към частни или резервирани адреси“:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"metadatos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 70
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
destino prohibido: 169.254.169.254:80: dirección privada o reservada
$ 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: text/plain; charset=utf-8
Content-Length: 75
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
destino prohibido: localhost resuelve a una dirección privada o reservada
Текстът е същият като в предишните уроци. Във втория случай съобщението казва само, че localhost се разрешава към частен или резервиран адрес, без да изброява кои: към какво се разрешава едно име зависи от системата, а списъкът би разкрил на клиента вътрешната мрежа на сървъра, без да му е от полза.
3.6.3 Други ръководства решават различно
Политиката на курса не е единствената разумна и си струва да знаеш какво правят другите, защото ще срещаш API-та и от двете страни:
- Ръководствата на Zalando казват, че
400трябва да се използва и когато тялото не минава бизнес или семантичната валидация, „вместо да се използва422“, и отбелязват422като „не използвай“: според тях400вече покрива почти всички случаи и не виждат ясна полза от разграничаването им. - Тези на Google превеждат грешките си за невалиден аргумент (
INVALID_ARGUMENT) в400. - API-то на GitHub, както видя в 3.5.2, използва
422за валидацията, дори и за повторените стойности.
Курсът остава при границата „четене срещу изпълнение“ по причина, свързана с онзи, който получава грешката: 400 казва на клиента, че нещо във формата на заявката му е счупено, обикновено програмна грешка, която трябва да се поправи в кода; 422 му казва, че формата е наред, а стойностите не се приемат, обикновено нещо, което е написал човек и което може да му се покаже във формуляр. Това са две различни действия и спецификацията има код за всяко. По-важно от точната граница е API-то да я запише и да я прилага еднакво във всичките си маршрути: клиентът може да се адаптира към всяка от двете политики; към API, което използва двете случайно — не.
3.7 Ресурсът, който си е отишъл: 410 срещу 404
3.7.1 Какво не казва 404
В Урок 2 премахването на услуга я изтриваше от хранилището, а после идентификаторът ѝ отговаряше с 404, също като такъв, който никога не е съществувал. За клиента двете ситуации са много различни. Ако една услуга никога не е съществувала, може би клиентът е сбъркал номера и си струва да провери. Ако е съществувала и е премахната нарочно, няма какво да се проверява: трябва да спре да я иска, да я изтрие от конфигурацията, да махне връзката от таблото.
Раздел 15.5.5 на RFC 9110 казва, че 404 не показва дали липсата е временна или постоянна и че се предпочита 410, ако сървърът знае, че състоянието вероятно е постоянно. Раздел 15.5.11 дефинира 410 Gone: ресурсът вече не е наличен на изходния сървър и вероятно това е завинаги. Ако сървърът не знае или няма как да знае дали е постоянно, би трябвало да използва 404. И обяснява намерението: да се съобщи, че ресурсът е премахнат нарочно и че собствениците му искат връзките, които сочат към него, да бъдат премахнати.
За да може да отговори с 410, revisor трябва да помни какво е премахнал. Затова almacen.ts вече има втора карта, retirados, която пази id на всяка премахната услуга заедно с екипа, към който е принадлежала. Ето как отговаря:
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 42
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 2 se retiró y no va a volver
$ 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:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
Услуга 2 е премахната: 410, а тялото го казва без заобикалки, „няма да се върне“. 99 никога не е съществувала: 404. Клиентът вече може да третира всеки случай различно само като погледне кода.
Още два детайла от спецификацията. Първо, 410 е кешируем по евристика, също като 404: кешът може да го запази, дори да не носи инструкции за кеширане, и това е правилно, защото ресурсът няма да се върне. Второ, не е задължително всичко премахнато да се отбелязва като 410, нито да се помни завинаги: спецификацията оставя това на преценката на онзи, който управлява сървъра. revisor го помни, докато работи; тъй като пази всичко в паметта, след рестартиране премахнатата услуга отново дава 404. В API с база данни редът би бил отбелязан като премахнат, вместо да бъде изтрит.
3.7.2 410 не се казва на всеки
410 казва нещо повече от „не е тук“: казва „беше тук“. А това, за някого, който не е трябвало да знае, че е съществувало, е информация. В Урок 2 revisor реши, че услуга на друг екип отговаря с 404, също като такава, която не съществува, за да не може никой да изброи чуждото чрез сравняване на отговорите. Ако сега услуга 2 на екип a отговаряше с 410 на всеки, екип b би могъл да обходи идентификаторите и да научи кои са съществували в екип a и кога са били премахнати. Затова encontrar отговаря с 410 само на екипа собственик; на останалите отговаря със същия 404 както винаги:
$ curl -i http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 2
Същият 404, със същия текст като този за услуга 99. За екип b услуга 2 никога не е съществувала, а точно това екип b е имал право да знае. Ето функцията, която решава, в servicios.ts:
export function encontrar(id: string, credencial: Credencial, respuesta: ServerResponse): Servicio | undefined {
const servicio = buscarServicio(Number(id));
if (servicio !== undefined && delEquipo(servicio.equipo, credencial)) return servicio;
const equipo = equipoDeRetirado(Number(id));
if (servicio === undefined && equipo !== undefined && delEquipo(equipo, credencial)) {
fallar(respuesta, 410, `el servicio ${id} se retiró y no va a volver`);
} else {
fallar(respuesta, 404, `no existe el servicio ${id}`);
}
return undefined;
}
Една-единствена функция за проверка, промяна, премахване и искане на инспекция: така никой манипулатор не може да забрави сравнението на екипа, а 410 и 404 винаги идват от едни и същи два реда. „Грешката, която ще видиш“ показва какво става, ако се премахне delEquipo от второто условие.
3.7.3 410 печели пред условието
PUT с If-Match: * към премахната услуга. * означава „ако съществува“; услугата не съществува, така че условието би било невярно и бихме могли да очакваме 412:
$ curl -i -X PUT http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'If-Match: *' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 42
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 2 se retiró y no va a volver
Това е 410, заради правилото от 3.1.3: раздел 13.2.1 изисква предусловията да се пренебрегнат, ако без тях отговорът не би бил нито 2xx, нито 412. Без If-Match този PUT би получил 410; следователно условието дори не се оценява. И това е най-полезният отговор: 412 би пратил клиента да чете отново, а при повторното четене той така или иначе би получил 410.
Същото важи, когато премахването се случи по време на промяната, в пролуката от 3.3.4. Регистрира се услуга, efimero, и се запазват адресът и етикетът ѝ. После същата надпревара като преди, с друг съперник: докато бавен PUT чака тялото си, DELETE премахва услугата:
$ ID=$(curl -sS -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 -sS -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/5 ETAG="5-1-c4dd49c4"
$ ( sleep 1; printf '%s' '{"nombre":"efimero","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}' ) | curl -sS -w '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 -sS -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
el servicio 5 se retiró y no va a volver
lento: 410
DELETE пристигна в пролуката и отговори с 204. Когато бавният PUT най-накрая записа, cambiarServicio вече не намери услугата, върна "retirado" и отговорът беше 410, със същото тяло като на GET към премахната услуга: сякаш PUT е пристигнал след премахването, което всъщност му се е случило. 412 тук би пратил клиента да чете отново, само за да се натъкне на 410 при следващата заявка; с 410 той разбира от първия път.
3.8 Версии на API-то: обявеният 410
3.8.1 Извеждане на версия от употреба, без да изненадаш никого
Премахнатата услуга е малък случай на същия проблем, който има всяко API, което расте: извеждането от употреба на стара версия. revisor има три поколения маршрути:
/servicios: текущата версия./v1/servicios: предишната версия. Продължава да работи, но е отхвърлена (deprecated): говореше заdireccionвместо заurlи за ограничението в секунди ("limite": 3за 3000 ms) вместо в милисекунди./v0/…: първата версия, вече изведена от употреба. Всеки неин маршрут отговаря с410.
Ако някой ден /v1 премине от отговор 200 към отговор 410 без предупреждение, всеки клиент, който все още я използва, ще разбере това от грешка в продукция. Правилното е да се предупреди предварително, във всеки отговор на /v1, за да има онзи, който я използва, време да мигрира. За това има две заглавки и те нямат еднаква тежест:
Deprecation, от RFC 9745 (март 2025 г., предложен стандарт на IETF). Стойността ѝ е датата, на която ресурсът е станал или ще стане отхвърлен; може да е в миналото („стана отхвърлен на тази дата“) или в бъдещето („ще стане“). Датата е във формата на структурираните полета на HTTP:@, последвано от секундите от 1 януари 1970 г. (раздел 2.1).Sunset, от RFC 8594 (май 2019 г., информационен документ, а не стандарт). Стойността ѝ е датата, на която ресурсът вероятно ще спре да отговаря, в обичайния HTTP формат за дата (Sun, 31 Jan 2027 00:00:00 GMT). Публикуван е като индивидуално предложение на автора си, прегледано от IETF, но без да мине през работна група (страницата му в datatracker на IETF).
Това, че едната е предложен стандарт, а другата — информационен документ, не променя начина, по който се използват; променя доколко можеш да очакваш клиентите и инструментите да ги познават. И нито една от двете не задължава за нищо: те са предупреждения. RFC 9745 казва в раздел 5, че отхвърлянето на ресурс не променя поведението му: онзи, който го използва, може да продължи да го използва по същия начин. RFC 8594, в раздел 3, казва, че клиентите би трябвало да третират Sunset като подсказка: няма гаранция, че ресурсът ще работи до тази дата, нито че ще спре да работи след нея.
Към двете се добавя връзка: RFC 9745 регистрира релацията deprecation за заглавката Link (раздел 3), която сочи към документацията за отхвърлянето, например ръководството за миграция. Ето как отговаря /v1 на revisor:
$ curl -i http://127.0.0.1:18080/v1/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
Link: <https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","direccion":"http://127.0.0.1:18081/codigo/200","limite":3}
$ curl -i http://127.0.0.1:18080/v1/servicios -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
Link: <https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"
Content-Type: application/json
Content-Length: 104
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","direccion":"http://127.0.0.1:18081/codigo/200","limite":3}]}
Данните са тези на услуга 1, в стария формат: direccion вместо url и "limite":3, 3000-те ms, които остави „бързата“ заявка от надпреварата в 3.3.4, сега в секунди. А трите заглавки са във всички отговори на /v1, при проверката и при списъка: отхвърлянето се отнася за ресурса, в който се появява заглавката (раздел 2.2 на RFC 9745), така че всеки отхвърлен маршрут я носи.
3.8.2 Правилото за реда: Sunset никога преди Deprecation
Двете дати разказват история по ред: първо версията престава да се препоръчва (отхвърляне), а по-късно престава да отговаря (извеждане от употреба). Раздел 4 на RFC 9745 прави това правило с не трябва: датата на Sunset не трябва да е преди тази на Deprecation. Ако е (поради грешка в конфигурацията, казва RFC), онзи, който разработва клиента, би трябвало да се обърне към онзи, който управлява ресурса, за да се изясни.
Правилото има смисъл, макар и двете да са предупреждения. Извеждане от употреба преди отхвърлянето би казвало „тази версия спира да отговаря в понеделник и се отхвърля в петък“, което е противоречие: никой не би имал време да мигрира между едното предупреждение и другото, защото второто пристига, когато вече няма нищо. RFC 8594 обяснява това от другата страна в раздел 1.4: отхвърлянето обикновено става на два етапа, първо „вече не е препоръчителната версия“ и после „спира да се поддържа“, а Sunset служи само за втория.
В revisor /v1 е отхвърлена на 1 юли 2026 г. (@1782864000) и спира да отговаря на 31 януари 2027 г.: седем месеца резерв. Упражнение 4 те кара да го провериш с програма, което е и онова, което би трябвало да прави всеки клиент, който чете тези предупреждения.
3.8.3 И накрая — 410
Когато дойде датата, версията се извежда от употреба и започва да отговаря с 410. Това вече се е случило с /v0:
$ curl -i http://127.0.0.1:18080/v0/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios
$ curl -i http://127.0.0.1:18080/v0/servicios
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios
410 с каквито и да е идентификационни данни, а също и без никакви, за всеки маршрут, който започва с /v0. Обърни внимание, че въпросът „/v0 ли е?“ е във входната точка преди идентификационните данни, заедно със старите пренасочвания: изведена от употреба версия няма какво да защитава, а стар клиент, който дори не умее да се автентикира с текущата схема, заслужава да научи, че версията си е отишла, а не че му липсват идентификационни данни. Тялото казва кога е изведена от употреба и накъде да се отиде. Това е същото разсъждение като при 410 за услуга, в голям мащаб: клиентът не трябва да преглежда кода си в търсене на грешка в адреса; трябва да мигрира.
410 е краят на историята, а не началото. API, което извежда версия от употреба, като отговаря с 410 още първия ден, в който някой научава за извеждането ѝ, е направило нещата наобратно: 410 идва след месеци с Deprecation и Sunset във всеки отговор и с връзка към ръководството за миграция. Урок 8 документира тези предупреждения в описанието OpenAPI на API-то, с deprecated: true за онова, което се извежда от употреба.
3.9 Пълният revisor
3.9.1 Файловете, които се променят
revisor от този урок живее в папката ~/curso-http/fig03_01/. Ако имаш папката от Урок 2, копирай я с това име и промени четири файла: един нов, versiones.ts, и три променени, almacen.ts, rutas.ts и servicios.ts. Тук те са дадени изцяло.
versiones.ts е нов. Той съдържа двете стари версии: /v1, с трите ѝ предупреждения в AVISOS и стария ѝ начин на представяне на услуга, и /v0, която отговаря с 410 на всичко:
// fig03_01/versiones.ts — las versiones viejas de la API: /v1 está desaprobada y /v0 ya se retiró.
import type { ServerResponse } from "node:http";
import { listarServicios, type Servicio } from "./almacen.ts";
import { enviarJson, fallar, type Cabeceras } from "./respuestas.ts";
import { encontrar, type Contexto } from "./servicios.ts";
// Deprecation (RFC 9745): desde cuándo está desaprobada, como fecha de campo estructurado (@segundos).
// Sunset (RFC 8594): cuándo probablemente dejará de responder, como fecha HTTP. Nunca antes que Deprecation.
const AVISOS: Cabeceras = {
Deprecation: "@1782864000", // 1 de julio de 2026, 00:00 UTC
Sunset: "Sun, 31 Jan 2027 00:00:00 GMT",
Link: '<https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"',
};
// La v1 hablaba de «direccion» y del límite en segundos.
function representarV1(servicio: Servicio): object {
return { id: servicio.id, nombre: servicio.nombre, direccion: servicio.url, limite: servicio.limiteMs / 1000 };
}
export function listarV1({ respuesta, credencial }: Contexto): void {
const propios = listarServicios().filter((s) => credencial.equipo === "*" || s.equipo === credencial.equipo);
enviarJson(respuesta, 200, { servicios: propios.map(representarV1) }, AVISOS);
}
export function consultarV1({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
enviarJson(respuesta, 200, representarV1(servicio), AVISOS);
}
// La v0 se retiró: 410 para cualquier ruta suya, sin importar quién pregunte.
export function versionRetirada(ruta: string, respuesta: ServerResponse): boolean {
if (ruta !== "/v0" && !ruta.startsWith("/v0/")) return false;
fallar(respuesta, 410, "la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios");
return true;
}
В almacen.ts има три нови функции и една променена: retirarServicio вече не само изтрива, а и помни премахването в retirados, заедно с екипа; equipoDeRetirado го проверява; buscarPorNombre търси име в рамките на екип, за 409; а cambiarServicio(id, versionLeida, datos) сравнява версията и прилага промяната в една стъпка, без await (3.3.4): ако версията вече не е онази, която е прочетена, връща "conflicto"; ако е, записва и увеличава версията, което обезсилва стария етикет:
// fig03_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 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" });
В rutas.ts се променят три неща: маршрутът /servicios/{id} вече приема PUT (и затова Allow на 405 за този маршрут става GET, HEAD, PUT, DELETE); има два нови маршрута за /v1, само за четене; а преди търсенето на маршрута versionRetirada обслужва /v0:
// fig03_01/rutas.ts — la puerta de la API: qué ruta existe, qué método admite y quién puede usarlo.
import type { IncomingMessage, ServerResponse } from "node:http";
import { identificar, motivo, reto, type Credencial } from "./autenticacion.ts";
import { enviarVacio, fallar } from "./respuestas.ts";
import {
cambiar, consultar, consultarRevision, darDeAlta, listar, pedirRevision, retirar, 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: /^\/revisiones\/(\d+)$/, metodos: { GET: lee(consultarRevision) } },
// 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 (versionRetirada(url.pathname, respuesta)) return;
// 1. ¿Existe la ruta?
let ruta: Ruta | undefined;
let parametros: string[] = [];
for (const candidata of RUTAS) {
const encontrada = candidata.patron.exec(url.pathname);
if (encontrada !== null) {
ruta = candidata;
parametros = encontrada.slice(1);
break;
}
}
if (ruta === undefined) {
fallar(respuesta, 404, "no existe esa ruta");
return;
}
// 2. ¿Admite este método?
const nombreMetodo = peticion.method === "HEAD" ? "GET" : (peticion.method ?? "GET");
// Object.hasOwn y no ruta.metodos[nombreMetodo] a secas: el método lo escribe el cliente, y un objeto
// también «tiene» lo que hereda de Object.prototype (constructor, toString…). Igual que las credenciales.
const metodo = Object.hasOwn(ruta.metodos, nombreMetodo) ? ruta.metodos[nombreMetodo] : undefined;
if (metodo === undefined) {
fallar(respuesta, 405, `${peticion.method} no se admite en ${url.pathname}`, { Allow: permitidos(ruta) });
return;
}
// 3. ¿Quién eres?
const identificacion = identificar(peticion);
if (identificacion.tipo !== "valida") {
const detalle = motivo(identificacion);
fallar(respuesta, 401, detalle, { "WWW-Authenticate": reto(identificacion) });
return;
}
// 4. ¿Puedes?
if (!puede(identificacion.credencial, metodo)) {
fallar(respuesta, 403, "tu credencial solo puede leer; para escribir pide una con rol de escritor");
return;
}
// 5, 6 y 7. ¿Existe el recurso? ¿En qué formato viene? ¿Se puede leer? Lo decide cada manejador. Después, ¿se puede cumplir? (422)
await metodo.manejador({ peticion, respuesta, parametros, credencial: identificacion.credencial });
}
И servicios.ts, където е почти всичко от този урок: encontrar (3.7.2), validar и rechazarDatos (3.6.1), 409 в darDeAlta и cambiar (3.5.1), esJson, който отделя от четеца на JSON въпроса за формата, за да може PUT да го зададе преди условието (3.1.3), и cambiar с неговия 415, неговия 428, неговия 412 за стар етикет (3.3.3) и втория му 412, този от надпреварата (3.3.4), или неговия 410, ако услугата е премахната в пролуката (3.7.3). encontrar вече се експортира, защото versiones.ts също я използва. representar също остана експортирана, макар в този урок никой друг файл да не я импортира: /v1 има своя representarV1:
// fig03_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,
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 { revisar } from "./cliente.ts";
import { CuerpoDemasiadoGrande, enviarJson, enviarVacio, fallar, leerCuerpo, TOPE_CUERPO, type Cabeceras } from "./respuestas.ts";
export type Contexto = {
peticion: IncomingMessage;
respuesta: ServerResponse;
parametros: string[];
credencial: Credencial;
};
const ESPERA_EN_COLA_MS = 1000;
export function representar(servicio: Servicio): object {
return { id: servicio.id, nombre: servicio.nombre, url: servicio.url, limite_ms: servicio.limiteMs };
}
function representarRevision(revision: Revision): object {
return { id: revision.id, servicio: revision.servicio, estado: revision.estado, resultado: revision.resultado };
}
function delEquipo(equipo: string, credencial: Credencial): boolean {
return credencial.equipo === "*" || equipo === credencial.equipo;
}
// Busca un servicio que la credencial puede ver. Si no lo encuentra, ya respondió:
// 410 si existió y se retiró, 404 si nunca existió o es de otro equipo (el 404 esconde a propósito).
export function encontrar(id: string, credencial: Credencial, respuesta: ServerResponse): Servicio | undefined {
const servicio = buscarServicio(Number(id));
if (servicio !== undefined && delEquipo(servicio.equipo, credencial)) return servicio;
const equipo = equipoDeRetirado(Number(id));
if (servicio === undefined && equipo !== undefined && delEquipo(equipo, credencial)) {
fallar(respuesta, 410, `el servicio ${id} se retiró y no va a volver`);
} else {
fallar(respuesta, 404, `no existe el servicio ${id}`);
}
return undefined;
}
// 415 si el cuerpo no se declara application/json. Va aparte de leerJson porque el PUT lo pregunta
// antes que If-Match: el 415 se sabe sin procesar el contenido, y el RFC 9110 (sección 13.2.1) dice que
// las precondiciones no se evalúan si sin ellas la respuesta no habría sido 2xx ni 412.
function esJson(peticion: IncomingMessage, respuesta: ServerResponse): boolean {
const tipo = (peticion.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (tipo !== "application/json") {
fallar(respuesta, 415, `el cuerpo debe ser application/json, no ${tipo === "" ? "(sin tipo)" : tipo}`, {
Accept: "application/json",
});
return false;
}
return true;
}
// 415 si no es JSON, 400 si no se puede leer. Política del curso: 400 = no se puede leer.
async function leerJson(peticion: IncomingMessage, respuesta: ServerResponse): Promise<Record<string, unknown> | undefined> {
if (!esJson(peticion, respuesta)) return undefined;
try {
const datos: unknown = JSON.parse(await leerCuerpo(peticion));
if (typeof datos === "object" && datos !== null && !Array.isArray(datos)) return datos as Record<string, unknown>;
} catch (error) {
// 413 (RFC 9110, sección 15.5.14): el cuerpo pasa del tope. «Connection: close» hace que Node cierre
// la conexión al terminar de responder, en vez de seguir recibiendo lo que el cliente quería mandar.
if (error instanceof CuerpoDemasiadoGrande) {
fallar(respuesta, 413, `el cuerpo pasa de ${TOPE_CUERPO} bytes`, { Connection: "close" });
return undefined;
}
// si no, cae al 400 de abajo
}
fallar(respuesta, 400, "el cuerpo no es un objeto JSON");
return undefined;
}
export type Falla = { campo: string; detalle: string };
type Validos = { nombre: string; url: string; limiteMs: number };
// Política del curso: 422 = se pudo leer, pero no se puede cumplir. Junta todas las fallas, no solo la primera.
function validar(datos: Record<string, unknown>): Validos | Falla[] {
const fallas: Falla[] = [];
const { nombre, url, limite_ms: limite } = datos;
if (typeof nombre !== "string" || nombre.trim() === "" || nombre.length > 40) {
fallas.push({ campo: "nombre", detalle: "debe ser un texto de 1 a 40 caracteres" });
}
let urlValida = false;
if (typeof url === "string") {
try {
urlValida = ["http:", "https:"].includes(new URL(url).protocol);
} catch {
urlValida = false;
}
}
if (!urlValida) fallas.push({ campo: "url", detalle: "debe ser una dirección http o https" });
if (limite !== undefined && (typeof limite !== "number" || !Number.isInteger(limite) || limite < 100 || limite > 10000)) {
fallas.push({ campo: "limite_ms", detalle: "debe ser un entero entre 100 y 10000" });
}
if (fallas.length > 0) return fallas;
return { nombre: nombre as string, url: url as string, limiteMs: (limite as number | undefined) ?? 2000 };
}
export function rechazarDatos(respuesta: ServerResponse, fallas: Falla[]): void {
fallar(respuesta, 422, fallas.map((f) => `${f.campo}: ${f.detalle}`).join("\n"));
}
// SSRF: el revisor se conecta a la URL que alguien escribió. Se revisa al registrarla y otra vez
// antes de cada conexión (en cliente.ts). Si no se permite, ya respondió y devuelve false.
async function destinoPermitido(url: string, respuesta: ServerResponse): Promise<boolean> {
const veredicto = await revisarDestino(url);
if (veredicto.permitido) return true;
fallar(respuesta, 422, `destino prohibido: ${veredicto.motivo}`);
return false;
}
export function listar({ respuesta, credencial }: Contexto): void {
const propios = listarServicios().filter((s) => delEquipo(s.equipo, credencial));
enviarJson(respuesta, 200, { servicios: propios.map(representar) });
}
export async function darDeAlta({ peticion, respuesta, credencial }: Contexto): Promise<void> {
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
const validos = validar(datos);
if (Array.isArray(validos)) {
rechazarDatos(respuesta, validos);
return;
}
if (!(await destinoPermitido(validos.url, respuesta))) {
return;
}
const existente = buscarPorNombre(credencial.equipo, validos.nombre);
if (existente !== undefined) {
fallar(respuesta, 409, `ya existe un servicio llamado ${validos.nombre}: /servicios/${existente.id}`);
return;
}
const servicio = crearServicio({ ...validos, equipo: credencial.equipo });
enviarJson(respuesta, 201, representar(servicio), {
Location: `/servicios/${servicio.id}`,
ETag: etiqueta(servicio),
});
}
export function consultar({ peticion, respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const actual = etiqueta(servicio);
// If-None-Match trae «*» o una lista, y se compara en débil (RFC 9110, secciones 13.1.2 y 8.8.3.2).
const condicion = peticion.headers["if-none-match"];
if (condicion !== undefined && hayCoincidencia(condicion, actual, coincideDebil)) {
enviarVacio(respuesta, 304, { ETag: actual });
return;
}
enviarJson(respuesta, 200, representar(servicio), { ETag: actual });
}
// PUT: cambiar sin pisar el cambio de otra persona. Exige If-Match con la etiqueta vigente.
export async function cambiar({ peticion, respuesta, parametros, credencial }: Contexto): Promise<void> {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
// Primero el formato (415), después la condición (428 y 412), después la lectura (400).
if (!esJson(peticion, respuesta)) return;
const condicion = peticion.headers["if-match"];
if (condicion === undefined) {
fallar(respuesta, 428, "para cambiar un servicio manda If-Match con su ETag");
return;
}
// If-Match se compara en fuerte (RFC 9110, secciones 13.1.1 y 8.8.3.2): una etiqueta W/ nunca coincide.
// El detail no repite la cabecera que mandó el cliente: dice qué pasó y qué hacer.
if (!hayCoincidencia(condicion, etiqueta(servicio), coincideFuerte)) {
// Si solo coincide en débil, no es que el servicio haya cambiado: la etiqueta W/ no sirve para escribir.
const detalle = hayCoincidencia(condicion, etiqueta(servicio), coincideDebil)
? "If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó"
: "tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo";
fallar(respuesta, 412, detalle);
return;
}
// La versión que coincidió. Entre aquí y la escritura hay dos await (leer el cuerpo y consultar el DNS),
// y en ese hueco otra petición puede cambiar el servicio: cambiarServicio vuelve a comparar al escribir.
const versionLeida = interpretarLista(condicion) === "*" ? null : servicio.version;
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
const validos = validar(datos);
if (Array.isArray(validos)) {
rechazarDatos(respuesta, validos);
return;
}
if (!(await destinoPermitido(validos.url, respuesta))) {
return;
}
const homonimo = buscarPorNombre(servicio.equipo, validos.nombre);
if (homonimo !== undefined && homonimo.id !== servicio.id) {
fallar(respuesta, 409, `ya existe un servicio llamado ${validos.nombre}: /servicios/${homonimo.id}`);
return;
}
const cambiado = cambiarServicio(servicio.id, versionLeida, validos);
if (cambiado === "conflicto") {
fallar(respuesta, 412, "el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo");
return;
}
if (cambiado === "retirado") {
// Otra petición lo retiró mientras ésta esperaba: 410, como si hubiera llegado después del retiro.
fallar(respuesta, 410, `el servicio ${servicio.id} se retiró y no va a volver`);
return;
}
enviarJson(respuesta, 200, representar(cambiado), { ETag: etiqueta(cambiado) });
}
export function retirar({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
retirarServicio(servicio.id);
enviarVacio(respuesta, 204);
}
export function pedirRevision({ respuesta, parametros, credencial }: Contexto): void {
const servicio = encontrar(parametros[0], credencial, respuesta);
if (servicio === undefined) return;
const revision = crearRevision(servicio.id);
setTimeout(async () => {
terminarRevision(revision.id, await revisar(servicio.url, servicio.limiteMs));
}, ESPERA_EN_COLA_MS);
enviarJson(respuesta, 202, representarRevision(revision), { Location: `/revisiones/${revision.id}` });
}
export function consultarRevision({ respuesta, parametros, credencial }: Contexto): void {
const revision = buscarRevision(Number(parametros[0]));
const servicio = revision === undefined ? undefined : buscarServicio(revision.servicio);
const equipo = servicio?.equipo ?? (revision === undefined ? undefined : equipoDeRetirado(revision.servicio));
if (revision === undefined || equipo === undefined || !delEquipo(equipo, credencial)) {
fallar(respuesta, 404, `no existe la revisión ${parametros[0]}`);
return;
}
const cabeceras: Cabeceras = revision.estado === "pendiente" ? { "Retry-After": "1" } : {};
enviarJson(respuesta, 200, representarRevision(revision), cabeceras);
}
Обърни внимание на реда в darDeAlta и в cambiar: първо validar (422), после дестинацията (също 422) и накрая повтореното име (409). Правилата за данните са преди сблъсъка със състоянието: няма смисъл да се търси дали вече съществува услуга с име, което така или иначе не е валидно.
3.9.2 Файловете, които не се променят
Останалите файлове са тези от Урок 2, без нито една промяна. Дадени са изцяло, за да може revisor да се сглоби от този урок. etiquetas.ts е онзи, който прави силното сравнение, използвано от cambiar:
// fig03_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));
}
// fig03_01/respuestas.ts — las tres formas de responder del revisor y la lectura del cuerpo.
import type { IncomingMessage, ServerResponse } from "node:http";
export type Cabeceras = Record<string, string>;
// Node trae los nombres viejos de estos dos códigos; aquí van los del registro de la IANA.
const FRASES: Partial<Record<number, string>> = { 413: "Content Too Large", 422: "Unprocessable Content" };
function escribirCabecera(respuesta: ServerResponse, codigo: number, cabeceras: Cabeceras): void {
const frase = FRASES[codigo];
if (frase === undefined) respuesta.writeHead(codigo, cabeceras);
else respuesta.writeHead(codigo, frase, cabeceras);
}
export function enviarJson(respuesta: ServerResponse, codigo: number, datos: unknown, cabeceras: Cabeceras = {}): void {
const cuerpo = JSON.stringify(datos) + "\n";
escribirCabecera(respuesta, codigo, {
...cabeceras,
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
// Para 204, 304 y las redirecciones: código y cabeceras, sin cuerpo.
export function enviarVacio(respuesta: ServerResponse, codigo: number, cabeceras: Cabeceras = {}): void {
// 204 y 304 no pueden llevar Content-Length de un cuerpo; los demás dicen «cero bytes».
const largo: Cabeceras = codigo === 204 || codigo === 304 ? {} : { "Content-Length": "0" };
escribirCabecera(respuesta, codigo, { ...cabeceras, ...largo });
respuesta.end();
}
// Un error: por ahora, una línea de texto. La lección 5 lo cambia por Problem Details.
export function fallar(respuesta: ServerResponse, codigo: number, detalle: string, cabeceras: Cabeceras = {}): void {
const cuerpo = detalle + "\n";
escribirCabecera(respuesta, codigo, {
...cabeceras,
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
// Tope del cuerpo: 16 KiB. Un servicio es un nombre de hasta 40 caracteres, una URL y un número; cabe
// de sobra. Sin tope, leerCuerpo guardaba en memoria todo lo que llegara: un POST de 50 MB subía la
// memoria del revisor unos 150 MB, y cualquiera con la dirección podía llenarla.
export const TOPE_CUERPO = 16 * 1024;
// La señal de que el cuerpo pasó del tope. Quien llama responde 413 (RFC 9110, sección 15.5.14).
export class CuerpoDemasiadoGrande extends Error {}
// Cuando el cuerpo ya pasó del tope, el cliente puede seguir mandando. Si el servidor responde el 413 y destruye la
// conexión en ese momento, lo que todavía llega a una conexión cerrada se contesta con un RST del sistema operativo,
// y ese RST puede borrar el 413 antes de que el cliente lo lea: el cliente ve EPIPE o ECONNRESET en vez de la
// respuesta. El RFC 9112, sección 9.6, aconseja un cierre en etapas: responder, cerrar solo la escritura (media cerrada),
// seguir leyendo y tirando lo que llegue hasta que el cliente cierre o pase un plazo (el RFC no fija ninguno; el de 2 s es del curso), y cerrar del todo.
// Node, con «Connection: close», llama a socket.destroySoon() al terminar de responder, que cierra y destruye; aquí
// se cambia ese método, solo en esta conexión, para que únicamente cierre la escritura.
const ESPERA_CIERRE_MS = 2000;
function cerrarConCalma(peticion: IncomingMessage): void {
const socket = peticion.socket;
socket.destroySoon = (): void => {
socket.end();
};
peticion.resume();
const reloj = setTimeout(() => socket.destroy(), ESPERA_CIERRE_MS);
socket.once("close", () => clearTimeout(reloj));
}
// Lee el cuerpo contando los bytes. Si Content-Length ya anuncia más del tope, rechaza sin guardar nada;
// si no lo anuncia (cuerpo por partes) o miente, deja de guardar en cuanto se pasa. En los dos casos no
// guarda más de TOPE_CUERPO bytes. No se destruye la petición, porque con ella se iría la conexión y
// no se podría responder: se prepara el cierre en etapas (ver arriba) y el 413 sale de inmediato, con
// «Connection: close».
export function leerCuerpo(peticion: IncomingMessage): Promise<string> {
return new Promise((resolver, rechazar) => {
const demasiado = (): void => {
cerrarConCalma(peticion);
rechazar(new CuerpoDemasiadoGrande(`el cuerpo pasa de ${TOPE_CUERPO} bytes`));
};
if (Number(peticion.headers["content-length"] ?? 0) > TOPE_CUERPO) {
demasiado();
return;
}
const trozos: Buffer[] = [];
let total = 0;
const alLlegar = (trozo: Buffer): void => {
total += trozo.length;
if (total > TOPE_CUERPO) {
peticion.off("data", alLlegar);
peticion.off("end", alTerminar);
demasiado();
return;
}
trozos.push(trozo);
};
const alTerminar = (): void => resolver(Buffer.concat(trozos).toString("utf8"));
peticion.on("data", alLlegar);
peticion.on("end", alTerminar);
peticion.on("error", rechazar);
});
}
// fig03_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"';
}
// fig03_01/servidor.ts — la cara servidor del revisor: la API que administra los servicios observados.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { fallar } from "./respuestas.ts";
import { atender } from "./rutas.ts";
const PUERTO = 18080;
// Red de seguridad: un error que se escape de un manejador no debe tumbar el proceso. Sin este catch,
// la promesa rechazada no la espera nadie y Node termina el revisor para todos. Se responde 500 sin
// pila y el mensaje queda en la terminal. La lección 4 explica el 500 y le agrega una referencia.
async function atenderSinCaerse(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
try {
await atender(peticion, respuesta);
} catch (error) {
console.error(`error en ${peticion.method} ${peticion.url}: ${error instanceof Error ? error.message : String(error)}`);
if (!respuesta.headersSent) fallar(respuesta, 500, "error interno");
else respuesta.destroy();
}
}
createServer((peticion, respuesta) => void atenderSinCaerse(peticion, respuesta)).listen(PUERTO, "127.0.0.1", () => {
console.log(`revisor en http://127.0.0.1:${PUERTO}`);
});
// fig03_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";
export async function revisar(url: string, limiteMs: number): Promise<Resultado> {
// 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 { estado: "CAIDO", codigo: null, ms: null, detalle: `destino prohibido: ${veredicto.motivo}` };
const inicio = performance.now();
try {
const respuesta = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(limiteMs) });
await respuesta.body?.cancel();
const ms = Math.round(performance.now() - inicio);
const detalle = String(respuesta.status);
if (respuesta.status >= 400) return { estado: "CAIDO", codigo: respuesta.status, ms, detalle };
return { estado: ms >= limiteMs / 2 ? "LENTO" : "ARRIBA", codigo: respuesta.status, ms, detalle };
} catch (error) {
const agotado = error instanceof DOMException && error.name === "TimeoutError";
return { estado: "CAIDO", codigo: null, ms: null, detalle: agotado ? "tiempo agotado" : "sin conexión" };
}
}
// fig03_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 };
}
// fig03_01/laboratorio.ts
// El banco de pruebas de la lección 0, en el puerto 18081: los servicios que el revisor consulta.
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}`);
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`);
});
{
"type": "module"
}
3.9.3 Пълното изпълнение
Отвори два терминала в ~/curso-http/fig03_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
В трети терминал това са всички заявки от раздели 3.2 до 3.8, в реда, в който се появиха, с отговорите им. Събрани са заедно, за да ги сравниш наведнъж със своите; помни, че етикетите ще завършват различно:
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 200 OK
ETag: "1-1-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ ETAG=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-1-c4dd49c4"
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-2-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/500","limite_ms":2000}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 115
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
$ VIGENTE=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "VIGENTE=$VIGENTE"
VIGENTE="1-2-c4dd49c4"
$ 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/$VIGENTE" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 109
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
If-Match se compara en fuerte: una etiqueta débil (W/) no sirve para cambiar; manda la ETag tal como llegó
$ 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\", $VIGENTE" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 200 OK
ETag: "1-3-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 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-4-c4dd49c4"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 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 -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ETAG=$ETAG"
ETAG="1-4-c4dd49c4"
$ ( sleep 1; printf '%s' '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1000}' ) | curl -sS -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 -sS -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
el servicio cambió mientras se atendía tu petición; vuelve a leerlo antes de cambiarlo
lenta: 412
$ curl -sS 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 -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","limite_ms":1500}'
HTTP/1.1 428 Precondition Required
Content-Type: text/plain; charset=utf-8
Content-Length: 52
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
para cambiar un servicio manda If-Match con su ETag
$ 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:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya existe un servicio llamado pagos: /servicios/2
$ 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":"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:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya existe un servicio llamado pagos: /servicios/2
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-b' -H 'Content-Type: application/json' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/4
ETag: "4-1-c4dd49c4"
Content-Type: application/json
Content-Length: 85
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":4,"nombre":"pagos","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":"","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:29 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 '["inventario"]'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"avisos","url":"http://127.0.0.1:18081/codigo/200","limite_ms":"2000"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 48
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
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":"metadatos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 70
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
destino prohibido: 169.254.169.254:80: dirección privada o reservada
$ 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: text/plain; charset=utf-8
Content-Length: 75
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
destino prohibido: localhost resuelve a una dirección privada o reservada
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 42
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 2 se retiró y no va a volver
$ 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:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
$ curl -i http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 2
$ curl -i -X PUT http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -H 'If-Match: *' -d '{"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 42
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 2 se retiró y no va a volver
$ ID=$(curl -sS -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 -sS -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/5 ETAG="5-1-c4dd49c4"
$ ( sleep 1; printf '%s' '{"nombre":"efimero","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3000}' ) | curl -sS -w '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 -sS -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
el servicio 5 se retiró y no va a volver
lento: 410
$ curl -i http://127.0.0.1:18080/v1/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
Link: <https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","direccion":"http://127.0.0.1:18081/codigo/200","limite":3}
$ curl -i http://127.0.0.1:18080/v1/servicios -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Deprecation: @1782864000
Sunset: Sun, 31 Jan 2027 00:00:00 GMT
Link: <https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"
Content-Type: application/json
Content-Length: 104
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","direccion":"http://127.0.0.1:18081/codigo/200","limite":3}]}
$ curl -i http://127.0.0.1:18080/v0/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios
$ curl -i http://127.0.0.1:18080/v0/servicios
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios
Трийсет и един реда. Двайсет и четирите пълни отговора, тези от curl -i, дадоха [200 200 412 412 200 200 428 409 409 201 422 400 422 422 422 204 410 404 404 410 200 200 410 410]; надпреварата от 3.3.4 даде rápida: 200 и lenta: 412; тази от 3.7.3 — DELETE en el hueco: 204 и lento: 410; а петте реда, които запазват етикет (ETAG два пъти, VIGENTE и ID с ETAG в 3.7.3) или четат услугата без -i, не отпечатват кода. Всяка грешка в този урок дойде след като заявката беше минала седемте въпроса на входната точка от Урок 2: всички носеха правилни маршрут, метод, идентификационни данни и разрешение и въпреки това не можеха да бъдат изпълнени.
Терминалът на revisor записа всяка заявка, в същия ред:
revisor en http://127.0.0.1:18080
GET /servicios/1
GET /servicios/1
PUT /servicios/1
PUT /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
POST /servicios
PUT /servicios/1
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
DELETE /servicios/2
GET /servicios/2
GET /servicios/99
GET /servicios/2
PUT /servicios/2
POST /servicios
GET /servicios/5
PUT /servicios/5
DELETE /servicios/5
GET /v1/servicios/1
GET /v1/servicios
GET /v0/servicios/1
GET /v0/servicios
Грешката, която ще видиш
Двата 200, които не трябваше да излязат
Двете най-важни счупени версии в този урок вече ги предизвика в основната част на урока, защото те са неговото сърце: PUT, който не пита (3.2.2), и този, който пита само веднъж (3.3.4). И двете се провалят по един и същи начин и си струва да се каже точно какво не е наред в отговора, защото то не се вижда в нито една заглавка: това е 200, който обещава нещо невярно. 200 на PUT казва на клиента „промяната ти е приложена върху онова, което си прочел“. И при двата дефекта вторият 200 е приложен върху нещо, което клиентът му никога не е виждал, и е изтрил чужда промяна. Поправката също е една и съща и в двата случая, в две части: да се изисква и сравнява условието преди четенето на тялото (428 и 412) и версията да се сравнява отново в същата стъпка, в която се записва (отново 412). Липсва ли едната от двете, сцената от началото се връща.
410, който издава
Вторият дефект е по-фин, защото се ражда от желанието нещата да се направят добре. Някой чете раздел 15.5.11, решава, че всяка премахната услуга заслужава своя 410, и опростява условието в encontrar, като премахва сравнението на екипа (пълната версия е в defectos/410-para-todos/servicios.ts):
if (servicio === undefined && equipo !== undefined) {
fallar(respuesta, 410, `el servicio ${id} se retiró y no va a volver`);
Серия E: създай rota/ от нулата (rm -r rota && cp -R fig03_01 rota), сложи тази версия в rota/servicios.ts и стартирай лабораторията и revisor от rota/. Екип a премахва своята услуга 2, а екип b пита за 2 и за 99:
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:18:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/2 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 42
Date: Wed, 21 Oct 2026 13:18:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 2 se retiró y no va a volver
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 25
Date: Wed, 21 Oct 2026 13:18:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
Екип b никога не е имал достъп до услуга 2, но сега знае, че е съществувала и че вече не съществува; и знае, че 99 никога не е съществувала. Това е изброяването от карето в Урок 2, през нова входна точка: два различни отговора за два чужди идентификатора. Със скрипт като програмата за изброяване от Урок 2 екип b би могъл да обходи идентификаторите и да разбере колко услуги е премахнал всеки екип. Поправката е да се върне в условието неговият delEquipo(equipo, credencial): 410 е за онзи, който е можел да вижда ресурса, когато е съществувал, а за останалите — същият 404 както винаги.
Какво се прави погрешно
400 за всичко, което не може да бъде изпълнено. Клиентът не може да различи „JSON-ът ти е счупен“ от „името ти се повтаря“ от „писал си върху стара версия“, а всяко от тях се поправя различно. Ако API-то реши да използва 400 и за валидацията, както препоръчват някои ръководства, нека поне конфликтът (409) и предусловието (412) имат свой код.
PUT без условие върху споделен ресурс. Това е изгубена актуализация, която чака да се случи. Не се проваля при тестовете, защото тестовете почти никога нямат двама редактори едновременно; проваля се в продукция, безшумно, с по един 200 за всеки.
Сравняване на версията само в началото. Ако между сравнението и записването има нещо, което чака (четене на тялото, питане на DNS, извикване на друга услуга), друга заявка със същия етикет също минава, а последната, която запише, презаписва другата с 200. Версията се сравнява отново в същата стъпка, в която се записва, като UPDATE … WHERE version = N.
Сравняване на If-Match слабо или като текст. При слабо сравнение W/"1-2-…" би минал за валиден, а спецификацията го забранява с трябва. Като текст списък "9-9", "1-2-…" или * биха дали 412, макар условието да е изпълнено.
409, когато If-Match не е изпълнено. Клиентът е поставил условие в заглавка и то не е изпълнено: това е 412. 409 е за сблъсъка със състоянието, който клиентът не е изразил като условие, или за случая, когато версията пътува в тялото.
412, когато If-Match липсва. Без условие няма нищо, което да не е изпълнено. Клиентът, който получи 412, смята, че някой е променил ресурса, и чете отново в търсене на чуждата промяна; с 428 знае, че просто е пропуснал да изпрати етикета.
409, който не казва с какво се сблъсква. Раздел 15.5.10 изисква тялото да позволява да се разпознае източникът на конфликта. Само „конфликт“ оставя клиента да гадае кое поле да промени.
Изпращане само на първото нарушение при валидацията. Три обиколки за поправяне на три полета, когато 422 с пълния списък би го решил с една.
410 за всички или 404 завинаги. Първото издава онова, което е съществувало, на някой, който не е можел да го вижда; второто кара собственика на ресурс, премахнат нарочно, да търси грешка в адреса.
Извеждане на версия от употреба без предупреждение или с предупреждения в обратен ред. 410 без предхождащи месеци с Deprecation и Sunset се открива в продукция. А Sunset преди Deprecation нарушава раздел 4 на RFC 9745 и не дава на никого време да мигрира.
Представяне на Deprecation или Sunset като задължения. Те са предупреждения, които клиентът може да прочете; отхвърленият ресурс се държи както преди, а датата на Sunset е подсказка. Добре направеният клиент ги записва и уведомява онзи, който го поддържа; не спира да работи, защото ги е видял.
Упражнения
И четирите се проверяват сами: всяко завършва с изпълнение, което ти казва дали си познал. Запиши своята прогноза в дневника преди да изпълниш каквото и да е. Те са серия F: за всички ти трябват работещи лаборатория и revisor от 3.9, току-що стартирани, а четирите се изпълняват по ред, едно след друго, без рестартиране между тях, освен там, където упражнението го изисква.
Упражнение 1 — Конфликт, правило или предусловие?
Без да изпълняваш нищо, предвиди кода на всяка от тези девет заявки и за всяка грешка кажи дали е конфликт на състоянието, бизнес правило, предусловие или нещо, което входната точка от Урок 2 или раздел 3.7 вече решаваше. Името във втората има 41 букви „x“:
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200"}'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","url":"http://127.0.0.1:18081/codigo/200"}'
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: "1-1-00000000"' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200"}'
curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'catalogo'
curl -i -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'
curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-a'
curl -i http://127.0.0.1:18080/v0/servicios
curl -i -X DELETE http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'
curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-a'
Четвъртата и петата имат капан: всяка носи два проблема. Кой се проверява първо?
Упражнение 2 — Етикетът, който не оцеля след рестартирането
Запази етикета на услуга 1 в променлива, спри revisor и го стартирай отново (Ctrl+C и node servidor.ts) и изпрати промяна с предишния етикет:
ANTES=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ANTES=$ANTES"
# aquí detienes y vuelves a arrancar el revisor
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: $ANTES" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
Предвиди кода. После отговори на два въпроса. Никой не е променял услуга 1 между четенето и записването: защо е правилно revisor да откаже? И какво лошо би имало в тяло, което казва само „някой е променил услугата“?
Упражнение 3 — Два проблема в една заявка
Без рестартиране запази актуалния етикет на услуга 1 в AHORA и изпрати две промени със същото невалидно тяло: едната с измислен етикет, а другата с актуалния. Накрая прочети етикета отново:
AHORA=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "AHORA=$AHORA"
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: "1-7-00000000"' -d '{"nombre":"","url":"ftp://archivos"}'
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: $AHORA" -d '{"nombre":"","url":"ftp://archivos"}'
curl -sS -o /dev/null -w '%header{etag}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
Предвиди двата кода и дали етикетът накрая е същият като AHORA. Защо първият не споменава празното име?
Упражнение 4 — Прочети предупрежденията с програма
Напиши програма, fig03_03.ts, която иска маршрут от revisor с lectura-a и казва: от кога е отхвърлен, кога спира да отговаря, къде да се прочете повече и дали двете дати са в реда, който изисква раздел 4 на RFC 9745. Ако маршрутът не носи предупреждения, нека го каже. Изпробвай я с /v1/servicios и с /servicios. Подсказка: стойността на Deprecation е @, последвано от секунди, а new Date(texto) на JavaScript разбира формата на Sunset.
Решения
Решение 1
$ curl -i -X POST http://127.0.0.1:18080/servicios -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 409 Conflict
Content-Type: text/plain; charset=utf-8
Content-Length: 53
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
ya existe un servicio llamado catalogo: /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":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 47
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
nombre: debe ser un texto de 1 a 40 caracteres
$ 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: "1-1-00000000"' -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 115
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
$ curl -i -X PUT http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'catalogo'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 51
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no text/plain
$ curl -i -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'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 51
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no text/plain
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 3
$ curl -i http://127.0.0.1:18080/v0/servicios
HTTP/1.1 410 Gone
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la versión 0 de la API se retiró el 31 de enero de 2026; usa /servicios
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 3
По ред:
409, конфликт на състоянието.catalogoе валидно име; в екип a вече има такова.422, бизнес правило. Името има 41 знака; правилото казва от 1 до 40. Никое състояние на сървъра не би го направило валидно.412, предусловие. Условието не съвпада с актуалния етикет ("1-1-00000000"не е твоят; твоят завършва с осемте цифри от това стартиране).415, а не428. Липсват две неща, форматът и условието. Форматът се разбира от заглавка,Content-Type, без да се обработва тялото, а раздел 13.2.1 изисква онова, което се открива преди обработката на съдържанието, да печели пред предусловията. Ако клиентът поправиContent-Type, следващият отговор ще бъде428.415, а не412. Сега условието го има и освен това е старо ("1-0-0"не е никоя версия на услугата). Няма значение: грешният формат се разбира първо и с негоrevisorдори не оценяваIf-Match. Ако клиентът поправиContent-Type, следващият отговор ще бъде412.404, входната точка от Урок 2. Услуга 3 е на екип b; за екип a тя не съществува.410, изведена от употреба версия. Без идентификационни данни:/v0се обслужва, преди да се попита кой си.204. Екип b премахва своята услуга 3.404, а не410. Услуга 3 е премахната, но беше на екип b. За екип a тя никога не е съществувала и продължава да не съществува:410е само за собственика.
Решение 2
$ ANTES=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "ANTES=$ANTES"
ANTES="1-1-caba5fd2"
$ 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: $ANTES" -d '{"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":1500}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 115
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
412, и това е правилно: етикетът, който изпраща клиентът, не е актуалният. След рестартирането услуга 1 има етикет "1-1-…" с други осем цифри, защото revisor генерира нов идентификатор при всяко стартиране. Спецификацията не изисква от сървъра да знае защо етикетът не съвпада; изисква от него да не изпълнява метода, ако не съвпада. А действието, което кодът посочва на клиента, да прочете отново и да направи промяната си наново върху онова, което намери, е правилното: revisor, който е обслужил четенето му, вече не съществува, както и онова, което клиентът е смятал, че знае за него. В сървър с база данни услугата би оцеляла след рестартирането, но в този, който пази всичко в паметта, нищо не гарантира, че сегашната услуга 1 е същата като предишната.
Тяло, което казва само „някой е променил услугата“, би било невярно в този случай: никой не я е пипал. Затова revisor казва двете възможни причини, „услугата се е променила или revisor е рестартиран“, и действието — „прочети я отново, преди да я промениш“. Кодът решава какво да се прави; тялото, ако го прочете човек, не го праща да търси промяна, която не съществува. За да провериш, че новият етикет наистина работи, прочети го и го използвай: това е първото, което прави упражнение 3.
Решение 3
$ AHORA=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'); echo "AHORA=$AHORA"
AHORA="1-1-e35ad556"
$ 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: "1-7-00000000"' -d '{"nombre":"","url":"ftp://archivos"}'
HTTP/1.1 412 Precondition Failed
Content-Type: text/plain; charset=utf-8
Content-Length: 115
Date: Wed, 21 Oct 2026 13:18:35 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu etiqueta ya no es la vigente: el servicio cambió o el revisor se reinició; vuelve a leerlo antes de cambiarlo
$ 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: $AHORA" -d '{"nombre":"","url":"ftp://archivos"}'
HTTP/1.1 422 Unprocessable Content
Content-Type: text/plain; charset=utf-8
Content-Length: 89
Date: Wed, 21 Oct 2026 13:18:35 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
$ curl -sS -o /dev/null -w '%header{etag}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
"1-1-e35ad556"
Първият е 412: условието се оценява преди четенето на тялото (раздел 13.2.1) и тъй като не е изпълнено, тялото дори не се проверява. Затова не споменава празното име: да се каже на клиента, че името му е грешно, не му помага с нищо, ако онова, което трябва да направи първо, е да прочете отново; когато прочете отново, може би услугата вече има друго име и цялата му промяна ще е друга. Вторият е 422: условието е изпълнено, тялото се прочита и двете нарушения се появяват заедно, както изисква политиката от 3.6.1. А етикетът накрая е същият като AHORA: нито един от двата PUT не промени нищо, така че версията не се увеличи. Отхвърлена промяна не изразходва етикета; клиентът може да поправи данните си и да го изпрати отново.
Решение 4
// fig03_03.ts
// Lee los avisos de retiro de una ruta: Deprecation (RFC 9745) y Sunset (RFC 8594).
// Uso: node fig03_03.ts /v1/servicios
const ruta = process.argv[2] ?? "/v1/servicios";
const respuesta = await fetch(`http://127.0.0.1:18080${ruta}`, {
headers: { Authorization: "Bearer lectura-a" },
});
await respuesta.body?.cancel();
const desaprobacion = respuesta.headers.get("deprecation"); // "@segundos": fecha de campo estructurado
const retiro = respuesta.headers.get("sunset"); // fecha HTTP: "Sun, 31 Jan 2027 00:00:00 GMT"
console.log(`${ruta}: ${respuesta.status}`);
if (desaprobacion === null && retiro === null) {
console.log("sin avisos: la ruta no anuncia que se vaya a retirar");
} else {
let desde: Date | undefined;
if (desaprobacion !== null) {
const segundos = /^@(-?\d+)$/.exec(desaprobacion);
if (segundos === null) console.log(`Deprecation no es una fecha válida: ${desaprobacion}`);
else desde = new Date(Number(segundos[1]) * 1000);
}
let hasta: Date | undefined;
if (retiro !== null) {
hasta = new Date(retiro);
if (Number.isNaN(hasta.getTime())) {
console.log(`Sunset no es una fecha válida: ${retiro}`);
hasta = undefined;
}
}
console.log(`desaprobada desde: ${desde?.toISOString() ?? "(no lo dice)"}`);
console.log(`deja de responder: ${hasta?.toISOString() ?? "(no lo dice)"}`);
const enlace = respuesta.headers.get("link");
if (enlace !== null) console.log(`dónde leer más: ${enlace}`);
if (desde !== undefined && hasta !== undefined) {
// RFC 9745, sección 4: la fecha de Sunset no puede ser anterior a la de Deprecation.
console.log(hasta.getTime() >= desde.getTime() ? "orden correcto: primero se desaprueba, luego se retira" : "AVISO MAL ARMADO: el retiro es anterior a la desaprobación");
}
}
$ node fig03_03.ts /v1/servicios
/v1/servicios: 200
desaprobada desde: 2026-07-01T00:00:00.000Z
deja de responder: 2027-01-31T00:00:00.000Z
dónde leer más: <https://revisor.example/docs/migrar-de-v1>; rel="deprecation"; type="text/html"
orden correcto: primero se desaprueba, luego se retira
$ node fig03_03.ts /servicios
/servicios: 200
sin avisos: la ruta no anuncia que se vaya a retirar
await respuesta.body?.cancel() изхвърля тялото, без да го чете, защото програмата се нуждае само от заглавките. Регулярният израз ^@(-?\d+)$ приема точно формата за дата на структурираните полета; всичко друго се отчита като невалидно, вместо да се превърне в произволна дата. Със Sunset става същото, и то по по-фина причина: new Date("proximo-mes") не хвърля никаква грешка, а създава невалидна дата, и toISOString() е тази, която после хвърля RangeError: Invalid time value и срива програмата. Затова програмата проверява Number.isNaN(hasta.getTime()), преди да я използва. Изпробвана срещу сървър играчка, който отговаряше със Sunset: proximo-mes, програмата не се срива: отпечатва Sunset no es una fecha válida: proximo-mes, после desaprobada desde: 2026-07-01T00:00:00.000Z и deja de responder: (no lo dice) и завършва с код 0. А последното сравнение е правилото от раздел 4 на RFC 9745: ако някой ден конфигурацията на /v1 сложи Sunset преди Deprecation, програмата го казва, вместо да мълчи. Истински клиент би направил с това нещо по-полезно от отпечатването: би го записал в дневника си и би уведомил онзи, който го поддържа, веднъж, а не при всяка заявка.
Как разбирам, че съм успял
- За неуспешна заявка казваш дали е конфликт на състоянието, бизнес правило или предусловие и отговаряш с
409,422или412с въпроса, който решава. -
node fig03_02.tsсрещуrevisorот 3.9 завършва с „el cambio de Ana sigue ahí“, а срещуrota/без условието — със „SE PERDIÓ el cambio de Ana“. - Надпреварата от 3.3.4 дава
rápida: 200иlenta: 412и оставя"limite_ms":3000срещуrevisorот 3.9, и200и200с"limite_ms":1000срещу копието, което подаваnullнаcambiarServicio. -
PUTкъм/servicios/1безIf-Matchдава428с тяло, което казва коя заглавка да се изпрати; със стар етикет дава412; сW/пред актуалния дава412с предупреждението, че е слаб; със списък, който го съдържа, или с*дава200и новETag. -
PUTсContent-Type: text/plainдава415, със стар етикет или без никакъв. - Регистрация на
pagosсescritura-aдава409и казва с коя услуга се сблъсква; същата заявка сescritura-bдава201. - Регистрация с
{"nombre":"","url":"ftp://archivos","limite_ms":5}дава422с три реда, а такава с["inventario"]дава400. - След премахване на услуга 2 екип a получава
410, а екип b получава404, със същото тяло като за услуга, която никога не е съществувала. -
/v1/servicios/1носиDeprecation,SunsetиLink, аnode fig03_03.ts /v1/serviciosказва, че редът е правилен;/v0/serviciosдава410без идентификационни данни. - При завършване не остава програма, която слуша:
curl -i http://127.0.0.1:18080/дава грешка 7.
Обобщение
Отговори, без да гледаш урока:
- Кой въпрос решава между
409,412и422? Дай пример отrevisorза всеки. - Какво е изгубената актуализация и в коя от четирите стъпки се случва?
- Ако
revisorвече е сравнилIf-Matchв началото, защо сравнява версията отново при записването? Какъв е еквивалентът в база данни? - Защо
If-Matchсе сравнява силно и с какво отговаряrevisorна етикетW/, който при слабо сравнение би съвпаднал? - Какво трябва да направи клиентът след
412и какво — след428? Защо не е едно и също? - Защо условието се оценява преди четенето на тялото? Кой раздел на RFC 9110 го казва?
- Кога на изгубена актуализация се отговаря с
409, а не с412? - Каква е политиката на курса за
400срещу422и какво решават други ръководства? - Какво казва
410, което404не казва? Защоrevisorне го казва на всички? - Каква дата дава
Deprecation, каква даваSunset, каква тежест има всеки RFC и какво правило свързва двете дати?
Източници
Всички бяха консултирани на 21 октомври 2026 г.
- RFC 9110, HTTP Semantics, раздел 15.5.10:
409, конфликтът с текущото състояние, който потребителят може да разреши, съдържанието, което позволява да се разпознае източникът му, и примерът с версии приPUT. - RFC 9110, раздел 15.5.11:
410, вероятно постоянното състояние, умишленото премахване, кеширането по евристика и свободата да не се отбелязва завинаги. - RFC 9110, раздел 15.5.5:
404не казва дали липсата е временна или постоянна и се предпочита410, когато сървърът знае, че е постоянна. - RFC 9110, раздел 15.5.13:
412, условие от заглавките, което се е оказало невярно. - RFC 9110, раздел 13.1.1:
If-Match, формата му (*или списък), задължителното силно сравнение, трите стъпки на оценяване и изгубената актуализация (lost update). - RFC 9110, раздели 13.2.1 и 13.2.2: кога се оценяват предусловията (преди обработката на съдържанието, след обичайните проверки) и в какъв ред.
- RFC 9110, раздел 8.8.3.2: силното и слабото сравнение на етикети, с таблицата им.
- RFC 9110, раздел 15.5.21:
422, съдържание, което се разбира и има правилен синтаксис, но не може да бъде обработено; и раздел 9.3.4 —PUTкато замяна на цялото състояние. - RFC 6585, Additional HTTP Status Codes, раздел 3:
428, условната заявка, изисквана срещу изгубената актуализация, обяснението как да се изпрати отново и забраната да се запазва в кеш. - RFC 9745, The Deprecation HTTP Response Header Field (март 2025 г., предложен стандарт): датата на отхвърляне като дата на структурирано поле (2.1), обхватът ѝ (2.2), релацията
deprecationзаLink(3), правилотоSunsetда не е по-рано (4) и това, че отхвърлянето не променя поведението (5). - RFC 8594, The Sunset HTTP Header Field (май 2019 г., информационен): датата, на която ресурсът вероятно ще спре да отговаря, като подсказка (3), и двата етапа на отхвърлянето (1.4); страницата му в datatracker на IETF, която го показва като индивидуално предложение.
- RFC 9457, Problem Details for HTTP APIs, раздел 3: примерът с разширението
errorsи по единpointerза поле, който Урок 5 възприема за422. - Регистърът на HTTP кодовете на състоянието на IANA: официалните имена на петте кода, сред тях
422 Unprocessable Contentи428 Precondition Required. - Zalando RESTful API Guidelines:
400и за бизнес валидацията и422като „не използвай“;412за оптимистичния контрол на конкурентността сIf-Matchи428за изискването му. - Google, AIP-154 Resource freshness validation и
google.rpc.Code: етикетът като поле на ресурса,ABORTED(409), когато не съвпада, иINVALID_ARGUMENT(400) за невалидните аргументи; и документацията на REST API на GitHub, който отговаря с422при валидацията и при повторените стойности (already_exists).
Термини от този урок
| изгубена актуализация (lost update) | когато клиент запазва старото си копие на ресурс и, без да знае, изтрива промяната, която друг е направил, след като той е прочел |
| оптимистичен контрол на конкурентността | всички се оставят да четат и редактират без заключване и се отхвърля само записът, който пристига върху стара версия |
| конфликт на състоянието | валидна заявка, която се сблъсква с онова, което вече съществува на сървъра; в друго състояние би проработила без промяна (409) |
| отхвърлен (deprecated) | който вече не се препоръчва за използване, макар все още да работи |
| предусловие | условие, което клиентът поставя в заглавка (If-Match), за да се изпълни методът само ако то е изпълнено (412, ако не е) |
| бизнес правило | условие за стойностите на данните, което не зависи от състоянието на сървъра (422, ако не е изпълнено) |
| извеждане от употреба (sunset) | моментът, в който ресурс или версия спира да отговаря |
Предпочитате имейл? Пишете ни на hola@habil.mx