Анатомия запроса и ответа
В прошлом уроке мы установили, что HTTP/1.1 — текстовый протокол поверх TCP. В этом уроке полезем в детали: что именно лежит в HTTP-сообщении, какая у него структура, и как увидеть это руками. К концу урока вы сможете прочитать любой HTTP-обмен, как программист читает код, а не как пользователь смотрит на иероглифы.
Структура HTTP-сообщения
И запрос, и ответ имеют одинаковую общую структуру:
Все строки в HTTP/1.1 разделены \r\n (CR LF, два байта: 0x0D 0x0A). Это исторический выбор из эпохи telnet. На вашем компьютере при выводе на экран \r\n отображается как обычный перенос строки, и вы не видите разницы между \n и \r\n — но протокол строгий: только \r\n.
Анатомия HTTP-запроса
Разберём вот такой запрос подробно:
POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Content-Length: 28
Authorization: Bearer abc123
User-Agent: MyClient/1.0
{"name":"Alice","age":30}
Несколько важных моментов:
- Имена заголовков case-insensitive.
Content-Type,content-type,CONTENT-TYPE— это один заголовок. По конвенции пишут в формате Pascal-Case-Через-Дефис. - Значения заголовков case-sensitive (обычно).
Bearer abc123≠bearer abc123для большинства серверов. - Один заголовок может встретиться несколько раз. Например,
Set-Cookieчасто несколько штук в ответе, илиAccept-Encoding: gzipиAccept-Encoding: deflate— стандарт разрешает. - Между двоеточием и значением — пробел.
Host: api.example.com, неHost:api.example.com. Технически второе тоже валидно, но пробел — стандарт.
Анатомия HTTP-ответа
Ответ от сервера на запрос выше может выглядеть так:
HTTP/1.1 201 Created
Date: Thu, 14 May 2026 12:34:56 GMT
Content-Type: application/json
Content-Length: 89
Location: /api/users/42
Cache-Control: no-cache
{"id":42,"name":"Alice","age":30,"created_at":"2026-05-14T12:34:56Z"}
Reason phrase (Created) — это исторический рудимент. В HTTP/2 его уже нет (бинарный формат), но семантика та же: 201. Когда вы пишете API на Go или Python, фреймворк сам ставит правильный текст. Ставить кастомный (типа 420 Enhance Your Calm от Twitter) технически разрешено, но бессмысленно.
Реальный raw HTTP через nc (netcat)
TCP-сервер и клиент на Python: echo шаг за шагом
Самый честный способ увидеть HTTP — использовать nc (netcat), который просто открывает TCP-соединение и шлёт байты, ничего не зная про HTTP. Это дико полезно для отладки.
# Откройте TCP-соединение к httpbin.org:80 и руками отправьте HTTP-запрос:
printf 'GET /get HTTP/1.1\r\nHost: httpbin.org\r\nAccept: */*\r\nConnection: close\r\n\r\n' | nc httpbin.org 80
Что вы увидите — буквально HTTP-ответ от сервера, точно так, как он улетел по TCP:
HTTP/1.1 200 OK
Date: Thu, 14 May 2026 12:45:00 GMT
Content-Type: application/json
Content-Length: 312
Connection: close
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
{
"args": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org"
},
"origin": "1.2.3.4",
"url": "http://httpbin.org/get"
}
Заметьте:
- Connection: close — мы попросили сервер закрыть TCP-соединение после ответа. Без этого
ncпросто висел бы, ожидая больше данных. По умолчанию HTTP/1.1 keep-alive (соединение остаётся открытым для следующих запросов). - \r\n — мы использовали
printfс явными\r\n, потому чтоechoдобавляет только\n. HTTP строго требует CRLF. - Пустая строка —
\r\n\r\nв конце означает «конец заголовков, тела нет». Без этого сервер бы ждал ещё заголовков.
Метод печатания HTTP вручную через nc — лучшая отладка, когда что-то не работает в библиотеке. Если ваш Python-скрипт получает 400 Bad Request, а вы не понимаете, почему — выведите request объект (logging), скопируйте байт-в-байт и пошлите через nc. Если nc получит 200 — проблема в библиотеке. Если 400 — проблема в самом запросе.
То же самое через curl -v
curl -v показывает то же самое, только удобнее. Префиксы:
>— что мы отправили на сервер<— что сервер прислал*— debug-вывод от curl (TLS, DNS, и т.д., не часть HTTP)
curl -v https://httpbin.org/get
Пример вывода (упрощённо):
* Trying 54.236.246.173:443...
* Connected to httpbin.org (54.236.246.173) port 443
* TLS handshake completed (TLS 1.3 / TLS_AES_256_GCM_SHA384)
> GET /get HTTP/2
> Host: httpbin.org
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/2 200
< date: Thu, 14 May 2026 12:50:00 GMT
< content-type: application/json
< content-length: 312
< server: gunicorn/19.9.0
<
{
"args": {},
"headers": {...},
"origin": "...",
"url": "https://httpbin.org/get"
}
Заметьте, что в HTTP/2 нет reason phrase (просто 200, без OK) — это бинарный фрейм, текстовый reason там не передаётся. Заголовки в HTTP/2 строчные (content-type, не Content-Type) — спецификация требует. На уровне семантики это та же информация.
Тело: что в нём может быть
Тело может содержать что угодно. Сервер сообщает формат через Content-Type. Самые частые:
Есть также параметры в Content-Type. Например: application/json; charset=utf-8. Параметры идут через ;. Для JSON charset=utf-8 — стандарт по умолчанию (RFC 8259), но многие серверы и клиенты его всё равно указывают.
Content-Length и chunked encoding
Как клиент понимает, что тело закончилось? Два способа:
Способ 1: Content-Length. Сервер заранее говорит размер. Клиент читает столько байт и знает, что дальше — следующий ответ или конец.
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 89
{"id":42,"name":"Alice","age":30,"created_at":"2026-05-14T12:34:56Z"}
Способ 2: Transfer-Encoding: chunked. Сервер не знает размер заранее (например, генерирует ответ потоково). Шлёт куски с указанием размера каждого.
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
1c
{"event":"user_created"}
1f
{"event":"order_placed","id":1}
0
Каждый chunk — это размер в hex, потом данные, потом \r\n. Финальный chunk имеет размер 0 — это маркер конца. Полезно для long-polling, server-sent events (модуль 11), стриминга больших ответов.
Если в одном ответе и Content-Length, и Transfer-Encoding: chunked — это уязвимость ‘HTTP request smuggling’. Современные серверы и прокси умеют детектировать и блокировать. Никогда не делайте так в собственных API.
Query string vs body
Где передавать данные — в URL или в теле? Зависит от метода и типа данных:
Конвенция:
- GET — параметры в query string. Тело технически разрешено, но в большинстве библиотек игнорируется.
- POST / PUT / PATCH — данные в теле, обычно JSON.
- DELETE — обычно ID ресурса в URL (
/api/users/42), без тела.
Query string использует URL-encoding для специальных символов:
# Пробел становится %20 или +
# Кириллица -- UTF-8 в %XX
curl -G --data-urlencode 'q=привет мир' --data-urlencode 'page=2' \
https://httpbin.org/get
Попробуй сам
Попробуйте все три способа увидеть HTTP:
# 1. Через nc (raw):
printf 'GET /get HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n' \
| nc httpbin.org 80
# 2. Через curl -v (с TLS):
curl -v https://httpbin.org/get 2>&1 | head -25
# 3. POST с JSON-телом:
curl -v -X POST https://httpbin.org/post \
-H 'Content-Type: application/json' \
-d '{"name":"Alice","age":30}'
# Посмотрите, как curl сам добавил Content-Length и Accept
# 4. То же через httpie (если установили):
http POST https://httpbin.org/post name=Alice age:=30
# httpie сам поставит Content-Type: application/json и сериализует
Откройте 2-3 терминала, поделайте разные варианты, посмотрите вывод. Через 10 минут такой практики у вас в голове сложится чёткая картина: запрос — это start-line + заголовки + тело, всё разделено CRLF.