Почти всё, что вы делаете в вебе, — открываете страницу, отправляете форму, дёргаете чужой API — это обмен HTTP-запросами и ответами. HTTP (HyperText Transfer Protocol) — это простой текстовый диалог между клиентом и сервером: клиент говорит «дай мне вот это» или «сохрани вот это», сервер отвечает «держи» или «не могу, вот почему». Никакой магии: если понять анатомию одного запроса и одного ответа, понятен будет весь протокол. И один вывод стоит унести сразу: когда ответ потерялся в сети и клиент повторил запрос, во что превратится повтор, решает метод, и это самое практичное, что есть в HTTP.
Хорошая новость — в версии HTTP/1.1, на которой мы всё и разберём, протокол человекочитаемый. Запрос и ответ можно распечатать глазами и прочитать как письмо: сверху что просят, ниже условия, в конце — содержимое. В HTTP/2 и HTTP/3 те же самые сообщения едут по проводу уже упакованными в двоичный вид: смысл не поменялся, а вот прочитать их глазами больше нельзя — про это в статье про версии HTTP. Разберём эту структуру по частям, а потом посмотрим на методы, статусы и заголовки — три вещи, которыми backend-разработчик пользуется каждый день.
Прежде чем разбирать эти части по отдельности, стоит увидеть, ради чего их различают: один и тот же сбой сети заканчивается по-разному — всё решают метод и заголовки запроса.
Сбой один и тот же — ответ не дошёл, клиент повторил запрос. У PUT повтор перезаписал те же данные, у POST — создал второй заказ, а POST с ключом идемпотентности узнал повтор и вернул тот же заказ. Метод и заголовки решают, во что превратится повтор.
Анатомия запроса и ответа
Любой HTTP-запрос состоит из четырёх частей, идущих строго по порядку: стартовая строка, заголовки, пустая строка и (необязательно) тело.
Вот как выглядит запрос целиком:
POST /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGc...
{"productId": 42, "quantity": 2}
Разберём построчно:
- Стартовая строка
POST /orders HTTP/1.1— что делаем (методPOST), с чем (путь/orders) и по какой версии протокола. - Заголовки — пары «имя: значение», метаданные запроса: на каком хосте искать ресурс, в каком формате тело, кто мы такие.
- Пустая строка — граница, отделяющая заголовки от тела. Без неё сервер не поймёт, где кончились метаданные.
- Тело — сами данные. У
GETего обычно нет, уPOST/PUT— есть.
Четыре части запроса идут строго в этом порядке: смотрите на пустую строку, именно она говорит получателю, что заголовки кончились и дальше идёт тело.
Ответ устроен так же зеркально:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /orders/1001
{"id": 1001, "status": "created"}
Стартовая строка ответа несёт код статуса (201) и его текстовую расшифровку (Created). Дальше — те же заголовки, пустая строка и тело. Вот и весь протокол: диалог из таких сообщений.
Методы: что мы хотим сделать
Метод — это глагол запроса, он говорит серверу о намерении. Основных пять:
- GET — «дай мне ресурс». Чтение, ничего не меняет. Запрос списка заказов, одной страницы, картинки — всё это
GET. - POST — «создай новое» или «выполни действие». Отправка формы, создание заказа. Каждый вызов, как правило, создаёт новую сущность.
- PUT — «замени ресурс целиком» вот этим содержимым. Если ресурса нет — создаст, если есть — перезапишет полностью.
- PATCH — «измени ресурс частично». Прислать только те поля, что поменялись, а не весь объект. Повторная отправка одного и того же PATCH может дать разный результат, а может и не дать — это зависит от того, что внутри: «поставь цену 100» повторять безопасно, «добавь позицию в список» — уже нет.
- DELETE — «удали ресурс».
Метод — это договорённость о смысле, а не техническое ограничение. Технически можно спрятать удаление за GET, но это нарушит все ожидания: браузеры, прокси и поисковики считают GET безопасным чтением и могут вызвать его когда угодно. Поэтому глагол выбирают по смыслу операции.
Два метода из списка стоят особняком, потому что их почти не пишут руками, а сталкиваются с ними постоянно.
HEAD — то же, что GET, но без тела ответа: приходят только заголовки. Им проверяют, существует ли ресурс и какого он размера, не скачивая его; так же работают проверки работоспособности и предварительная проверка больших файлов. Практическое следствие для сервера: ручка, отвечающая на GET, обязана отвечать и на HEAD теми же заголовками — иначе проверяющие инструменты увидят ошибку там, где всё в порядке.
OPTIONS — «что здесь можно». Сам по себе он используется редко, зато именно его браузер отправляет сам перед межсайтовым запросом: это предзапрос, и от его ответа зависит, уйдёт ли ваш настоящий запрос вообще. Механика разобрана в разделе про межсайтовые запросы ниже, а здесь важно другое: если ваш сервис не отвечает на OPTIONS (или отвечает ошибкой авторизации, потому что предзапрос идёт без заголовков аутентификации), фронтенд не сможет с ним работать, и в журналах вы увидите одинокие OPTIONS без последующих запросов.
Безопасные и идемпотентные методы
Ответ потерялся в сети, клиент подождал и повторил запрос. Для GET это ничего не стоит, для POST /orders это второй заказ. Два свойства методов, которые звучат заумно, отвечают ровно на этот вопрос: что будет, если запрос повторить.
| Метод | Безопасный | Идемпотентный |
|---|---|---|
| GET | да | да |
| PUT | нет | да |
| DELETE | нет | да |
| POST | нет | нет |
| PATCH | нет | зависит от формата тела |
Безопасный (safe) метод ничего не меняет на сервере. GET безопасен: сколько раз ни запрашивай страницу, состояние сервера не сдвинется. Поэтому браузер спокойно делает GET при переходе по ссылке, а прокси — кэширует.
Идемпотентный метод можно повторить сколько угодно раз, и результат будет тем же, что после первого раза. PUT идемпотентен: «поставь адрес X» — хоть один раз, хоть пять, в итоге адрес равен X. DELETE тоже: удалили один раз, повторный запрос застанет ресурс уже удалённым — состояние не изменилось. А вот POST не идемпотентен: три POST /orders создадут три заказа.
Почему это важно для backend. Сети ненадёжны: ответ может потеряться по дороге, и клиент, не дождавшись, повторит запрос. Если метод идемпотентен, повтор безопасен — ничего не задвоится. Если нет (как POST), повтор может создать дубликат, и защищаться от этого приходится отдельно — ключом идемпотентности, при котором сервер узнаёт повторную попытку и не создаёт вторую сущность.
Коды статусов: что ответил сервер
Код статуса — трёхзначное число в ответе, разбитое на пять классов по первой цифре. Класс уже говорит главное, а конкретное число уточняет.
Первая цифра кода отвечает сразу на два вопроса, кто виноват и что делать: главная граница проходит между 4xx и 5xx, повторять с задержкой стоит только вторые.
2xx — успех. Всё хорошо.
200 OK— стандартный успешный ответ, тело содержит результат.201 Created— создан новый ресурс (типичный ответ наPOST); в заголовкеLocation— адрес созданного.
3xx — перенаправление. Ресурс не здесь, иди в другое место.
301 Moved Permanently— переехал навсегда, запомни новый адрес. Браузер понимает это буквально: запоминает надолго и в следующий раз идёт на новый адрес, вообще не спросив сервер. Ошиблись адресом в301— чинить придётся у каждого клиента, а не у себя, поэтому «навсегда» включают последним, когда всё проверено.302 Found— переехал временно, старый адрес остаётся рабочим и запоминать его не надо.307и308— то же самое, временно и навсегда, но с важным обещанием: метод и тело сохраняются. Со старыми301и302браузеры исторически превращалиPOSTвGETпо дороге, и это до сих пор источник сюрпризов — поэтому перенаправлять не-GET-запросы принято через307/308.304 Not Modified— «у тебя уже есть свежая копия, бери из кэша». Ответ без тела, про него ниже, в заголовках.
4xx — ошибка клиента. Виноват тот, кто прислал запрос: не то попросил, не так оформил.
400 Bad Request— запрос кривой: невалидный JSON, не хватает поля.401 Unauthorized— не представился, нужна аутентификация (кто ты?).403 Forbidden— представился, но прав на это нет (я знаю, кто ты, но нельзя).404 Not Found— такого ресурса нет.409 Conflict— конфликт с текущим состоянием: например, создаёшь то, что уже существует.429 Too Many Requests— слишком часто, притормози. Самый ходовой4xxв жизни сервиса: его и получают от чужих API, и отдают своим клиентам.
5xx — ошибка сервера. Клиент всё сделал правильно, сломалось на стороне сервера.
500 Internal Server Error— что-то упало внутри, необработанное исключение.502 Bad Gateway— сервер выступал посредником и получил невнятный ответ от того, к кому обращался.503 Service Unavailable— сервис временно недоступен (перегрузка, обслуживание).
Практическая разница между 4xx и 5xx огромна. 4xx обычно значит «исправь запрос, повтор с тем же телом не поможет», 5xx — «на моей стороне беда, попробуй позже», и вот такие ответы часто имеет смысл повторять с задержкой. Но из первого правила есть два исключения, которые стоит запомнить: 429 (слишком часто) и 408 (сервер устал ждать сам запрос) повторять как раз нужно — менять в запросе ничего не надо, надо подождать. Причём при 429 сервер нередко сам говорит сколько именно, заголовком Retry-After.
Ключевые заголовки
Заголовки — это условия сделки, метаданные вокруг тела. Их десятки, но для старта хватит нескольких:
- Content-Type — в каком формате тело:
application/json,text/html,image/png. По нему получатель понимает, как разбирать содержимое. - Accept — какой формат ответа устроит клиента:
application/json. Сервер выбирает по нему и подтверждает выбор вContent-Typeответа. - Authorization — учётные данные:
Bearer <token>. Так клиент доказывает, кто он. - Cache-Control — правила кэширования:
no-store(не хранить вообще),max-age=3600(можно держать час). Управляет тем, будут ли браузеры и прокси переспрашивать сервер или отдадут сохранённую копию. - ETag и If-None-Match — пара для вопроса «а не устарело ли». Вместе с ответом сервер выдаёт
ETag— короткую метку версии содержимого. Когда срок изmax-ageвышел, клиент присылает эту метку обратно вIf-None-Match, и если содержимое не менялось, сервер отвечает304 Not Modified— пустым ответом без тела. Трафика почти ноль, а копия в кэше снова считается свежей. - Vary — в ответе говорит кэшу, от каких заголовков запроса зависит содержимое:
Vary: Accept-Languageзначит «русская и английская версии — это разные страницы, не путай их одной записью в кэше». - Location — куда смотреть: адрес нового ресурса при
201или адрес переезда при3xx.
Заголовки есть и в запросе, и в ответе, и часть из них парная: клиент говорит Accept: application/json («хочу JSON»), сервер отвечает Content-Type: application/json («вот тебе JSON»).
Длина тела: Content-Length и передача частями
Получатель должен знать, где кончается тело ответа, и есть два способа ему это сообщить.
Content-Length — длина в байтах, известная заранее. Просто и надёжно; получатель читает ровно столько байт и понимает, что ответ целый.
Передача частями (Transfer-Encoding: chunked) — тело идёт куском за куском, каждый со своей длиной, а нулевая длина означает конец. Так отдают то, что генерируется на ходу: выгрузку из базы, поток событий, длинный отчёт. Длину заранее никто не знает, и это нормально.
Отсюда объяснение частого симптома «ответ пришёл наполовину». Если сервер объявил Content-Length, а соединение оборвалось раньше, клиент это обнаружит: прочитано меньше, чем обещано, — ошибка. А при передаче частями оборванный поток без завершающего нулевого куска выглядит как... просто законченный поток, если клиент не проверяет завершение. Отсюда правило: при потоковой отдаче обрыв надо уметь отличать от конца — либо проверять завершающий кусок, либо передавать в самом ответе признак полноты (число строк, контрольную сумму).
Сжатие
Текстовые ответы сжимаются по дороге, и договариваются об этом два заголовка: клиент присылает Accept-Encoding: gzip, br, сервер отвечает Content-Encoding: gzip. Экономия на JSON и HTML обычно в разы — это самая дешёвая оптимизация трафика, и включается она обычно на прокси, а не в приложении.
Три практических следствия. Content-Length в сжатом ответе — длина сжатого тела, а не исходного; клиент, который хочет показать прогресс, этого не знает. Сжимать уже сжатое (картинки, архивы, видео) бессмысленно и только жжёт процессор — поэтому на прокси задают список типов. И самое важное для кэширования: ответ, который может прийти в сжатом и несжатом виде, обязан иметь заголовок Vary: Accept-Encoding, иначе кэш отдаст сжатый ответ клиенту, который сжатия не понимает.
Повторы и ключ идемпотентности
Заголовок с ключом идемпотентности стоит назвать здесь прямо, потому что он появляется на схеме и непонятно откуда. Проблема: клиент отправил POST (создать заказ, списать деньги), не получил ответа и не знает, дошло ли. Повторять нельзя — может получиться второй заказ; не повторять тоже нельзя — может не быть ни одного.
Решение — клиент генерирует уникальный ключ на операцию (не на запрос) и передаёт его заголовком: Idempotency-Key: 0f3c…. Сервер запоминает ключ вместе с результатом: первый запрос выполняется, повтор с тем же ключом не выполняет операцию заново, а возвращает сохранённый ответ.
Это превращает неидемпотентный POST в идемпотентный на уровне приложения — и делает безопасными повторы после таймаутов. Полный разбор с ловушками (сколько хранить ключ, что считать «тем же запросом», как отвечать на повтор с другими данными) — в статье про надёжность сетевых вызовов.
HTTP stateless: каждый запрос сам по себе
Важное свойство: HTTP не имеет состояния (stateless). Сервер не помнит предыдущий запрос — каждое сообщение самодостаточно и несёт всё нужное для обработки. Второй запрос не знает, что был первый.
Аналогия — разговор с оператором, который после каждой фразы теряет память. Чтобы он вас понял, в каждой реплике приходится заново называть себя и суть дела. Именно поэтому Authorization шлётся с каждым запросом: сервер не помнит, что минуту назад вы уже представились.
Для backend это скорее подарок. Раз сервер не хранит контекст между запросами, любой запрос может обработать любой экземпляр сервиса — можно поднять десять копий за балансировщиком, и они взаимозаменяемы. Состояние (сессии, корзины) выносят в общее хранилище, а сами серверы остаются одинаковыми и легко масштабируются.
Обратная сторона этого свойства — то, что состояние всё равно нужно: пользователь не должен входить в систему на каждой странице. Решают это двумя способами, и оба означают, что состояние живёт не в протоколе.
Куки и сессия на сервере. Сервер выдаёт идентификатор сессии в куке, а сами данные (кто вошёл, корзина) держит у себя. Просто, надёжно, и ровно поэтому создаёт проблему при нескольких экземплярах сервиса: следующий запрос может попасть на другой экземпляр, у которого этой сессии нет. Отсюда — или привязка пользователя к экземпляру (и тогда выкат или отказ узла разлогинивает людей), или общее хранилище сессий (Redis), или отказ от серверной сессии вовсе.
Токен у клиента. Всё нужное подписано и лежит в самом токене, сервер ничего не помнит и проверяет подпись. Экземпляры становятся полностью равноправными — это и есть «без состояния» в том смысле, в каком его требует балансировка. Цена: токен нельзя отозвать мгновенно, и в него нельзя положить много.
Почему это важно знать при разговоре о HTTP: «протокол без состояния» — не про то, что состояния нет, а про то, что его положение становится вашим архитектурным решением. Как оно влияет на балансировку — в статье про балансировку нагрузки, где привязка к экземпляру разобрана отдельно.
Где это применяется
HTTP — это фундамент, на котором стоит почти весь backend: REST-API, вебхуки, обращения между микросервисами. Владеть им — значит понимать не только как отправить запрос, но и как правильно ответить.
- Возвращайте правильные статусы. Создали ресурс —
201, а не200. Не нашли —404, а не200с пустым телом. Невалидный ввод —400, нет прав —403. Клиенты (и мониторинг) принимают решения по коду статуса, поэтому «всегда200, а ошибка в теле» ломает всю автоматику вокруг. - Разделяйте
4xxи5xxчестно. Ошибка валидации — это4xx(виноват запрос), а не500. Если отдавать500на кривой ввод, метрики покажут ложную аварию, а клиент начнёт зря ретраить то, что повтором не чинится. - Держите идемпотентность под ретраи. Операции, которые клиент может безопасно повторить (замена, удаление), проектируйте идемпотентными. Для неидемпотентных создающих операций закладывайте ключ идемпотентности, чтобы потерянный ответ и повторный запрос не создали дубль — платёж или заказ дважды.
Где спотыкаются начинающие:
- Отдают
200на всё, даже на ошибки. Тогда клиент вынужден парсить тело, чтобы понять, успех это или провал, — а весь смысл кодов статусов в том, чтобы это было видно сразу. - Путают
401и403.401— не знаю, кто ты (нужна аутентификация);403— знаю, но тебе нельзя (нет прав). Разные проблемы и разные исправления. - Считают
GETместом для изменений. «ДёрнуGET /delete?id=5» — и однажды поисковый робот или предзагрузчик браузера пройдётся по ссылкам и снесёт данные.GETобязан быть безопасным. - Забывают, что HTTP stateless. Ждут, что сервер «помнит» прошлый запрос, и не шлют аутентификацию заново — а он не помнит ничего.
Глубже: HTTP-кэширование: ETag, 304, Cache-Control и Varyрасширенное
В списке заголовков выше кэширование уместилось в три строки, а в жизни это отдельный механизм с двумя независимыми вопросами, и путаница между ними даёт либо вечно старые данные, либо кэш, который ничего не кэширует.
Первый вопрос: можно ли отдать сохранённую копию, не спрашивая сервер. На него отвечает Cache-Control в ответе. max-age=3600 разрешает браузеру и промежуточным кэшам час отдавать копию молча. no-cache вопреки названию не запрещает хранить, а требует перед каждой отдачей спросить сервер, не устарело ли. no-store запрещает хранить вообще, и это ответ для всего личного и платёжного. private разрешает хранить только браузеру пользователя, но не общему кэшу на прокси или CDN; public наоборот. immutable говорит браузеру не переспрашивать даже при обновлении страницы, и это ответ для файлов с хэшем в имени.
Второй вопрос: как дёшево проверить, устарела ли копия. Когда срок вышел или стоит no-cache, клиент делает условный запрос. С ответом сервер отдал ETag: "a1b2c3", метку версии содержимого, и клиент присылает её обратно в If-None-Match. Если версия та же, сервер отвечает 304 Not Modified без тела, и клиент продлевает жизнь своей копии. То же самое по времени: Last-Modified в ответе, If-Modified-Since в запросе. Экономится не время сервера (он всё равно проверил), а трафик тела ответа, и для больших ответов это главное.
GET /api/catalog HTTP/1.1
If-None-Match: "a1b2c3"
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
Cache-Control: max-age=60
ETag считают так, чтобы он менялся вместе с содержимым: хэш тела или версия строки в базе плюс идентификатор; время последнего изменения с точностью до секунды для этого хуже. У ETag есть второе применение, оптимистичная блокировка: If-Match: "a1b2c3" в PUT заставляет сервер отказать с 412 Precondition Failed, если ресурс уже изменили.
Vary защищает от путаницы в общем кэше: если ответ зависит от Accept-Language или Accept-Encoding, кэш обязан хранить отдельные копии на каждое значение, и Vary это ему говорит. Vary: * или Vary: Cookie фактически отключают общий кэш, потому что значений слишком много.
Для API это складывается в правило: ответы справочного характера получают Cache-Control: public, max-age и ETag, ответы конкретного пользователя private или no-store, а всё, что меняется на каждый запрос, отдают с no-store, чтобы прокси по дороге не решил помочь. Забытый Cache-Control означает, что решение примет каждый кэш по дороге по своим правилам.
Глубже: CORS и предзапрос OPTIONSрасширенное
Первая связка бэкенда с фронтендом на другом домене заканчивается одинаково: сервер отвечает 200, а в консоли браузера blocked by CORS policy, и в логе сервера виден странный запрос OPTIONS, которого фронтенд не делал.
Это правило браузера, а не сервера: страница с shop.example.com не может читать ответы с api.example.com, пока тот сам не разрешит. Сервер отвечает всем, а браузер прячет ответ от скрипта. Разрешение это заголовок ответа Access-Control-Allow-Origin: https://shop.example.com; браузер сравнивает его с источником страницы и, если совпало, отдаёт ответ скрипту. Для простых запросов (GET, POST с формой) этого достаточно.
Запрос с Content-Type: application/json, с заголовком Authorization или методом PUT браузер считает «непростым» и перед ним сам отправляет предзапрос OPTIONS с описанием, что собирается сделать: Access-Control-Request-Method: PUT, Access-Control-Request-Headers: authorization, content-type. Сервер отвечает, что разрешает, и только потом идёт настоящий запрос:
OPTIONS /api/orders/42 HTTP/1.1
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Max-Age разрешает браузеру запомнить ответ и не делать предзапрос перед каждым вызовом. Ловушки: OPTIONS приходит без Authorization, и если авторизация стоит перед обработкой CORS, предзапрос получает 401, а браузер трактует это как отказ; звёздочка в Allow-Origin не сочетается с куками и Allow-Credentials: true; и заголовки ответа, кроме простых, скрипту не видны, пока их не перечислить в Access-Control-Expose-Headers. Всё это относится только к браузеру: curl, мобильное приложение и соседний сервис CORS не знают и ходят без предзапросов, поэтому CORS не защита API, а лишь правило для страниц. Та же настройка есть у бакета объектного хранилища, о чём говорит статья про object storage.
Коротко
- HTTP это текст: строка запроса, заголовки, пустая строка, тело; сервер отвечает статусом и теми же частями.
- Методы различают безопасность и идемпотентность:
GETничего не меняет,PUTиDELETEможно повторять,POSTнет. - Коды:
2xxсделано,3xxиди туда,4xxисправь запрос,5xxсервер сломался;429значит притормози. - Кэш отвечает на два вопроса: можно ли отдать копию молча (
Cache-Control) и как дёшево проверить свежесть (ETagиIf-None-Match, ответ304);Varyразделяет копии. - CORS это правило браузера:
Access-Control-Allow-Originв ответе, предзапросOPTIONSперед непростыми запросами,Max-Ageчтобы не повторять его; дляcurlи сервисов CORS не существует. HEADотдаёт только заголовки (им проверяют существование и размер), аOPTIONSбраузер отправляет сам как предзапрос — и обязательно без заголовков аутентификации.- Длину тела задаёт
Content-Lengthлибо передача частями; во втором случае обрыв внешне неотличим от конца, поэтому полноту проверяют явно. - Сжатие включают на прокси и по списку типов; ответ, зависящий от
Accept-Encoding, обязан иметьVary, иначе кэш отдаст сжатое непонимающему клиенту. - Повтор
POSTпосле таймаута делают безопасным заголовкомIdempotency-Key: сервер запоминает ключ с результатом и на повтор возвращает сохранённый ответ. - «Без состояния» не значит «состояния нет»: сессия на сервере требует общего хранилища или привязки к экземпляру, токен у клиента делает экземпляры равноправными.
Что почитать дальше
HTTP не живёт в вакууме. Снизу его несёт транспорт — как запрос вообще доезжает до сервера, разобрано в статье про модели OSI и TCP/IP. Поверх HTTP кладётся шифрование: тот же протокол, но в защищённом канале — это HTTPS и TLS. Сам протокол тоже развивался — чем HTTP/2 и HTTP/3 отличаются от старого HTTP/1.1, смотрите в статье про версии HTTP. А когда что-то в этом обмене ломается — таймауты, повторы, коды 5xx — на помощь приходят приёмы из статьи про надёжность.
Следующий большой шаг — как из этих методов и статусов собрать удобный и предсказуемый интерфейс сервиса. Это уже проектирование API: продолжение — в разделе про REST API.