Если вы только начинаете знакомство с HTTP и хотите наконец понять, что же на самом деле происходит, когда браузер открывает страницу, а сервер отвечает на запросы API – эта статья для вас. Мы разберём HTTP с нуля: от структуры запроса до кодов ответов, от методов до версий протокола.
Что такое HTTP
HTTP – это протокол прикладного уровня, работающий поверх TCP (а в HTTP/3 – поверх QUIC). Если упростить: клиент отправляет запрос, сервер возвращает ответ. Всё взаимодействие в вебе строится на этом обмене сообщениями – HTML, JSON, изображения, файлы передаются именно так.
Сам протокол stateless. Каждый запрос независим, предыдущий забывается сразу после отправки ответа. Но это не значит, что приложение обязано быть таким же – поверх HTTP легко построить полноценную сессию с авторизацией. Именно для этого существуют куки, токены и другие механизмы.
В этой статье разберём, как устроены HTTP-запросы и ответы, что означают статус-коды и чем отличаются методы. В следующей части подробно разберём жизненный цикл HTTP-запроса.

HTTP-запрос.
Запрос состоит из стартовой строки (request line), заголовков и необязательного тела.

Стартовая строка включает:
HTTP-метод

Адрес запрашиваемого ресурса (request-target)

Версию протокола HTTP

Какие существуют методы HTTP запроса
Методов HTTP несколько, но в реальной жизни вы постоянно будете сталкиваться с парой-тройкой из них. Остальные либо для специфичных задач, либо вообще редко встречаются.
GET – запрашивает ресурс с сервера. Самый популярный метод. Именно его браузер отправляет, когда вы открываете сайт. У GET-запроса формально может быть тело, но на практике его никто не шлёт – серверы и прокси могут такое проигнорировать или отвалиться с ошибкой. А вот в ответе тело есть почти всегда – иначе что за ресурс без данных. Но бывают исключения: статусы 204 No Content или 304 Not Modified, когда сервер сознательно решает ничего не отдавать.
HEAD – работает как GET, только сервер не присылает тело ответа, только заголовки. Удобно проверить, существует ли файл, не скачивая его целиком, или посмотреть Content-Length, чтобы узнать размер.
POST – отправляет данные на сервер. Обычно это формы, файлы или JSON. У запроса есть тело, у ответа – как повезёт. Часто сервер возвращает созданный ресурс или просто статус, что всё ок.
PUT – заменяет ресурс целиком. Отправили объект – сервер заменил существующий ресурс. Если ресурса не было – может создать, но это уже детали реализации конкретного API.
PATCH – для частичных изменений. Не тащить же весь объект, если нужно одно поле поправить. Отправили только то, что поменялось, – сервер разобрался.
DELETE – удаляет ресурс. Тут всё понятно.
OPTIONS – спрашивает у сервера, что тут вообще можно делать. Какие методы разрешены, какие заголовки. Браузеры иногда сами отправляют OPTIONS перед сложными запросами – это называется preflight.
TRACE – отладочный метод. Сервер возвращает в ответе то, что получил от клиента. Полезно посмотреть, модифицируют ли что-то прокси по пути. В продакшене обычно отключают из соображений безопасности.
CONNECT – используется прокси-серверами для установки туннелей. Например, когда клиент хочет открыть HTTPS-соединение через прокси. В веб-разработке с ним почти не сталкиваешься напрямую.

Подробнее о каждом методе можно прочитать здесь.
URI модель и различия с URL
URI (Uniform Resource Identifier) – это просто строка, идентифицирующая ресурс. Неважно, что это за ресурс – файл, картинка, страница или что-то абстрактное вроде конкретного пользователя. Спецификация это не регламентирует. Главное, что URI только идентифицирует, но ничего не говорит о доступности. Ресурс может лежать где-то в локальной сети, быть доступным через интернет или вообще существовать только в теории – URI просто даёт ему имя. Все формальности по поводу формата описаны в RFC 3986, если кому-то интересно покопаться. В различиях между URI, URL и URN можно прочитать тут.
Структура URI в общем виде выглядит так:

Scheme – http, https, ftp, mailto, urn. Задаёт контекст.
Authority – обычно хост с портом (может включать и userinfo). Указывает, на каком узле искать.
Path – путь внутри хоста. Например, /products/123.
Query – параметры запроса после вопросительного знака. Необязательно.
Fragment – часть после решётки, якорь на конкретное место внутри ресурса. На сервер не уходит, только браузер с ним работает.
Теперь про различия, в которых постоянно путаются. URI – это общее понятие. У него есть два уточняющих подвида: URL и URN.
URL (Uniform Resource Locator) – тот же URI, но с привязкой к местоположению. Он не просто говорит "это ресурс", а ещё и объясняет, где конкретно он лежит и по какому протоколу к нему обращаться. Схемы http/https как раз про это.

URN (Uniform Resource Name) – URI со схемой urn, который даёт ресурсу имя, никак не привязанное к тому, где он физически находится. То есть URN идентифицирует, но не локализует.

Коротко по сути:
URI – идентификатор сам по себе.
URL – идентификатор с указанием, где лежит и как брать.
URN – только имя, без адреса.
Каждый URL и URN являются URI. В HTTP-запросах передаётся именно URI. Но в веб-разработке мы всегда имеем дело с URL – URI, у которых схема http или https. Поэтому дальше разберём структуру URL подробнее.
Структура URL

URL – это тот самый URI, который ещё и объясняет, как до ресурса добраться и где он вообще находится.
Что в него входит:
Схема – она же протокол. http или https, например. Определяет, как общаться с сервером.
Хост – доменное имя. Может быть одноуровневым (site.ru) или с поддоменами (sub.domain.com). Про уровни доменов поговорим отдельно во второй части.
Порт – если явно указан. Для HTTP по умолчанию 80, для HTTPS – 443. В браузере их обычно не пишут, но технически порт всегда есть.
Путь – уже конкретное место на сервере. Вроде /products/123 или /images/cat.jpg.
Query – необязательная часть после вопросительного знака. Те самые параметры вроде ?page=2&sort=asc.
Фрагмент – то, что после #. Указывает на конкретный раздел внутри страницы. На сервер не уходит, браузер сам с ним разбирается.
Абсолютные и относительные URL
Тут всё упирается в контекст.
Когда вы вбиваете адрес в строку браузера, браузер не знает ничего – ни откуда вы пришли, ни где находитесь. Поэтому нужен полный, абсолютный URL. Со схемой, хостом и всем остальным.
А когда ссылка встречается внутри HTML-страницы, браузер уже в курсе, на каком сайте вы сейчас. Он берёт базовый адрес документа и достраивает недостающие части сам. Поэтому внутри страниц можно писать просто /products/123 или даже ../../images/pic.jpg – браузер разберётся.
Относительные URL
При отсутствии протокола:

Браузер будет использовать тот же протокол, что и для загрузки документа:

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

Браузер будет использовать тот же протокол и то же доменное имя, что и для загрузки документа, на котором размещен этот URL.

При отсутствии протокола и доменного имени, а путь начинается не с "/":
Текущий ресурс, на котором мы находимся:


Браузер попытается найти документ в подкаталоге, содержащем текущий ресурс.

При отсутствии протокола, доменного имени, а путь начинается с "..":
Текущий ресурс, на котором мы находимся:


Используется, чтобы сообщить браузеру, что мы хотим подняться на один уровень выше относительно текущего пути.

Только якорь #, отсутствуют все части, кроме самого якоря:
Текущий ресурс, на котором мы находимся:


Браузер будет использовать URL текущего документа и заменит или добавит к нему часть, содержащую якорь. Используется, когда хотим создать ссылку на определенную часть текущего документа.

Имя пользователя и пароль в URL
В URL-адреса может быть так же включен логин и пароль.

Если сайт использует механизм аутентификации HTTP, то в компонент authority можно включить логин и пароль, чтобы мгновенно войти на сайт и обойти диалоговое окно авторизации. Которое появится, если мы не укажем логин и пароль.


Передача учетных данных в URL считается небезопасной практикой, поскольку такие данные могут попадать в логи, историю браузера и заголовки Referer. Современные приложения не используют этот механизм.
Версии протокола HTTP
HTTP/0.9 (1991)
Самая первая версия, которая была почти что игрушечной. Поддерживала только один метод – GET. Никаких заголовков, никакой версии в запросе. Просто строка вроде:

Как сервер понимал, какой именно сайт имеется в виду, если запрос шёл на IP-адрес? А никак. Тогда это и не требовалось – каждый сервер крутил только один сайт. Соединение устанавливалось напрямую с хостом, например через telnet:

И ответ летел обратно.
HTTP/1.0 (1996)
Тут уже пошла серьёзная работа. В запросе появилась версия протокола (GET /page.html HTTP/1.0), добавили заголовки (например, Content-Type), чтобы передавать не только HTML, но и картинки, файлы.

Методов стало больше: POST и HEAD. Но соединение по-прежнему закрывалось после каждого ответа. Заголовок Host ещё не был обязательным – сервер всё ещё определялся по IP.
HTTP/1.1 (1997)
Главное изменение – заголовок Host стал обязательным. Почему? Потому что серверы научились держать много сайтов на одном IP (виртуальный хостинг). Без Host непонятно, какой именно сайт запрашивают.

Если забыть его указать, сервер отвечает 400 Bad Request.
Соединение по умолчанию стало keep-alive – не закрывалось после каждого запроса, что значительно ускорило загрузку страниц. Добавили кучу методов: PUT, DELETE, OPTIONS, TRACE, CONNECT. А в 2010 году к ним присоединился PATCH.
HTTP/2 (2015)
Тут разработчики решили, что текстовый формат пора менять. HTTP/2 стал бинарным – никаких читаемых строк, всё упаковывается в двоичные фреймы. Главная фишка – мультиплексирование: в одном TCP-соединении можно передавать несколько запросов и ответов одновременно, не блокируя друг друга. Раньше, чтобы загрузить много ресурсов, браузер открывал кучу соединений, теперь всё летает по одному.
Правда, осталась проблема на уровне TCP – head-of-line blocking. Если один пакет теряется, всё соединение ждёт его пересылки, даже если остальные данные уже готовы. Но это уже ограничение самого TCP, не протокола.
Текстовой строки запроса больше нет, логически тот же GET из HTTP/1.1 выглядит так:

HTTP/3 (2022)
Самый свежий стандарт. Он отказался от TCP и использует QUIC – протокол поверх UDP. Зачем? QUIC решил проблему head-of-line blocking на уровне транспорта и ускорил установку соединения. В итоге сайты открываются ещё быстрее, особенно на плохих каналах.
HTTP/1.0 - RFC 1945.
HTTP/1.1 - RFC 9112.
HTTP/2 - RFC 7540.
HTTP/3 - RFC 9114.
В следующих частях разберём каждую версию подробнее. Пока главное запомнить: формат HTTP-сообщения сильно зависит от того, с какой версией протокола вы работаете.
Заголовки в HTTP-запросах
HTTP-заголовки – это такие пары ключ-значение, которые передаются вместе с запросом или ответом и уточняют, что именно мы хотим, что за данные шлём или как их интерпретировать. Без них протокол был бы слепым – сервер бы не знал, какой браузер у клиента, какой язык предпочтительнее, а клиент не понимал бы, что за контент ему прилетел.
Их обычно делят на несколько групп, хотя классификация условная и иногда одни и те же заголовки могут попадать в разные категории.
Заголовки запроса. Это те, что клиент шлёт серверу, чтобы рассказать о себе или уточнить, что именно ему нужно. Например, User-Agent (кто я такой), Accept (какие форматы данных я понимаю), Authorization (вот мой токен, пустите). Без них серверу пришлось бы гадать.
Заголовки ответа. Тут сервер отвечает взаимностью: рассказывает про себя или добавляет служебную информацию. Классика – Server (апач, нджинкс или что-то самописное), Location (редирект, ищи там), Retry-After (приходи позже). Иногда без них никак, например, при авторизации нужно отдать WWW-Authenticate.
Заголовки представления (Representation headers). Они описывают, что именно лежит в теле сообщения. Самый известный – Content-Type (это JSON, картинка или HTML), плюс Content-Language, Content-Encoding (сжато gzip или нет). Если перепутать, клиент попытается распарсить HTML как JSON и упадёт с ошибкой.
Заголовки управления передачей (Message Framing). Они нужны, чтобы понять, где заканчивается тело запроса/ответа и начинается следующее сообщение. Главные тут Content-Length (сколько байт читать) и Transfer-Encoding: chunked (данные идут кусками, смотри на размер каждого чанка). Если сервер ошибётся с длиной, клиент либо откусит лишнего, либо зависнет в ожидании.


Ниже приведены примеры наиболее часто используемых заголовков каждой категории.

Подробное описание назначения каждого заголовка выходит за рамки данной статьи. Ознакомиться со всеми заголовками можно по ссылке.
Тело запроса
Тело запроса – это данные, которые клиент отправляет на сервер. В HTTP оно идёт после заголовков, и между ними обязательно должна быть пустая строка. Без этой пустой строки сервер не поймёт, где заканчивается служебная информация и начинаются сами данные.
Не все запросы имеют тело. GET и HEAD, например, обычно не имеют тела – большинство серверов его игнорируют. А вот POST, PUT и PATCH без тела почти не бывает, потому что через них как раз и отправляют то, что меняет состояние на сервере: формы, JSON, файлы. DELETE в теории тоже может что-то передавать в теле, но на практике так почти никто не делает, и многие серверы просто игнорируют тело в DELETE.
Что именно лежит в теле, определяют заголовки. Content-Type говорит, что за тип данных: JSON, XML, картинка или просто текст. Content-Length – сколько байт сервер должен прочитать. Если забыть указать Content-Type, серверу придётся гадать, и обычно это кончается ошибкой 415 Unsupported Media Type.
В качестве тела может передаваться что угодно: JSON (сейчас это стандарт для API), XML (на старых проектах), form-data (когда в форме есть файлы), бинарные данные или поток. Главное, чтобы и клиент, и сервер понимали, с чем имеют дело.

После того как мы разобрали структуру HTTP-запроса и его ключевые компоненты, сформируем корректный HTTP-запрос и разберём его.
Начнём со стартовой строки:
В качестве метода используем POST, поскольку он предполагает передачу данных в теле запроса и изменение состояния ресурса.
В качестве request-target используем /post. Согласно HTTP/1.1 в стартовой строке передаётся только путь и query-компонент, а хост указывается отдельно в заголовке.
Версию протокола мы используем HTTP/1.1.
Мы сделали стартовую строку, которая выглядит следующим образом:

Приступим к написанию заголовков:
Начнём с самого важного заголовка – Host. Указываем Host: httpbin.org. Устанавливается TCP соединение к IP-адресу сервера. Заголовок Host сообщает серверу, какой виртуальный хост требуется обслужить. В HTTP/1.1 этот заголовок является обязательным.
Content-Type – указываем как серверу интерпретировать тело запроса, каким парсером его обрабатывать. Указываем Content-Type: application/json.
Content-Length – указываем сколько байт читать после пустой строки, показываем то, где заканчивается тело. Мы будем отправлять тело {"name":"habr","id":1}. Как нам узнать сколько байт нужно отправить? Мы можем посчитать посимвольно, но это слишком долго и можно сбиться. Мы воспользуемся командой в PowerShell. У нас получилось 22 байта. Указываем: Content-Length: 22.

Connection – указываем, что после ответа соединение нужно закрыть. HTTP/1.1 по умолчанию использует keep-alive, поэтому мы явно просим закрыть соединение. Указываем: Connection: close
После этого у нас получился такой блок заголовков:

Дальше нам необходимо оставить одну строку пустой и перейти к написанию тела запроса.
Как я уже указывал выше – наше тело состоит из строки {"name":"habr","id":1}. Просто указываем его после пустой строки. У нас получился вот такой запрос:

Переходим к отправке этого запроса.

Ну и смотрим, что нам прилетело обратно. Сервер ответил статусом 200 ОК – значит, запрос принял, разобрал, данные ушли куда надо. Теперь про то, как вообще устроен ответ от сервера.
HTTP-ответ
Когда сервер разобрался с запросом, он собирает ответ. Структура примерно та же, что и у запроса: сначала статусная строка (для HTTP/1.1), потом заголовки, потом пустая строка и, если нужно, тело.
В статусной строке три части: версия протокола, трёхзначный код и текстовое пояснение к нему. Код – это самое главное. Пояснение вроде OK, Not Found или Internal Server Error уже опционально, браузеры на него не особо смотрят.
Что именно сервер вернёт, зависит не только от того, какой метод использовали, но и от того, что там внутри у сервера случилось. Например, если стучались GET-ом за страницей и всё хорошо –придёт 200 и в теле сама страница. А если страница с прошлого раза не поменялась, сервер может сказать 304 Not Modified и тело не отдавать – браузер тогда сам достанет старую версию из кэша.

Коды ответов
Код состояния ответа HTTP показывает, был ли успешно выполнен определённый HTTP запрос.
Ответы сгруппированы в 5 классов:
Информационные ответы (1XX)
Успешные ответы (2XX)
Сообщения о перенаправлении (3XX)
Ошибки клиента (4XX)
Ошибки сервера (5XX)

Информационные (1xx):
100 Continue – сервер говорит: «давай, шли данные дальше, я готов». Бывает, когда клиент сначала отправляет заголовки и ждёт подтверждения, прежде чем пихать большое тело.

101 Switching Protocols – сервер соглашается переключиться на другой протокол, как просили в заголовке Upgrade. Например, при переходе на WebSocket.

Успешные (2xx):
200 OK – классика. Запрос выполнен, данные в теле, если они предполагались.
201 Created – приходит на POST или PUT, когда на сервере что-то создали. Часто в ответе ещё отдают Location, где этот новый ресурс лежит.
204 No Content – сервер всё сделал, но тело пустое. Например, удалили запись и возвращать нечего.

Перенаправления (3xx):
301 Moved Permanently – ресурс навсегда переехал. Старый адрес больше не работает, используйте новый из Location.
302 Found – временный переезд. Сейчас иди туда, но потом ресурс снова будет здесь.
304 Not Modified – приходит на GET с заголовками If-Modified-Since или If-None-Match, если ресурс не менялся. Тела нет, браузер берёт данные из кэша.

Ошибки клиента (4xx):
400 Bad Request – сервер не понял, что от него хотят. Синтаксическая ошибка, битые заголовки или кривое тело.
401 Unauthorized – нужна аутентификация. Обычно в ответе висит WWW-Authenticate с подсказкой, как именно авторизоваться.

403 Forbidden – сервер вас узнал, но доступ не даёт.
404 Not Found – нет такого ресурса. Самый популярный код.
429 Too Many Requests – забанили за частоту. Клиент слишком часто стучится, приходите позже.

Ошибки сервера (5xx):
500 Internal Server Error – всё упало, но непонятно почему. Самая частая серверная ошибка.
502 Bad Gateway – сервер (обычно прокси или шлюз) сходил наверх, а оттуда пришёл неправильный ответ.
503 Service Unavailable – сервер временно не тянет нагрузку или лежит на обслуживании.

504 Gateway Timeout – прокси ждал ответ от вышестоящего сервера, но не дождался.
Со всеми статусами кодов можно ознакомиться по ссылке. Описывать их все в статье было бы избыточным.
Если вы только начинаете – надеюсь, стало понятнее, что происходит, когда вы открываете сайт или дёргаете API. Если вы уже опытный – возможно, освежили в памяти детали.
Следующая часть будет про жизненный цикл: как соединения открываются и закрываются, что такое DNS, keep-alive и прочее.
Буду рад любой критике и замечаниям. Если где-то ошибся или что-то можно объяснить лучше – пишите в комментариях, в следующей части постараюсь всё поправить и учесть.
Ещё пишу заметки про безопасность в Telegram – а если интересно, заглядывайте.
