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

Урок 0 — Контрактът, който пътува по HTTP

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

23:40 ч. в петък е. Таблото на revisor казва, че четирите услуги работят: catalogo, inventario, pagos и reportes, и четирите в зелено. Мониторингът на компанията също е зелен: нула грешки през последния час. И въпреки това чатът на поддръжката се пълни със съобщения от хора, които не могат да платят. Някой отваря терминал, пита pagos за състоянието му и за първи път тази нощ прочита целия отговор. Първият ред казва HTTP/1.1 200 OK. Тялото, три реда по-надолу, казва {"error": "la base de datos no está disponible"}. Всички програми, които следяха услугата, прочетоха първия ред и останаха спокойни. Нито една не прочете тялото. Услугата не работеше от четирийсет минути и казваше, че е наред.

Този урок е за този първи ред: какво казва, кой го чете и защо не може да лъже.

Време: 90 мин или 2 × 45 (разделянето е между раздели 0.4 и 0.5)

Какво изграждаш: средата на курса (Node 24 и curl), първия си HTTP сървър и тестовия стенд, който ще използваш във всички уроци: сървър, който отговаря с кода на състоянието, който поискаш (GET /codigo/503), със заглавките, които този код носи

Какво научаваш: какво е HTTP отговор (ред на състоянието, заглавки, тяло); петте семейства кодове; кодът командва, а тялото обяснява; какво прави една програма с код, който не познава; къде се намират официалните регистри на кодове, заглавки и типове проблеми; и защо 200 с {"error": …} заблуждава кеша, мониторинга и клиента

Откъде идваш. Още не носиш нищо от този курс. Носиш обаче онова, което изисква README: вече си писал някакво API, на какъвто и да е език, знаеш какво е HTTP заявка и какво е JSON и четеш кратка програма на TypeScript. Не е нужно да познаваш никаква спецификация: всеки път, когато урокът цитира някоя, той ти казва кой раздел и какво пише в него.

Какво не прави този урок. Не учи как да се избере код за конкретна ситуация: това започва в Урок 1 с успешните отговори и продължава до Урок 4 с грешките. Не дава формат на тялото на грешките: тук тялото е ред текст, а Урок 5 го заменя с application/problem+json. А кешът, който се появява в раздел 0.8, е играчка от шейсет реда, която прилага две реални правила от спецификациите; той служи, за да видиш проблема с очите си, а не за използване в продукция.

За изпълненията. Linux Mint 22 е референтната система за курса. Изходите в този урок са получени на 21 октомври 2026 г. на macOS с Node 24.14.1 и curl 8.7.1 и са повторени изцяло в контейнер с Ubuntu 24.04.5 (основата на Linux Mint 22) с Node 24.22.0 и curl 8.5.0, тоест curl, който инсталира apt. Двете изпълнения дадоха едно и също ред по ред, освен в три неща, които се отбелязват там, където се появяват: датата в заглавката Date (ще бъде тази от твоя ден), версията на curl, която се вижда в curl -v, и номерът на грешката „адресът вече се използва“ (-48 в macOS, -98 в Linux). Не е изпълнявано на десктоп инсталация на Mint. Програмите са в programas/00-el-contrato-que-viaja/, в хранилището на курса, всяка с изпълнението си във файл fig00_NN.salida.txt. Всички минават tsc в строг режим (раздел 0.9 казва как да се провери).

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

  • Да стартираш HTTP сървър с Node 24 без зависимости и да го питаш с curl -i, curl -v и curl -I.
  • Да посочиш в реален отговор реда на състоянието, всяка заглавка, празния ред и тялото и да кажеш коя част чете всяка програма по пътя.
  • Да кажеш семейството на всеки трицифрен код и какво трябва да направи клиент с код, който не познава, като цитираш правилото от RFC 9110.
  • Да предизвикаш по желание всеки код с тестовия стенд и да провериш с curl какви заглавки носи.
  • Да намериш код или заглавка в регистъра на IANA и да преброиш колко кода са присвоени в едно семейство, без да попаднеш в капана на редовете „Unassigned“.
  • Да покажеш с изпълнение, че 200 с грешка в тялото минава проверката на състоянието (health check) на curl -f, остава записан в кеш и му пречи да сервира доброто копие.

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

Едно API говори с много програми, които не познаваш. Тази, която го е извикала, познаваш: написал си я ти или някой от екипа ти и тя умее да чете твоя JSON. Но между тази програма и твоя сървър има други, които никога не са чели документацията ти: балансьор на натоварването, който решава към коя машина да изпрати всяка заявка, кеш, който пази отговори, за да не се повтаря работа, мониторинг, който брои грешките в минута, проверка на състоянието (health check), която рестартира услугата, ако тя не отговаря правилно, и самият HTTP клиент на всеки език, който идва с вградени правила. На нито една от тях не ѝ е нужно да разбира тялото ти. Всички разбират числото. (Има шлюзове, които проверяват съдържанието, и клиенти, които наистина го четат; но не можеш да разчиташ на тях, а числото е единственото, което всички имат общо.)

Затова първият ред на отговора е контракт. Не контракт, който подписваш с човек, а с целия софтуер, който вече съществува и следва същите спецификации. Когато сървърът ти отговори с 503, всяка от тези програми знае нещо, без да те пита: мониторингът го брои като повреда, проверката на състоянието го отбелязва, кешът може да сервира предишно копие, клиентът знае, че проблемът не е негов. Когато отговори с 200, всички заключават, че всичко е минало добре, каквото и да казва тялото. Сцената от началото не е преувеличена: това се случва, когато числото и тялото казват различни неща, а числото печели, защото е единственото, което почти всички четат.

В revisor това се изживява от двете страни. Като клиент revisor пита чужди услуги и решава дали работят според това, което отговарят; ако една услуга излъже с 200, revisor лъже заедно с нея. Като сървър revisor е API, което някой друг ще пита, и грешките му трябва да казват истината в числото, за да не се заблуждават собствените му клиенти, мониторинги и кешове.

Урокът има пет части, в този ред:

  1. Средата и първият отговор (0.1 и 0.2): инсталираш Node 24, стартираш сървър от петнайсет реда и гледаш какво наистина пътува.
  2. Семействата (0.3): първата цифра на кода и въпросът, на който отговаря всяко семейство.
  3. Тестовият стенд (0.4): сървърът, който отговаря с кода, който поискаш, и който ще използваш в целия курс.
  4. Кой какво чете (0.5 до 0.7): кодът командва, а тялото обяснява; какво прави една програма с код, който не познава; и къде е записано кои кодове съществуват.
  5. Щетата (0.8): кеш, мониторинг и клиент срещу сървър, който лъже, и друг, който казва истината.

С тази цел пред очи, ето какво ще видиш в раздел 0.8: същата неработеща услуга, през кеш, в две версии. В първата сървърът отговори с 200 с грешката си, а кешът я запази и я повтаря. Във втората сървърът отговори с 503, а кешът сервира последното добро копие:

{"error":"la base de datos no está disponible"}
{"servicios":["catalogo","pagos"]}

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

Понятията

0.1 Средата: Node 24, curl и една папка

0.1.1 curl

curl е инструментът на курса: програма за терминала, която прави HTTP заявка и ти показва какво е отговорил сървърът, без красив екран, който да скрива нещо. Той е пръв, защото ти трябва, за да инсталираш Node, а после — за всичко останало. Ако curl --version не отговаря, инсталирай го:

sudo apt install curl

0.1.2 Node 24

Програмите на курса са на TypeScript и ги изпълнява Node.js 24. Node 24 има една характеристика, от която този курс се възползва: изпълнява файл .ts директно, като маха типовете преди изпълнението, без да го компилира към JavaScript и без да инсталира нищо друго. Документацията на Node 24 го описва така: Node заменя анотациите за типове с интервали и изпълнява това, което остава; затова приема само TypeScript, който може да бъде „изтрит“, без да се промени програмата. Това, което не може да бъде изтрито така (enum, namespace с код, свойствата, декларирани в параметрите на конструктор), дава грешка, а програмите на курса не го използват. Тази функция е включена по подразбиране от Node 23.6, спря да отпечатва предупреждението „experimental“ в 24.3 и беше обявена за стабилна в 24.12.

Защо Node 24, а не друга? Защото е линия LTS (long-term support, дългосрочна поддръжка). Според официалния график на версиите на Node 24 влезе в LTS на 28 октомври 2025 г., премина в етап на поддръжка на 20 октомври 2026 г. и получава поправки до 30 април 2028 г. 26 ще бъде LTS от 28 октомври 2026 г.; програмите от този курс не използват нищо, което да се различава между двете.

В Linux Mint 22 не е добре Node да се инсталира с apt: Mint 22 се основава на Ubuntu 24.04, а пакетът nodejs на тази основа е версия 18.19.1 (проверено с apt-cache policy nodejs в контейнер с Ubuntu 24.04), линия, която вече не получава поправки. Инсталира се с nvm, мениджъра на версии на Node, който препоръчва страницата за изтегляне на Node, точно както в курса по TypeScript на Hábil. Документацията на nvm публикува този инсталатор за своята версия 0.40.8, актуалната:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash

Бележка за сигурността. Конвейерът | bash изпълнява на машината ти, без да го прочетеш, това, което върне този адрес. Тук това е разумно, защото е официалният инсталатор на nvm, през HTTPS и с фиксирана версия (v0.40.8), но никога не го прави с адрес, който не познаваш. Ако искаш да го прегледаш преди това, изтегли го във файл (curl -o instalar-nvm.sh със същия адрес), прочети го и го изпълни после с bash instalar-nvm.sh.

Затвори терминала, отвори друг (инсталаторът добавя nvm във файла ти за стартиране, а вече отвореният терминал не разбира за това) и поискай линия 24:

nvm install 24
nvm alias default 24
node -v

node -v трябва да отпечата число, което започва с v24. Числото след това зависи от деня, в който инсталираш; изпълненията на този урок бяха направени с 24.14.1 и 24.22.0 и дадоха едно и също.

0.1.3 Папката на курса

Целият курс живее в една папка, ~/curso-http/. Създай я и влез в нея, защото всички команди от урока се изпълняват оттам:

mkdir -p ~/curso-http && cd ~/curso-http

Сложи вътре файл package.json с едно-единствено указание:

{
  "type": "module"
}

Този файл не инсталира нищо; курсът няма зависимости. Той само казва на Node, и на tsc, ако го използваш, че файловете в тази папка са ESM модули, модерната форма на JavaScript, тази, която използва import. Без него Node би трябвало да отгатва всеки път.

0.2 Един отговор отвътре

0.2.1 Първият сървър

Това е най-малкият HTTP сървър, който си струва да се напише. Запиши го като ~/curso-http/fig00_01.ts:

// fig00_01.ts
import { createServer } from "node:http";

const servidor = createServer((peticion, respuesta) => {
  console.log(`${peticion.method} ${peticion.url}`);
  const cuerpo = "el revisor está escuchando\n";
  respuesta.writeHead(200, {
    "Content-Type": "text/plain; charset=utf-8",
    "Content-Length": Buffer.byteLength(cuerpo),
  });
  respuesta.end(cuerpo);
});

servidor.listen(18080, "127.0.0.1", () => {
  console.log("escuchando en http://127.0.0.1:18080");
});

Чете се отгоре надолу. node:http е модулът на Node, който умее да говори HTTP; префиксът node: казва, че идва с Node, а не от външен пакет. createServer получава функция, която Node извиква веднъж за всяка заявка, с два обекта: пристигналата peticion и respuesta, която ще напишеш. Функцията отпечатва метода и пътя (за да видиш в терминала на сървъра какво е пристигнало) и после пише отговора на две стъпки. writeHead пише кода на състоянието и заглавките; end пише тялото и затваря отговора.

Две заглавки, и двете с причина. Content-Type казва какво е тялото: обикновен текст в UTF-8. Content-Length казва колко байта е, и затова се изчислява с Buffer.byteLength, а не с cuerpo.length: „á“ в „está“ заема два байта в UTF-8 и тялото е 28 байта, макар да има 27 знака. Накрая listen отваря порт 18080 на 127.0.0.1, което е името на „същия този компютър“: никой извън твоята машина не може да стигне до този сървър, и така трябва да бъде, докато се упражняваш.

Изпълни го в терминал, който ще оставиш отворен:

$ node fig00_01.ts
escuchando en http://127.0.0.1:18080

Програмата не приключва: остава да чака заявки, докато не я спреш с Ctrl+C.

0.2.2 Какво пътува: curl -i

В друг терминал поискай нещо от него. Опцията -i казва на curl да покаже целия отговор, с реда на състоянието и заглавките, а не само тялото:

$ curl -i http://127.0.0.1:18080/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 28
Date: Wed, 21 Oct 2026 07:27:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5

el revisor está escuchando

Това е HTTP отговор и той има три части, винаги в един и същ ред:

Част В изпълнението Кой я е написал
Ред на състоянието HTTP/1.1 200 OK ти, с 200 от writeHead; Node добави версията и OK
Заглавки от Content-Type до Keep-Alive ти написа две; Node добави Date, Connection и Keep-Alive
Тяло el revisor está escuchando ти, с end

Между заглавките и тялото има празен ред. Той не е украса: той е единственият начин, по който четящият може да разбере къде свършват заглавките. А тялото свършва там, където казва Content-Length: 28 байта, нито един повече. Спецификацията, която определя тази точна форма, ред по ред, е RFC 9112, която описва HTTP/1.1 така, както пътува по мрежата. Това, което означават кодовете и заглавките, е в друг документ, RFC 9110, HTTP Semantics, който ще цитираш най-много в този курс. Разделението има смисъл: HTTP/2 и HTTP/3 пътуват по друг начин, с друг, двоичен формат, но кодовете и заглавките означават едно и също и в трите версии.

Погледни сега реда на състоянието спокойно. Той има три части: версията (HTTP/1.1), кода (200) и фраза (OK). Фразата се нарича reason phrase, а RFC 9112, в своя раздел 4, казва нещо, което е добре да помниш от днес: клиентът би трябвало да я пренебрегва, защото тя не е надежден канал за информация; може да идва преведена, може да я промени посредник или може да изчезне при преминаване към друга версия на HTTP. Фразата е за човека, който чете терминала. За програмите единственото, което има значение, е числото. В раздел 0.5 ще видиш, че дори Node използва фрази, различни от официалните, за някои кодове.

Бележка за името на опцията. В последните версии на curl дългата опция на -i се казва --show-headers (преди се казваше --include и старото име продължава да работи), според ръководството на curl. -i работи по един и същ начин и при двете.

0.2.3 Заявката също има форма: curl -v

Един HTTP разговор има две половини, а -i ти показва само втората. С -v (verbose, „с всички подробности“) curl ти показва и това, което е изпратил:

$ curl -v http://127.0.0.1:18080/servicios
*   Trying 127.0.0.1:18080...
* Connected to 127.0.0.1 (127.0.0.1) port 18080
> GET /servicios HTTP/1.1
> Host: 127.0.0.1:18080
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=utf-8
< Content-Length: 28
< Date: Wed, 21 Oct 2026 07:27:41 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
< 
el revisor está escuchando
* Connection #0 to host 127.0.0.1 left intact

Редовете със * са коментари на curl за връзката. Тези, които започват с >, са заявката, така както е излязла: ред с метода (GET), пътя (/servicios) и версията, а после собствените ѝ заглавки. Тези, които започват с <, са отговорът, същият като преди. (В Linux ще видиш curl/8.5.0 в User-Agent или версията, която имаш; това е единственото, което се променя.)

Обърни внимание на две неща. Първото: заявката има същата форма като отговора — начален ред, заглавки, празен ред и, ако е нужно, тяло. Второто: поиска /servicios и сървърът отговори с 200 със същия текст като винаги, защото този сървър не гледа пътя; само го отпечатва. В неговия терминал се появи GET /servicios. Сървър, който отговаря с 200 на път, който не съществува, вече е малка лъжа, а стендът от раздел 0.4 не я допуска.

0.2.4 Само заглавките: HEAD и curl -I

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

$ curl -I http://127.0.0.1:18080/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 28
Date: Wed, 21 Oct 2026 07:27:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Content-Length продължава да казва 28, защото казва колко би било тялото на GET, но тяло не пристигна. Програмата ти не направи нищо специално за това: тя написа тялото с end, а Node, който знае, че заявката е HEAD, не го изпрати. В терминала на сървъра се появи HEAD /. Проверките на състоянието и някои мониторинги използват HEAD точно защото ги интересува само числото, а числото пристига така или иначе.

Вече можеш да спреш сървъра с Ctrl+C. Останалата част от урока използва порт 18080 за други програми.

0.3 Пет семейства и един въпрос

Кодът на състоянието е трицифрено число, а първата цифра е семейството. RFC 9110 определя пет в своя раздел 15 и всяко отговаря на различен въпрос:

Семейство Какво казва RFC 9110 Въпросът, на който отговаря
1xx заявката е получена и обработката продължава да чакам ли още? Да: това не е окончателният отговор
2xx заявката е получена, разбрана и приета мина ли добре? Да
3xx нужно е друго действие, за да бъде завършена къде е? На друго място, и ти казвам къде
4xx заявката съдържа грешка или не може да бъде изпълнена кой сгреши? Този, който пита
5xx сървърът се провали с привидно валидна заявка кой сгреши? Този, който обслужва

Семейството 1xx е различно от другите четири и е добре да го знаеш от днес. Отговорът 1xx е междинен: според раздел 15.2 една заявка може да получи нула или повече отговора 1xx и след това точно един окончателен отговор от другите семейства. 1xx не може да носи тяло и никога не е последната дума. Затова тестовият стенд от следващия раздел отказва да произведе такъв като окончателен отговор.

Въпросът в последната колона е най-полезният в курса, а уроци 2 до 4 го превръщат в метод. Когато получиш грешка, преди да прочетеш каквото и да е друго, погледни първата цифра. 4 казва, че този, който трябва да промени нещо, е този, който е направил заявката: входа, идентичността, разрешението. 5 казва, че заявката е била наред и се е провалил сървърът, и че този, който я е направил, няма какво да поправя, макар това да не го упълномощава да я повтаря сляпо (Урок 6 се занимава с това кога да). Ако искаш да видиш същата таблица, приложена към API за контакти, със сървър, който лъже, и друг, който не лъже, един до друг, първата част от поредицата „HTTP и Problem Details“ в бюлетина на Hábil, цитирана накрая, я разглежда код по код.

0.4 Тестовият стенд

0.4.1 За какво служи

За да научиш какво прави една програма с 503, ти трябва сървър, който отговаря с 503, когато ти поискаш. Да чакаш реална услуга да спре да работи не е метод, а да го предизвикаш в чужда услуга не е редно. Тестовият стенд решава това: той е сървър, който на пътя /codigo/NNN отговаря с кода NNN, който му поискаш, със заглавките, които този код изисква или обикновено носи. Ще го използваш във всички уроци на курса и затова си струва да го прочетеш внимателно веднъж.

0.4.2 Програмата

Запиши я като ~/curso-http/fig00_02.ts:

// fig00_02.ts
// El banco de pruebas del curso: responde el código que le pidas en /codigo/NNN.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

const PUERTO = 18080;

// 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));
  }
  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(`banco de pruebas en http://127.0.0.1:${PUERTO}/codigo/NNN`);
});

Програмата има три части.

Две таблици. NOMBRES пази официалната фраза на 28-те кода, които курсът използва (23-те главни и петте, които се появяват мимоходом), копирана от регистъра на IANA, който е официалният списък с кодове (раздел 0.7). CABECERAS пази заглавките, които носи всеки код. Не всички имат едно и също тегло и е добре това да се каже точно: RFC 9110 казва, че 401 трябва да носи WWW-Authenticate с поне едно предизвикателство (раздел 15.5.2) и че 405 трябва да носи Allow с методите, които са позволени (раздел 15.5.6). За пренасочванията (301, 302, 307, 308) сървърът би трябвало да изпраща Location. За 201 Location идентифицира създадения ресурс, а ако липсва, създаденият ресурс е самият път на заявката (раздел 15.3.2). А Retry-After при 503 е по избор според RFC 9110, точно както при 429 според RFC 6585, където е определен този код. Уроци 1 до 4 обясняват всяка в дълбочина; стендът само ти позволява да ги видиш.

Функцията enviar. Сглобява тялото, един ред с кода и името му и друг с обяснението, и пише отговора. Има два детайла. Първият: за 204, 205 и 304 не пише тяло, нито Content-Type, защото тези три кода не могат да го носят: RFC 9112 (раздел 6.3) казва, че отговор 204 или 304 свършва на първия празен ред след заглавките, а RFC 9110 (раздел 15.3.6) забранява генерирането на съдържание в 205. Списъкът не е цялото правило. Един 1xx също не може да носи тяло, но стендът вече отказва да произведе такъв (видя го в 0.3 и ще го видиш в 0.4.4); а отговор на HEAD никога не носи тяло, за това се грижи Node (0.2.4). Кодът 205 е рядък (той казва на браузъра да изчисти формуляра, който е изпратил), но стендът може да го произведе, ако го поискаш, и затова е в списъка. Вторият: ако кодът е в NOMBRES, подава на writeHead официалната фраза; ако не, оставя Node да сложи своята. Документацията на writeHead приема трите аргумента: кода, фразата (по избор) и заглавките.

Функцията atender. Тя е входната точка и проверява по ред. Първо метода: стендът умее да отговаря само на GET и HEAD, а на всеки друг отговаря с 405 с неговия Allow; тоест стендът спазва правилото, на което учи. После пътя: той трябва да бъде /codigo/, последван от точно три цифри, иначе стендът отговаря с 404 и казва кой е единственият съществуващ път. И накрая числото: под 100 не съществува, 1xx не може да бъде окончателният отговор, а над 599 е невалидно според спецификацията, но стендът го произвежда въпреки това, нарочно, защото в раздел 0.6 ще ти трябва да видиш какво прави клиент с код, който не би трябвало да съществува.

0.4.3 Изпробване

Изпълни го в терминал и го остави отворен:

$ node fig00_02.ts
banco de pruebas en http://127.0.0.1:18080/codigo/NNN

В друг терминал поискай от него неработеща услуга:

$ curl -i http://127.0.0.1:18080/codigo/503
HTTP/1.1 503 Service Unavailable
Retry-After: 120
Content-Type: text/plain; charset=utf-8
Content-Length: 50
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

503 Service Unavailable
lo pediste en /codigo/503

Кодът, фразата му, заглавката Retry-After („опитай отново след 120 секунди“) и тяло, което обяснява. Създаден ресурс:

$ curl -i http://127.0.0.1:18080/codigo/201
HTTP/1.1 201 Created
Location: /servicios/demo
Content-Type: text/plain; charset=utf-8
Content-Length: 38
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

201 Created
lo pediste en /codigo/201

Отговор без съдържание, който не носи тяло, нито Content-Type, защото няма какво да се описва:

$ curl -i http://127.0.0.1:18080/codigo/204
HTTP/1.1 204 No Content
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Кодът 205 е от същия вид: без тяло. Тъй като програмата не пише Content-Length, Node изпраща отговора на парчета, с едно-единствено последно парче с размер нула (формата, която обяснява 0.8.2); съдържание няма:

$ curl -i http://127.0.0.1:18080/codigo/205
HTTP/1.1 205 Reset Content
Date: Wed, 21 Oct 2026 22:00:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

Липса на идентичност, с предизвикателството, което изисква спецификацията:

$ curl -i http://127.0.0.1:18080/codigo/401
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 43
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

401 Unauthorized
lo pediste en /codigo/401

0.4.4 Трите входа на стенда

Сега трите проверки на atender, за да видиш, че самият стенд също казва истината, когато грешката е твоя. Метод, който не обслужва (-X DELETE казва на curl кой метод да използва):

$ curl -i -X DELETE http://127.0.0.1:18080/codigo/200
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
Content-Length: 56
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

405 Method Not Allowed
el banco solo atiende GET y HEAD

Път, който не съществува:

$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 68
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

404 Not Found
la única ruta es /codigo/NNN; prueba con /codigo/503

И 1xx, поискан като окончателен отговор:

$ curl -i http://127.0.0.1:18080/codigo/103
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 76
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

400 Bad Request
un 1xx es una respuesta intermedia: no puede ser la última

Всяка заявка остана записана в терминала на стенда (GET /codigo/503, DELETE /codigo/200…). Остави стенда да работи: ще ти трябва в следващите два раздела.

0.5 Кодът командва, а тялото обяснява

0.5.1 Кой чете числото

Това е първият от трите принципа на курса и може да се види с инструмент, който вероятно вече имаш в някой скрипт: curl -f. С -f (fail) curl престава да бъде инструмент за гледане и става инструмент за решаване: ако кодът е 400 или по-голям, не отпечатва тялото и завършва с код на изход 22. Ръководството на curl го определя така: код 22 означава, че страницата не е получена, защото сървърът е отговорил с код 400 или повече, и се появява само с --fail. Много проверки на състоянието са точно това — curl -f, чийто код на изход решава дали услугата е наред. echo $? показва кода на изход на последната команда:

$ curl -f http://127.0.0.1:18080/codigo/200
200 OK
lo pediste en /codigo/200
$ echo $?
0
$ curl -f http://127.0.0.1:18080/codigo/503
curl: (22) The requested URL returned error: 503
$ echo $?
22

curl -f нито веднъж не прочете тялото. Нямаше как да знае, че „lo pediste en /codigo/503“ не е много полезно обяснение; нито го интересува. Реши по числото и по нищо друго.

HTTP клиентът на JavaScript прави същото. Когато използваш fetch, отговорът има свойство ok, а стандартът Fetch го определя с едно изречение: състояние ok е състояние между 200 и 299 включително. Половината свят пише if (respuesta.ok) и продължава напред. Ако сървърът е отговорил с 200 с {"error": …} вътре, този if влиза в грешния клон и нито една програма по пътя няма да разбере. Раздел 0.6 използва fetch срещу стенда, а 0.8 прави пълната сметка на щетата.

Затова принципът има две половини. Кодът командва: той е частта от съобщението, която всички четат, и тази, която решава какво правят. Тялото обяснява: то е за този, който се нуждае от подробностите — човек или програма, която разбира формата на твоето API. А условието, което свързва двете, е това, което се нарушава в сцената от началото: двете казват едно и също. Тялото може да добави информация към кода, но никога да му противоречи или да го замества. RFC 9110 го изисква и за двете семейства грешки: при 4xx и при 5xx, освен когато заявката е HEAD, сървърът би трябвало да изпраща в тялото обяснение на грешката и да казва дали тя е временна или постоянна (раздели 15.5 и 15.6). Тоест: числото и освен това обяснението, а не едното вместо другото.

0.5.2 Фразата в реда на състоянието не е съобщението

В 0.2.2 видя, че клиентът би трябвало да пренебрегва фразата в реда на състоянието. Ето ти доказателството защо. Стендът използва фразите от официалния регистър за своите 28 кода, но за всеки друг оставя Node да сложи своята. Поискай от него 418, който не е в таблицата му, само заглавките:

$ curl -I http://127.0.0.1:18080/codigo/418
HTTP/1.1 418 I'm a Teapot
Content-Type: text/plain; charset=utf-8
Content-Length: 57
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Node го нарича „I'm a Teapot“, „аз съм чайник“, което идва от шега: RFC 2324, публикуван на 1 април 1998 г., който се подиграваше на злоупотребите с HTTP. В официалния регистър 418 фигурира като (Unused), а раздел 15.5.19 на RFC 9110 обяснява защо: той е бил използван като шега толкова пъти, че вече не става за никаква сериозна употреба, така че е останал резервиран. И това не е единственият случай: таблицата с фрази на Node нарича 422 „Unprocessable Entity“, а 413 — „Payload Too Large“, които бяха старите имена; RFC 9110 ги промени на „Unprocessable Content“ и „Content Too Large“ и така се появяват в регистъра. Затова стендът носи собствена таблица.

Нито една от тези разлики не чупи нищо, и това е урокът: фразата може да казва каквото и да е и никоя програма не трябва да решава по нея. Клиент, който сравнява фразата с "Not Found", спира да работи с първия сървър, който я преведе, съкрати или пропусне. Числото е едно и също при всички.

0.6 Код, който машината не познава

0.6.1 Правилото

Има повече от шейсет присвоени кода (раздел 0.7) и от време на време се появяват нови. Никоя програма не ги познава всичките, и спецификацията знае това. RFC 9110, във въведението на своя раздел 15, дава кратко правило, което е добре да знаеш наизуст: клиентът не е длъжен да разбира всички регистрирани кодове, но трябва да разбира семейството на всеки код по първата му цифра и трябва да третира код, който не разпознава, като x00 на неговото семейство. Самият RFC дава пример: ако пристигне 471, който не познава, първата цифра казва, че нещо в заявката не е наред, и клиентът го третира, сякаш е получил 400.

Същият раздел добавя второ правило: стойностите извън диапазона от 100 до 599 са невалидни. Някои библиотеки използват числа като 600 или 999, за да съобщават вътрешни грешки, които не са на HTTP, и ако някое от тях стигне до клиент, клиентът би трябвало да го третира като 5xx. Това е разумно решение: число, което не би трябвало да съществува, казва преди всичко, че нещо от страната на сървъра не е наред.

С тези две правила програма, която познава само шепа кодове, може да реагира смислено на всеки. И затова измислянето на собствени кодове е лоша идея (курсът не го учи, нарочно): твоят 299 Casi listo не го разбира никой друг и всички ще го третират като обикновен 200.

0.6.2 Четецът, който гледа само числото

Тази програма чете отговорите на стенда така, както би го направил мониторинг: не гледа тялото, само числото, и прилага двете правила. Запиши я като ~/curso-http/fig00_03.ts:

// fig00_03.ts
// Lee solo el número, como lo hace un monitor o un balanceador.

// Los códigos que este lector conoce por su nombre: los del curso.
const CONOCIDOS = new Set([
  200, 201, 202, 204, 301, 302, 304, 307, 308,
  400, 401, 403, 404, 405, 406, 409, 410, 412, 413, 415, 422, 428, 429,
  500, 501, 502, 503, 504,
]);

const FAMILIAS = ["", "informativa", "éxito", "redirección", "error del cliente", "error del servidor"];

// La regla de la sección 15 del RFC 9110.
function comoSiFuera(codigo: number): number {
  if (codigo < 100 || codigo > 599) return 500; // inválido: se trata como un 5xx
  if (CONOCIDOS.has(codigo)) return codigo; // conocido: se trata como lo que es
  return Math.floor(codigo / 100) * 100; // desconocido: el x00 de su familia
}

for (const pedido of [200, 204, 299, 308, 404, 471, 503, 600]) {
  const respuesta = await fetch(`http://127.0.0.1:18080/codigo/${pedido}`, { redirect: "manual" });
  await respuesta.text();
  const trato = comoSiFuera(respuesta.status);
  const familia = FAMILIAS[Math.floor(trato / 100)];
  console.log(`${respuesta.status}  ok=${String(respuesta.ok).padEnd(5)}  se trata como ${trato} (${familia})`);
}

Функцията comoSiFuera е правилото, написано на три реда, в реда, в който трябва да се прилага: първо невалидното, после познатото и накрая непознатото, което се закръгля до x00 на семейството си с Math.floor(codigo / 100) * 100. Цикълът иска от стенда осем кода, сред които два, които никой не е регистрирал (299 и 471), и един невалиден (600). Два детайла за fetch: redirect: "manual" му казва да не следва Location на 308 (по подразбиране би го последвал и би получил 200 от целта, а не 308), а await respuesta.text() чете тялото и го изхвърля, за да остане връзката свободна. await в цикъла работи извън функция, защото файлът е модул благодарение на package.json от 0.1.3.

При работещ стенд, в другия терминал:

$ node fig00_03.ts
200  ok=true   se trata como 200 (éxito)
204  ok=true   se trata como 204 (éxito)
299  ok=true   se trata como 200 (éxito)
308  ok=false  se trata como 308 (redirección)
404  ok=false  se trata como 404 (error del cliente)
471  ok=false  se trata como 400 (error del cliente)
503  ok=false  se trata como 503 (error del servidor)
600  ok=false  se trata como 500 (error del servidor)

Прочети колоната ok и тази отдясно заедно. 299 е ok за fetch, защото е между 200 и 299, а четецът го третира като 200: измислен код в семейството на успеха се чете като успех, което е точно това, което казва правилото. 471 се чете като 400, примерът от самия RFC. А 600, който стендът произведе нарочно, се чете като грешка на сървъра. Обърни внимание и на това, че 308 не е ok: ok покрива само семейството 2xx, а пренасочването все още не е успех. Програма, която прави if (!respuesta.ok) reportarFalla(), без да гледа повече, ще отчита като повреда всяко пренасочване; това е видът детайл, който Урок 7 превръща в таблица за вземане на решения.

Вече можеш да спреш стенда с Ctrl+C.

0.7 Официалните регистри

0.7.1 Кой решава кои кодове съществуват

Нито Node, нито curl, нито този курс решават кои кодове съществуват. Решава го публичен процес. RFC 9110, в своя раздел 16.2.1, постановява, че кодовете се регистрират в регистъра на кодовете на състоянието на HTTP, който поддържа IANA (Internet Assigned Numbers Authority, организацията, която управлява официалните номера и имена на интернет протоколите). Всеки запис има три данни: кода, кратко описание и препратка към спецификацията, която го определя. А добавянето на нов код изисква преглед от IETF, групата, която пише стандартите на интернет; не е достатъчно някой да го използва.

Регистърът може да се изтегли като CSV и беше преброен на 21 октомври 2026 г., във версията му, обновена на 15 септември 2025 г.: има 62 присвоени кода. По семейства: 5 в 1xx, 10 в 2xx, 8 в 3xx, 28 в 4xx и 11 в 5xx. Освен това има два резервирани неизползвани номера, 306 и 418, и цели неприсвоени диапазони. Един от 62-та е временен: 104, регистриран, докато се довършва черновата, която го определя, със срок на изтичане през ноември 2026 г. Курсът използва 23 от 62-та, а упражнение 3 те кара да повториш преброяването на семейството 4xx със собствените си ръце, защото крие капан.

0.7.2 Другите три регистъра, които ще използваш

Кодовете не са единственото, което се регистрира. В хода на курса ще се обръщаш към още три регистъра на IANA и е добре да знаеш още днес, че съществуват:

Регистър Какво пази Къде се появява в курса
Имена на полета на HTTP всяка заглавка, със статуса ѝ (постоянна, временна, остаряла) и спецификацията, която я определя Location, Allow, Retry-After или WWW-Authenticate, във всеки урок
Медийни типове валидните стойности на Content-Type, като application/json или application/problem+json Урок 5
Типове проблеми на HTTP стандартни идентификатори за полето type на Problem Details Урок 5

Регистърът на заглавките беше изтеглен в същия ден: има 260 имена и не всички са в сила; 39 са отбелязани като остарели и 8 като неодобрени, така че „е в регистъра“ не означава „трябва да се използва“. Този за типовете проблеми е много по-малък: създаде го RFC 9457, спецификацията на Problem Details, която Урок 5 преподава изцяло, и днес има шест записа, първият от които е about:blank.

Бележка за принципа: когато един урок казва, че даден код „означава“ нещо или че дадена заглавка „изисква“ нещо, източникът е една от тези спецификации, с връзка в раздела с източници на урока, а регистърът на IANA е арбитърът за това какво съществува. Когато курсът вземе решение, което спецификацията оставя отворено (като 400 срещу 422), той ще го каже: това е принцип на дизайна, а не норма.

0.8 Защо 200 с {"error": …} чупи кеша, мониторинга и клиента

0.8.1 Експериментът

Вече видя поотделно, че curl -f и fetch решават по числото. Остава третият четец по пътя, този, който нанася най-много щети, когато греши, защото греши за всички: кешът. Споделеният кеш е програма между клиентите и сървъра, която пази отговори и ги повтаря, за да не изчислява сървърът хиляди пъти едно и също. Правилата кога може да запази нещо са в RFC 9111, а две от тях имат значение тук.

Първото правило е от раздел 3 на RFC 9111: кешът може да запази отговор, наред с други условия, ако отговорът изрично го разрешава, например с Cache-Control: max-age=N („използвай ме повторно N секунди“). Второто е от едно разширение, RFC 5861: със stale-if-error=N сървърът разрешава на кеша да продължи да сервира изтекло копие в продължение на N секунди, ако при искането на ново срещне грешка. А RFC определя „грешка“ със затворен списък: всяка ситуация, която води до 500, 502, 503 или 504. Кешът разпознава грешката по числото. Ако числото казва 200, за кеша грешка няма.

Тази програма поставя двете части една до друга: източник (origin), който може да е здрав, да лъже или да казва истината, и кеш-играчка пред него, който прилага тези две правила и само тях. Запиши я като ~/curso-http/fig00_04.ts:

// fig00_04.ts
// Un origen que puede estar sano, mentir o decir la verdad, y una caché de juguete delante.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

type Modo = "sano" | "mentiroso" | "honesto";
let modo: Modo = "sano";

// ---------- El origen, en el puerto 18081 ----------

function origen(peticion: IncomingMessage, respuesta: ServerResponse): void {
  const cambio = /^\/modo\/(sano|mentiroso|honesto)$/.exec(peticion.url ?? "");
  if (peticion.method === "PUT" && cambio !== null) {
    modo = cambio[1] as Modo;
    console.log(`origen: ahora estoy ${modo}`);
    respuesta.writeHead(204).end();
    return;
  }
  if (modo === "honesto") {
    respuesta.writeHead(503, {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-store",
      "Retry-After": "120",
    });
    respuesta.end("la base de datos no está disponible\n");
    return;
  }
  const cuerpo = modo === "sano"
    ? '{"servicios":["catalogo","pagos"]}\n'
    : '{"error":"la base de datos no está disponible"}\n';
  // Esta ruta declara que sus respuestas se pueden reutilizar 5 segundos.
  respuesta.writeHead(200, {
    "Content-Type": "application/json",
    "Cache-Control": "max-age=5, stale-if-error=600",
  });
  respuesta.end(cuerpo);
}

// ---------- La caché, en el puerto 18080 ----------

type Copia = { cuerpo: string; tipo: string; guardadaEn: number; maxAge: number; staleIfError: number };
let copia: Copia | undefined;

// Los únicos códigos que stale-if-error considera un error (RFC 5861, sección 4).
const ERRORES = new Set([500, 502, 503, 504]);

function directiva(cacheControl: string, nombre: string): number | undefined {
  const encontrada = new RegExp(`${nombre}=(\\d+)`).exec(cacheControl);
  return encontrada === null ? undefined : Number(encontrada[1]);
}

function servirCopia(respuesta: ServerResponse, guardada: Copia, edad: number): void {
  respuesta.writeHead(200, { "Content-Type": guardada.tipo, Age: String(Math.floor(edad)) });
  respuesta.end(guardada.cuerpo);
}

async function cache(_peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
  const edad = copia === undefined ? Infinity : (Date.now() - copia.guardadaEn) / 1000;
  if (copia !== undefined && edad < copia.maxAge) {
    console.log("caché: tengo una copia fresca; no le pregunto al origen");
    servirCopia(respuesta, copia, edad);
    return;
  }
  const delOrigen = await fetch("http://127.0.0.1:18081/servicios");
  const cuerpo = await delOrigen.text();
  const cacheControl = delOrigen.headers.get("cache-control") ?? "";
  if (ERRORES.has(delOrigen.status) && copia !== undefined && edad < copia.maxAge + copia.staleIfError) {
    console.log(`caché: el origen respondió ${delOrigen.status}; sirvo la copia anterior`);
    servirCopia(respuesta, copia, edad);
    return;
  }
  const maxAge = directiva(cacheControl, "max-age");
  if (delOrigen.status === 200 && maxAge !== undefined) {
    copia = {
      cuerpo,
      tipo: delOrigen.headers.get("content-type") ?? "application/octet-stream",
      guardadaEn: Date.now(),
      maxAge,
      staleIfError: directiva(cacheControl, "stale-if-error") ?? 0,
    };
    console.log(`caché: guardé la respuesta 200 por ${maxAge} s`);
  } else {
    console.log(`caché: el origen respondió ${delOrigen.status}; no la guardo`);
  }
  const cabeceras: Record<string, string> = {};
  for (const nombre of ["content-type", "cache-control", "retry-after"]) {
    const valor = delOrigen.headers.get(nombre);
    if (valor !== null) cabeceras[nombre] = valor;
  }
  respuesta.writeHead(delOrigen.status, cabeceras);
  respuesta.end(cuerpo);
}

createServer(origen).listen(18081, "127.0.0.1", () => {
  console.log("origen en http://127.0.0.1:18081 (PUT /modo/sano|mentiroso|honesto)");
});
createServer((peticion, respuesta) => void cache(peticion, respuesta)).listen(18080, "127.0.0.1", () => {
  console.log("caché en http://127.0.0.1:18080/servicios");
});

Източникът има контролна входна точка, PUT /modo/..., за да сменя настроението си, без да се рестартира, а на всяка друга заявка, с какъвто и да е метод и път, отговаря според режима си. Входната точка е лабораторен превключвател: не изисква идентичност, така че всеки, който стигне до порта, може да срине услугата; тук това няма значение, защото слуша само на 127.0.0.1, но път за инжектиране на повреди никога не трябва да съществува в реална услуга. Здрав, отговаря с 200 със списъка. Лъжлив, отговаря с 200 с грешка в тялото, със същите заглавки за кеша: това е реалистично, защото тези заглавки обикновено са конфигурирани за целия път, а не за всеки отговор. Честен, отговаря с 503 с Retry-After и Cache-Control: no-store („не пази това“).

Кешът прави четири неща, в този ред. Ако има копие, което още не е изпълнило своите max-age секунди, го сервира, без да пита източника. Ако не, пита източника. Ако източникът е отговорил с една от четирите грешки от списъка и изтеклото копие още е в своя прозорец stale-if-error, сервира старото копие. А ако източникът е отговорил с 200 с max-age, запазва новия отговор на мястото на предишния. Когато сервира копие, добавя заглавката Age, секундите, откакто е запазено, което е стандартната заглавка за това според раздел 5.1 на RFC 9111. Истинският кеш прилага много повече правила; този прилага две, и това е достатъчно, за да се види проблемът. max-age от 5 секунди са кратки нарочно, за да не трае дълго експериментът; в реална услуга биха били минути.

0.8.2 Първо изпълнение: източникът лъже

Изпълни я в един терминал. В друг поискай списъка през кеша, накарай източника да започне да лъже, изчакай копието да изтече (sleep 6) и поискай отново два пъти:

$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
content-type: application/json
cache-control: max-age=5, stale-if-error=600
Date: Wed, 21 Oct 2026 07:28:26 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"servicios":["catalogo","pagos"]}
$ curl -X PUT http://127.0.0.1:18081/modo/mentiroso
$ sleep 6
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
content-type: application/json
cache-control: max-age=5, stale-if-error=600
Date: Wed, 21 Oct 2026 07:28:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"la base de datos no está disponible"}
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
Content-Type: application/json
Age: 0
Date: Wed, 21 Oct 2026 07:28:33 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"la base de datos no está disponible"}

А ето какво написа програмата в своя терминал:

origen en http://127.0.0.1:18081 (PUT /modo/sano|mentiroso|honesto)
caché en http://127.0.0.1:18080/servicios
caché: guardé la respuesta 200 por 5 s
origen: ahora estoy mentiroso
caché: guardé la respuesta 200 por 5 s
caché: tengo una copia fresca; no le pregunto al origen

Проследи историята ред по ред. Първата заявка намери празен кеш, попита здравия източник и запази списъка. След като копието изтече, кешът попита отново, източникът отговори с 200 с грешката си, а кешът направи правилното според това, което видя: 200 с max-age е добър отговор за повторна употреба, така че го запази върху доброто копие. Третата заявка дори не стигна до източника: кешът повтори грешката, с Age: 0, на всеки, който попита. С max-age от пет минути грешката би се повтаряла пет минути на всички клиенти, дори услугата вече да се е възстановила. А доброто копие, което stale-if-error беше там, за да защити, се изгуби.

Заглавките с малки букви в отговорите, минали през източника (content-type, cache-control), не са грешка: fetch предава имената с малки букви и кешът ги препрати така. Имената на заглавките не различават главни от малки букви според раздел 5.1 на RFC 9110 и никоя програма не трябва да ги сравнява по друг начин.

Не е грешка и това, че липсва Content-Length и се появява Transfer-Encoding: chunked. В 0.2.2 тялото свършваше там, където казваше Content-Length, но fig00_04.ts не изчислява размера преди writeHead и тогава Node изпраща тялото на парчета (chunks): документацията на Node казва, че изпращането на Content-Length е това, което изключва тази форма, която е по подразбиране. Според раздел 7.1 на RFC 9112 всяко парче носи собствения си размер отпред, а парче с размер нула отбелязва края. Това са два начина да се каже къде свършва тялото; curl разбира и двата и ти показва едно и също тяло.

Остава мониторингът. Попитай директно лъжливия източник с проверката на състоянието от 0.5:

$ curl -f http://127.0.0.1:18081/servicios
{"error":"la base de datos no está disponible"}
$ echo $?
0

Проверката мина. Услугата не работи, а проверката на състоянието казва, че е наред. Това е зеленият мониторинг от сцената в началото.

0.8.3 Второ изпълнение: източникът казва истината

Спри програмата с Ctrl+C и я стартирай отново, за да започнеш с празен кеш. Сега източникът става честен:

$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
content-type: application/json
cache-control: max-age=5, stale-if-error=600
Date: Wed, 21 Oct 2026 07:28:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"servicios":["catalogo","pagos"]}
$ curl -X PUT http://127.0.0.1:18081/modo/honesto
$ sleep 6
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
Content-Type: application/json
Age: 6
Date: Wed, 21 Oct 2026 07:28:40 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"servicios":["catalogo","pagos"]}
$ curl -f http://127.0.0.1:18081/servicios
curl: (22) The requested URL returned error: 503
$ echo $?
22

А в терминала на програмата:

origen en http://127.0.0.1:18081 (PUT /modo/sano|mentiroso|honesto)
caché en http://127.0.0.1:18080/servicios
caché: guardé la respuesta 200 por 5 s
origen: ahora estoy honesto
caché: el origen respondió 503; sirvo la copia anterior

Същата услуга, същият срив, друго число. Кешът получи 503, разпозна го като грешка, защото е в списъка на RFC 5861, и сервира последното добро копие, с Age: 6, за да знае всеки, който иска, че то е на шест секунди. Клиентите продължават да виждат списъка с услуги, докато източникът се възстановява. А проверката на състоянието, която пита директно източника, се провали с код 22: мониторингът става червен, някой получава аларма и започва работа. 503 защити клиентите и предупреди онези, които отговарят за експлоатацията, едновременно. Лъжливият 200 направи обратното и на двата фронта.

Това е цялата щета, измерена: кешът пази грешката и губи доброто копие; мониторингът остава зелен; а клиентът, който прави if (respuesta.ok), вярва, че е получил данни. И тримата решиха правилно с това, което прочетоха. Този, който сгреши, беше числото.

Спри програмата с Ctrl+C. Нищо не остава да слуша.

0.9 Проверка на типовете с tsc (по избор)

Node маха типовете и изпълнява; не ги проверява. Ако напишеш const x: number = "a", Node го изпълнява, без да се оплаче. Затова курсът проверява програмите си с tsc, компилатора на TypeScript, в строг режим, и ти можеш да направиш същото, когато поискаш. Това не е част от курса и не е нужно за нито един урок. Вътре в ~/curso-http/:

npm install --no-save typescript@7.0.2 @types/node@24
npx tsc --strict --noEmit --target esnext --module nodenext --erasableSyntaxOnly --verbatimModuleSyntax --types node fig00_01.ts fig00_02.ts fig00_03.ts fig00_04.ts

Първата команда изтегля компилатора и описанията на типовете на Node в папка node_modules/, без да ги записва в package.json (това прави --no-save). Втората проверява четирите програми, без да генерира файлове (--noEmit); --erasableSyntaxOnly го кара да отхвърля това, което Node не би могъл да изтрие, а --verbatimModuleSyntax — да изисква import type там, където се импортира само тип, както във fig00_02.ts. Това са опциите, които препоръчва документацията на Node за този начин на работа. Ако всичко е наред, tsc не отпечатва нищо и завършва с код 0, което се получи и с четирите програми от този урок.

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

Заетият порт. Това е грешката, която ще виждаш най-често в този курс, защото всички програми използват порт 18080. Ако стартираш сървър, докато друг още слуша на този порт, Node спира така:

$ node fig00_01.ts
node:events:486
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE: address already in use 127.0.0.1:18080
    at Server.setupListenHandle [as _listen2] (node:net:1948:16)
    at listenInCluster (node:net:2005:12)
    at node:net:2214:7
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
Emitted 'error' event on Server instance at:
    at emitErrorNT (node:net:1984:8)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  code: 'EADDRINUSE',
  errno: -48,
  syscall: 'listen',
  address: '127.0.0.1',
  port: 18080
}

Node.js v24.14.1

Разчита се по реда, който започва с Error:: listen се провали с EADDRINUSE, адресът вече се използва, на 127.0.0.1:18080. Редовете с at са вътрешният път на Node и тук не ти казват нищо полезно. В Linux errno казва -98 вместо -48 (това е същата грешка с номера на друга система), а номерата на редовете на node:net се променят с версията на Node. Почти винаги причината е предишна програма от курса, която още работи в друг терминал: намери я и я спри с Ctrl+C. Ако не я намираш, попитай системата кой процес слуша на този порт: в Linux ss -ltnp 'sport = :18080' го показва с номера му като pid=NNNN; в macOS lsof -iTCP:18080 -sTCP:LISTEN го показва в колоната PID. С този номер го спираш: kill NNNN.

Никой не слуша. Обратната грешка: искаш нещо от порт, на който няма сървър. Това вече не е грешка на Node, а на curl:

$ curl -i http://127.0.0.1:18080/
curl: (7) Failed to connect to 127.0.0.1 port 18080 after 0 ms: Couldn't connect to server

Обърни внимание на това, което липсва: няма ред на състоянието, нито заглавки, нито код. Не е имало HTTP отговор, защото не е имало разговор. Това разграничение е много важно за revisor: „услугата отговори с 503“ и „не можа да се осъществи връзка“ са различни повреди, с различни причини, и revisor ще ги отчита различно. Код 7 на curl казва „не успях да се свържа“; 22 от раздел 0.5 казва „свързах се и сървърът отговори с грешка“. Скрипт, който гледа само дали кодът на изход е различен от нула, не различава двете.

Какво се прави погрешно

Отговор 200 с грешката в тялото. Вече измери цената в 0.8: кешът го пази и губи доброто копие, проверката на състоянието минава, а клиентът, който се доверява на ok, продължава напред. Поправката не е да се добави в тялото по-видимо поле "exito": false: тя е да се сложи истината в числото.

Решаване по фразата в реда на състоянието. if (statusText === "Not Found") спира да работи с първия сървър, който я преведе или пропусне, а RFC 9112 предупреждава, че фразата може да се изгуби при преминаване към друга версия на HTTP. Видя в 0.5.2, че Node и официалният регистър вече използват различни фрази за три кода.

Измисляне на собствени кодове. Самоделен 299 или 471 не го разбира никой друг, а правилото от 0.6 кара всички да ги третират като 200 или 400. Ако това, което искаше да кажеш, не се побира в регистриран код, то се побира в тялото, а Урок 5 ти дава формата.

Писане на клиент, който познава само своите кодове. switch, който разпознава 200, 404 и 500 и третира всичко останало като „непозната грешка“, превръща 204 в повреда. Правилото за x00 съществува точно за да не се случва това.

Третиране на „не можа да се осъществи връзка“ като код. Някои програми превръщат отказана връзка в 500 или във фалшива 0, за да „бъде всичко число“. Губи се най-полезната информация: дали сървърът е отговорил или не.

Мерене със сървър, който не гледа пътя. Сървърът от 0.2.1 отговаря с 200 на /servicios, на /lo-que-sea и на всичко останало. Той служи, за да се види формата на един отговор, и за нищо повече; такъв сървър заблуждава всеки тест, който му направиш.

Упражнения

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

Упражнение 1 — Четири кода, които четецът не познава

Без да стартираш нищо, предвиди за всеки от тези кодове три неща: какво казва respuesta.ok, как го третира comoSiFuera и какъв код на изход дава curl -f. Кодовете са 226, 418, 451 и 799. После смени списъка в цикъла на fig00_03.ts с [226, 418, 451, 799], стартирай стенда и четеца и сравни. За curl -f използвай curl -s -o /dev/null -w '%{http_code}\n' -f, последвано от адреса, а после echo $?.

Упражнение 2 — Следване на пренасочването

При работещ стенд предвиди какво отпечатва curl -i -L http://127.0.0.1:18080/codigo/308. Опцията -L казва на curl да следва Location. Колко реда на състоянието ще видиш? Кой път се появява в крайното тяло? А какво пише стендът в своя терминал? Изпълни командата и сравни.

Упражнение 3 — Преброяване на семейството 4xx в регистъра

Изтегли регистъра на кодовете на IANA в CSV:

curl -s -o codigos.csv https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv

Преброй колко кода 4xx са присвоени, като използваш grep. После потърси коя спецификация определя 429 и 451 и какво казва регистърът за 418. Сравни преброяването си с това от раздел 0.7.1: ако не ти е дало 28, решението обяснява защо.

Упражнение 4 — Лъжец, който не позволява запазване

Някой предлага поправка за проблема от 0.8, която не пипа числото: лъжливият източник да изпраща Cache-Control: no-store в грешките си, за да не ги запазва кешът. Копирай fig00_04.ts като ej4.ts и смени заглавката на отговора 200 с тази, която разрешава запазване само на здравите отговори:

    "Cache-Control": modo === "sano" ? "max-age=5, stale-if-error=600" : "no-store",

Преди да го стартираш, предвиди какво вижда клиент на кеша, когато източникът лъже, и дали кешът сервира доброто копие. После повтори първото изпълнение от 0.8.2 с ej4.ts и сравни. Решава ли поправката проблема?

Решения

Четирите решения са изпълнени със soluciones.sh, който създава копията ej1.ts и ej4.ts във временна папка и използва портовете на курса; пълният му изход е в soluciones.salida.txt. Датите в заглавките се променят при всяко изпълнение; всичко останало трябва да съвпада.

Решение 1

$ node ej1.ts
226  ok=true   se trata como 200 (éxito)
418  ok=false  se trata como 400 (error del cliente)
451  ok=false  se trata como 400 (error del cliente)
799  ok=false  se trata como 500 (error del servidor)
$ curl -s -o /dev/null -w '%{http_code}\n' -f http://127.0.0.1:18080/codigo/226; echo $?
226
0
$ curl -s -o /dev/null -w '%{http_code}\n' -f http://127.0.0.1:18080/codigo/418; echo $?
418
22
$ curl -s -o /dev/null -w '%{http_code}\n' -f http://127.0.0.1:18080/codigo/451; echo $?
451
22
$ curl -s -o /dev/null -w '%{http_code}\n' -f http://127.0.0.1:18080/codigo/799; echo $?
799
22

(Тук ej1.ts е твоето копие на fig00_03.ts със сменения списък; ако си редактирал директно fig00_03.ts, командата е node fig00_03.ts.) 226 (IM Used) съществува в регистъра, но не е в таблицата на четеца, така че се третира като 200 на семейството си, а ok е true, защото е между 200 и 299. 418 и 451 (Unavailable For Legal Reasons, който също съществува) се третират като 400. 799 е невалиден и се третира като 500. При curl -f няма изненади: всичко, което е 400 или повече, дава 22, и да, това включва и невалидния 799. Ако си сгрешил за 451, обърни внимание на поуката от упражнението: това, че един код съществува в регистъра, не означава, че програмата ти го познава, и затова е правилото за x00.

Решение 2

$ curl -i -L http://127.0.0.1:18080/codigo/308
HTTP/1.1 308 Permanent Redirect
Location: /codigo/200
Content-Type: text/plain; charset=utf-8
Content-Length: 49
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 33
Date: Wed, 21 Oct 2026 08:05:05 GMT
Connection: keep-alive
Keep-Alive: timeout=5

200 OK
lo pediste en /codigo/200

Два реда на състоянието: с -i curl показва заглавките на всеки отговор по пътя, но тялото само на последния. Тялото на 308 е съществувало (49 байта, казва го неговият Content-Length), но curl не го показва, защото е продължил напред. Крайният път е /codigo/200, този от Location, който е относителен: curl го е допълнил със сървъра и порта от първоначалната заявка. В терминала на стенда се появиха два реда, GET /codigo/308 и GET /codigo/200: това бяха две заявки, а не една. Какво се променя между 301 и 308, когато заявката е POST, е тема на Урок 1.

Решение 3

$ grep -E '^4[0-9]{2},' codigos.csv | wc -l
31
$ grep -E '^4[0-9]{2},' codigos.csv | grep -vc -e Unassigned -e Unused
28
$ grep -E '^(418|429|451),' codigos.csv
418,(Unused),"[RFC 9110, Section 15.5.19]"
429,Too Many Requests,[RFC 6585]
451,Unavailable For Legal Reasons,[RFC 7725]

Капанът е в първата команда: дава 31, а не 28. Регистърът записва празнините по два начина. Големите празнини са като диапазон на един ред (432-450,Unassigned) и шаблонът ^4[0-9]{2}, не ги брои, защото след трите цифри идва тире, а не запетая. Но празнините от едно-единствено число са на собствен ред, с номера си (427,Unassigned и 430,Unassigned), и шаблонът ги брои. Добави и 418, който не е присвоен, а резервиран и неизползван, и имаш трите излишни реда. Втората команда ги маха и дава 28, същото число, което излезе в 0.7.1 с програма, която чете CSV колона по колона; когато едно преброяване е важно, то се прави два пъти по два различни начина. 429 е определен в RFC 6585, 451 — в RFC 7725, а 418 фигурира като (Unused) с препратка към раздел 15.5.19 на RFC 9110, който обяснява защо е останал резервиран.

Решение 4

$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
content-type: application/json
cache-control: max-age=5, stale-if-error=600
Date: Wed, 21 Oct 2026 08:05:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"servicios":["catalogo","pagos"]}
$ curl -X PUT http://127.0.0.1:18081/modo/mentiroso
$ sleep 6
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store
Date: Wed, 21 Oct 2026 08:05:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"la base de datos no está disponible"}
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store
Date: Wed, 21 Oct 2026 08:05:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

{"error":"la base de datos no está disponible"}
$ curl -f http://127.0.0.1:18081/servicios
{"error":"la base de datos no está disponible"}
$ echo $?
0

А в терминала на програмата:

origen en http://127.0.0.1:18081 (PUT /modo/sano|mentiroso|honesto)
caché en http://127.0.0.1:18080/servicios
caché: guardé la respuesta 200 por 5 s
origen: ahora estoy mentiroso
caché: el origen respondió 200; no la guardo
caché: el origen respondió 200; no la guardo

Поправката ограничава щетата, но не я решава. Кешът вече не пази грешката, така че не я повтаря на всички в продължение на минути: всяка заявка отново пита източника. Но не сервира и доброто копие, което си стоеше там, защото stale-if-error се задейства само с 500, 502, 503 или 504, а пристигна 200. Клиентът вижда грешката при всяка заявка, проверката на състоянието продължава да минава, а доброто копие не служи за нищо. Сравни го с второто изпълнение от 0.8.3: 503 направи и трите неща правилно едновременно. Никоя заглавка не компенсира грешно число.

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

  • node -v започва с v24, а curl --version отговаря.
  • При работещ fig00_01.ts посочваш в изхода на curl -i реда на състоянието, всяка заглавка, празния ред и тялото и обясняваш защо Content-Length казва 28, а не 27.
  • Обясняваш какво показва curl -v, което curl -i не показва, и какво означават редовете с > и с <.
  • curl -i http://127.0.0.1:18080/codigo/503 срещу стенда дава 503 Service Unavailable с Retry-After: 120, а curl -i -X DELETE дава 405 с Allow: GET, HEAD, точно както в 0.4.3.
  • node fig00_03.ts отпечатва осемте реда от 0.6.2 и обясняваш, без да гледаш, защо 471 се третира като 400, а 600 — като 500.
  • Казваш наизуст правилото за x00 и в кой раздел на RFC 9110 се намира.
  • Повтори двете изпълнения от 0.8 и терминалите ти показват същото като урока, освен датата: запазената грешка с Age: 0 в първото и доброто копие с Age: 6 и код на изход 22 във второто.
  • Твоето преброяване на присвоените кодове 4xx в регистъра на IANA даде 28 и знаеш защо наивното преброяване дава 31.
  • Накрая не остава нито една програма, която да слуша: curl -i http://127.0.0.1:18080/ дава грешка 7.

Обобщение

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

  1. Кои са трите части на HTTP отговора и какво ги разделя?
  2. Защо клиентът би трябвало да пренебрегва фразата в реда на състоянието? Дай пример от този урок.
  3. На какъв въпрос отговаря всяко от петте семейства и защо 1xx е различно от другите четири?
  4. Какво трябва да направи клиентът с код, който не познава? А с такъв, по-голям от 599?
  5. Кой решава кои кодове съществуват и къде се проверява списъкът?
  6. Какво означава „кодът командва, а тялото обяснява“? Какво се случва, когато двете не казват едно и също?
  7. В кеша от 0.8, защо лъжливият 200 губи доброто копие, а 503 го запазва?
  8. С какво се различават грешка 7 и грешка 22 на curl и защо тази разлика е важна за revisor?

Източници

Всички бяха консултирани на 21 октомври 2026 г.

  1. RFC 9110, HTTP Semantics, раздел 15: петте семейства, правилото за x00 при непознат код и това за стойностите извън 100 до 599. Това е основният източник на този урок и на курса.
  2. RFC 9110, раздели 15.5 и 15.6: при 4xx и при 5xx сървърът би трябвало да обясни грешката в тялото, освен в отговор на HEAD.
  3. RFC 9110, раздели 15.3.2, 15.5.2 и 15.5.6: Location при 201, задължителното WWW-Authenticate при 401 и задължителното Allow при 405, заглавките на стенда.
  4. RFC 9110, раздел 16.2.1: регистърът на кодовете на състоянието на IANA и прегледът от IETF, който изисква всеки нов код.
  5. RFC 9112, HTTP/1.1, раздел 4: формата на реда на състоянието и защо клиентът би трябвало да пренебрегва фразата.
  6. RFC 9111, HTTP Caching, раздели 3 и 5.1: кога кешът може да запази отговор и какво казва заглавката Age.
  7. RFC 5861, HTTP Cache-Control Extensions for Stale Content, раздел 4: stale-if-error и затвореният списък от кодове, които се броят за грешка (500, 502, 503, 504).
  8. RFC 6585, Additional HTTP Status Codes: определя 428 и 429, както и незадължителния Retry-After при 429.
  9. Регистър на кодовете на състоянието на HTTP, IANA, версия, обновена на 15 септември 2025 г.: оттук идват 62-та присвоени кода, фразите на стенда и резервираният неизползван 418.
  10. Регистър на имената на полетата на HTTP, IANA: 260-те регистрирани заглавки и техният статус.
  11. Регистър на типовете проблеми на HTTP, IANA, създаден от RFC 9457: шест записа, първият about:blank. Използва се в Урок 5.
  12. Стандарт Fetch, WHATWG: определението за състояние ok (от 200 до 299), което използва respuesta.ok.
  13. Ръководство на curl: -i (--show-headers), -v, -I, -f и кодът на изход 22.
  14. Node.js 24: TypeScript и Node.js 24: HTTP: изпълнението на .ts чрез махане на типовете, синтаксисът, който не се допуска, препоръчителните опции на tsc и сигнатурата на writeHead.
  15. График на версиите на Node.js и nvm: датите на LTS за Node 24 и 26 и инсталаторът на nvm 0.40.8.

Допълнително четене

  • Поредицата „HTTP и Problem Details“ в En construcción, бюлетина на Hábil, в четири части. Първата, „200, който лъжеше“, разказва с различна история същата идея като този урок и разглежда тринайсет кода с API за контакти, сървър, който лъже, и друг, който не лъже; втората разглобява формата на Problem Details, който този курс преподава в Урок 5; третата и четвъртата го реализират в Spring Boot, Quarkus, ASP.NET Core, FastAPI и Go. Този курс не ги повтаря: ако ти се иска да видиш кодовете с друг пример или формата на твоя език, започни оттам.

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

заглавка (header field) всеки ред Nombre: valor между реда на състоянието и тялото; имената не различават главни от малки букви
споделен кеш програма между клиентите и сървъра, която пази отговори и ги повтаря на всеки клиент
проверка на състоянието (health check) автоматичен тест, който решава дали една услуга е наред, почти винаги по кода на състоянието
код на състоянието трицифреното число в реда на състоянието; първата му цифра е семейството
семейство класът на един код според първата му цифра: от 1xx до 5xx
IANA организацията, която поддържа официалните регистри на кодове, заглавки и медийни типове
ред на състоянието първият ред на отговора: версия, код и фраза
reason phrase фразата след кода (OK, Not Found); декоративна, клиентът би трябвало да я пренебрегва
междинен отговор отговор 1xx: пристига преди окончателния отговор и никога не го замества
LTS long-term support: линия от версии, която получава поправки в продължение на години
Да обсъдим вашия проект

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