Модуль: Backend · Уровень: Middle+/Senior

TL;DR#

  • REST — архитектурный стиль (диссертация Роя Филдинга, 2000), а не протокол. Ключевые ограничения: client-server, stateless, cacheable, layered system, uniform interface, опционально code-on-demand.
  • Ресурс — абстрактная сущность, у неё есть представления (JSON/XML/…). URI идентифицирует ресурс, метод выражает действие. Не глаголы в URI (/users/42, а не /getUser?id=42).
  • Уровни зрелости Ричардсона (RMM): L0 — один эндпоинт (RPC поверх HTTP), L1 — ресурсы, L2 — HTTP-методы + статусы (де-факто отраслевой стандарт), L3 — HATEOAS (гиперссылки).
  • Семантика методов: GET/HEAD/OPTIONS — safe; GET/HEAD/PUT/DELETE/OPTIONS — idempotent; POST/PATCH — нет. Кэшируются по умолчанию GET/HEAD.
  • Идемпотентность = N одинаковых запросов дают тот же эффект на сервере, что и один. PUT идемпотентен по дизайну, POST — нет → для платежей нужен Idempotency-Key.
  • Версионирование: URL path (/v1/) — просто и видно; media-type через Accept — «правильно» по REST, но сложнее в эксплуатации; query param — компромисс.
  • Пагинация: offset/limit прост, но дрейфует и медленный на больших offset; cursor/keyset — стабилен и быстр (WHERE (created_at, id) < (?, ?)).
  • Ошибки: единый формат RFC 7807/9457 Problem Details (application/problem+json), стабильные машинные коды, никаких stack trace и SQL наружу.

Теория#

1. Принципы REST (ограничения архитектуры)#

REST задаётся набором архитектурных ограничений (constraints). Соблюдение даёт масштабируемость, кэшируемость и слабую связанность.

ОграничениеСутьПрактический вывод
Client–ServerРазделение UI и хранения данныхНезависимая эволюция фронта и бэка
StatelessКаждый запрос самодостаточен; сервер не хранит сессионное состояние между запросамиАутентификация в каждом запросе (токен), любой инстанс обработает запрос → горизонтальное масштабирование
CacheableОтветы помечают себя как кэшируемые/нетCache-Control, ETag, Expires
Uniform InterfaceЕдинообразный интерфейс: идентификация ресурсов, манипуляция через представления, self-descriptive messages, HATEOASПредсказуемость, переиспользование инфраструктуры (прокси, CDN)
Layered SystemКлиент не знает, говорит ли он с сервером напрямую или через прокси/LBМожно ставить gateway, кэш, балансировщик прозрачно
Code-on-Demand (опц.)Сервер может прислать код (JS)Редко применимо к API

Stateless на практике: «состояние» бизнес-данных живёт в БД и идентифицируется URI; «состояние приложения» (где в навигации находится клиент) держит клиент. Серверные HTTP-сессии в памяти нарушают stateless и ломают масштабирование — отсюда переход на JWT/токены.

Ресурс vs представление: ресурс GET /users/42 может отдаваться как JSON или CSV в зависимости от Accept (content negotiation). Один ресурс — несколько представлений.

Уровни зрелости Ричардсона (Richardson Maturity Model)#

УровеньНазваниеОписаниеПример
0The Swamp of POXОдин URI, один метод (обычно POST), RPC поверх HTTPPOST /api с телом {"method":"getUser"} (SOAP, многие JSON-RPC)
1ResourcesМного URI-ресурсов, но всё ещё один методPOST /users/42, POST /orders/7
2HTTP VerbsИспользуются методы и статус-коды по семантикеGET /users/42 → 200, DELETE /users/42 → 204
3Hypermedia (HATEOAS)Ответы содержат ссылки на возможные переходыв ответе _links: {cancel: ...}

На практике 99% «REST» API живут на уровне 2 и это считается достаточным.

2. HTTP-методы и семантика#

МетодSafe (без side-effect)IdempotentCacheableТело запросаТипичный код успеха
GETнет200
HEADнет200 (без тела)
OPTIONSнет200/204
POST⚠️ только с Cache-Control/Expiresда201/200/202
PUTда200/204
PATCH❌ (не гарантируется)да200/204
DELETEопц.204/200
  • Safe — не меняет состояние ресурса (можно префетчить, кэшировать, ретраить без последствий).
  • Idempotent — повтор даёт тот же серверный эффект. DELETE: первый удаляет (204), повтор → 404, но состояние одинаково (ресурса нет) → идемпотентен.
  • PUT vs POST: PUT — «положи ресурс по этому URI» (клиент знает URI, полная замена). POST — «создай подчинённый ресурс / выполни действие», сервер назначает URI (Location в ответе).
  • PATCH — частичное обновление. Идемпотентность зависит от семантики патча: JSON Merge Patch (RFC 7386) с абсолютными значениями обычно идемпотентен; JSON Patch (RFC 6902) с операцией add в массив — нет.
PUT /users/42 HTTP/1.1
Content-Type: application/json

{"name": "Alice", "email": "alice@example.com"}

PUT с этим телом можно повторить 100 раз — результат один.

POST /users HTTP/1.1
Content-Type: application/json

{"name": "Alice"}

HTTP/1.1 201 Created
Location: /users/43

3. Идемпотентность и Idempotency-Key#

POST неидемпотентен → при сетевом таймауте клиент не знает, прошёл ли платёж, и ретрай создаст дубль списания. Решение — клиент генерирует уникальный ключ и шлёт его в заголовке; сервер дедуплицирует.

POST /payments HTTP/1.1
Idempotency-Key: 9f1c2b7e-4a3d-4c8e-9f0a-1b2c3d4e5f60
Content-Type: application/json

{"amount": 1000, "currency": "USD", "account": "acc_1"}

Принципы реализации (как у Stripe):

  1. Ключ уникален для логической операции (UUID v4), хранится с TTL (например 24ч).
  2. Атомарно «застолбить» ключ (insert с unique-индексом / SETNX в Redis).
  3. На повтор с тем же ключом — вернуть сохранённый ответ, не выполняя операцию заново.
  4. Желательно проверять, что тело запроса совпадает с первым (иначе 422 — конфликт ключа).
  5. Обрабатывать гонку: два параллельных запроса с одним ключом → один выполняет, второй ждёт/получает 409.
func (s *Server) CreatePayment(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        writeProblem(w, http.StatusBadRequest, "missing-idempotency-key",
            "Idempotency-Key header is required")
        return
    }

    body, _ := io.ReadAll(r.Body)
    reqHash := sha256.Sum256(body)

    // Атомарно пытаемся застолбить ключ. Уникальный индекс на idempotency_key.
    rec, err := s.store.AcquireKey(r.Context(), key, reqHash[:])
    switch {
    case errors.Is(err, ErrKeyInProgress):
        w.WriteHeader(http.StatusConflict) // запрос с тем же ключом ещё выполняется
        return
    case rec != nil && rec.Completed:
        if !bytes.Equal(rec.RequestHash, reqHash[:]) {
            writeProblem(w, http.StatusUnprocessableEntity, "idempotency-key-reuse",
                "key already used with a different request body")
            return
        }
        w.WriteHeader(rec.StatusCode) // воспроизводим сохранённый ответ
        w.Write(rec.ResponseBody)
        return
    }

    // Первый запрос с этим ключом — выполняем операцию в транзакции
    resp, status := s.processPayment(r.Context(), body)
    s.store.CompleteKey(r.Context(), key, status, resp)

    w.WriteHeader(status)
    w.Write(resp)
}

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

4. HTTP статус-коды#

КодИмяКогда использовать
200OKУспех с телом (GET, обновление с возвратом)
201CreatedРесурс создан; вернуть Location
202AcceptedПринято в асинхронную обработку (очередь), результата ещё нет
204No ContentУспех без тела (DELETE, PUT без возврата)
301Moved PermanentlyРесурс переехал навсегда (меняйте URL)
304Not ModifiedConditional GET: ресурс не изменился (If-None-Match/ETag) — экономит трафик
400Bad RequestСинтаксически некорректный запрос (битый JSON, неверный тип)
401UnauthorizedНет/невалидная аутентификация (по факту «не аутентифицирован»)
403ForbiddenАутентифицирован, но нет прав на ресурс
404Not FoundРесурс не найден (или скрываем существование вместо 403)
409ConflictКонфликт состояния: дубликат, нарушение версии (optimistic lock), параллельный idempotency-key
422Unprocessable EntityСинтаксис верный, но семантическая/валидационная ошибка (бизнес-правила)
429Too Many RequestsRate limit; вернуть Retry-After
500Internal Server ErrorНеобработанная ошибка сервера
502Bad GatewayАпстрим вернул невалидный ответ (прокси/gateway)
503Service UnavailableСервис недоступен/перегружен/maintenance; Retry-After
504Gateway TimeoutАпстрим не ответил вовремя

400 vs 422: 400 — клиент прислал то, что сервер не может разобрать (битый JSON, отсутствует обязательное поле на уровне формата). 422 — разобрали, но не прошла валидация бизнес-правил (age = -5, email уже занят). Граница условна; главное — последовательность внутри API.

401 vs 403: 401 — «кто ты?» (нет/протух токен → надо переаутентифицироваться). 403 — «знаю кто ты, но нельзя».

5. Версионирование API#

Версия нужна для breaking changes: удаление/переименование полей, изменение типа, изменение семантики, ужесточение валидации. Неразрушающие (добавление опционального поля, нового эндпоинта) версии не требуют — клиенты должны игнорировать неизвестные поля (правило толерантного ридера).

ПодходПримерПлюсыМинусы
URL pathGET /v1/usersПросто, видно, легко роутить/кэшировать, удобно в браузере«Не по REST» (URI должен идентифицировать ресурс, а не версию), дублирование роутов
Header / media-typeAccept: application/vnd.api.v2+jsonЧисто по REST, URI стабиленСложнее тестировать/кэшировать, невидимо, ошибки легко не заметить
Query paramGET /users?version=2Просто добавитьЗасоряет кэш-ключи, легко забыть, неоднозначно
Custom headerX-API-Version: 2URI стабиленНестандартно, та же невидимость

На практике для публичных API чаще берут URL path (GitHub, Stripe — Stripe ещё привязывает версию к аккаунту/дате). Для строгого REST — media-type versioning.

Дополнительно:

  • Политика deprecation: заголовки Deprecation, Sunset, ссылка на доку; срок поддержки старой версии.
  • Минимизируйте число живых версий (поддержка дорогая). Иногда лучше аддитивные изменения без новой версии.

6. Пагинация#

Offset / Limit#

GET /orders?limit=20&offset=40
  • ✅ Просто, позволяет прыгать на произвольную страницу.
  • Дрейф данных: вставка/удаление между запросами сдвигает окно → дубли или пропуски.
  • ❌ Производительность: OFFSET 100000 заставляет БД отсчитать и выбросить 100k строк (O(offset)).

Cursor / Keyset pagination#

Курсор кодирует «откуда продолжить» (значения сортируемых колонок последней строки), а не номер строки.

GET /orders?limit=20&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNi0wMVQxMDowMDowMFoiLCJpZCI6MTAwfQ

Курсор обычно — base64 от {created_at, id}. SQL (keyset):

SELECT * FROM orders
WHERE (created_at, id) < ($1, $2)   -- строгая сортировка по уникальному ключу
ORDER BY created_at DESC, id DESC
LIMIT 20;
  • ✅ Стабильно при вставках/удалениях (нет дрейфа окна).
  • ✅ Быстро: индекс по (created_at, id)O(log n), не зависит от глубины.
  • ❌ Нельзя прыгнуть на «страницу 500», только next/prev.
  • ❌ Сортировка должна включать уникальный тай-брейкер (id), иначе пропуски при равных created_at.

Ответ с навигацией (стиль ссылок next):

{
  "data": [ ... ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkX2F0Ijoi...",
    "has_more": true
  }
}
Link: <https://api.example.com/orders?cursor=...&limit=20>; rel="next"

Рекомендация для больших/часто меняющихся коллекций — cursor/keyset. Offset оставляйте для небольших админ-таблиц с произвольным переходом по страницам.

7. Ошибки API: RFC 7807 / 9457 (Problem Details)#

Единый машиночитаемый формат. Content-Type — application/problem+json.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "The 'email' field must be a valid address",
  "instance": "/users",
  "code": "VALIDATION_ERROR",
  "errors": [
    {"field": "email", "message": "invalid format"}
  ],
  "trace_id": "abc-123"
}

Поля: type (URI-идентификатор класса ошибки, стабилен), title (человекочитаемое, неизменное для типа), status, detail (про конкретный случай), instance (про конкретный ресурс/запрос). Расширения (code, errors, trace_id) допускаются.

type Problem struct {
    Type     string        `json:"type"`
    Title    string        `json:"title"`
    Status   int           `json:"status"`
    Detail   string        `json:"detail,omitempty"`
    Instance string        `json:"instance,omitempty"`
    Code     string        `json:"code,omitempty"`
    Errors   []FieldError  `json:"errors,omitempty"`
    TraceID  string        `json:"trace_id,omitempty"`
}

func writeProblem(w http.ResponseWriter, status int, code, detail string) {
    p := Problem{
        Type:   "https://api.example.com/errors/" + code,
        Title:  http.StatusText(status),
        Status: status,
        Code:   code,
        Detail: detail,
    }
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(p)
}

Правила:

  • Стабильные машинные code — клиенты ветвятся по ним, а не по тексту/HTTP-коду.
  • Не светить internals: никаких stack trace, SQL, путей файлов, имён таблиц в ответе. Внутреннюю причину логируйте под trace_id, наружу — общий detail.
  • Консистентность: один формат на все эндпоинты.

8. HATEOAS (кратко)#

HATEOAS (Hypermedia as the Engine of Application State) — уровень 3 RMM: ответ несёт ссылки на доступные дальнейшие действия, клиент не хардкодит URI.

{
  "id": 7, "status": "pending", "total": 100,
  "_links": {
    "self":   {"href": "/orders/7"},
    "cancel": {"href": "/orders/7/cancel", "method": "POST"},
    "pay":    {"href": "/orders/7/payment", "method": "POST"}
  }
}

Идея: сервер управляет переходами состояний (ссылки появляются/исчезают по статусу), клиент слабо связан с URL-схемой.

Почему редко применяют: дополнительная сложность и объём ответов; большинство клиентов всё равно хардкодят URI; нет общепринятого медиа-типа (HAL, JSON:API, Siren конкурируют); нет инструментов/выгоды для типичного SPA/мобайла; цена > выгоды для внутренних API. Хорошо живёт в строго гипермедийных доменах (некоторые публичные API, AtomPub-подобные).

9. Content negotiation и conditional requests#

Content negotiation — клиент сообщает желаемое представление, сервер выбирает:

GET /report/7 HTTP/1.1
Accept: application/json, text/csv;q=0.5
Accept-Language: ru-RU
Accept-Encoding: gzip

Сервер отвечает Content-Type, при невозможности — 406 Not Acceptable. Указывайте Vary: Accept для корректного кэширования.

ETag и conditional requests — валидаторы для кэша и оптимистичной блокировки.

GET /users/42
HTTP/1.1 200 OK
  ETag: "a1b2c3"

# повторный GET с валидатором
GET /users/42
If-None-Match: "a1b2c3"
→ HTTP/1.1 304 Not Modified        # тело не передаётся

# безопасное обновление (optimistic concurrency)
PUT /users/42
If-Match: "a1b2c3"
→ 412 Precondition Failed          # если ресурс уже изменили
  • If-None-Match (GET) → 304, экономия трафика.
  • If-Match (PUT/PATCH/DELETE) → защита от потери обновлений: правим только если версия совпала, иначе 412.
  • ETag: strong ("abc") — побайтовое равенство; weak (W/"abc") — семантическое.
  • Также Last-Modified + If-Modified-Since/If-Unmodified-Since (гранулярность — секунды, слабее ETag).

Подводные камни / gotchas#

  • 200 на ошибку. Возврат 200 OK с телом {"error": ...} ломает обработку у клиентов и прокси. Используйте корректный статус.
  • PATCH считают идемпотентным. Зависит от семантики патча; JSON Patch с add/copy в массив — нет.
  • DELETE повторно → 404 ≠ нарушение идемпотентности. Идемпотентность про состояние сервера, а не про одинаковый статус-ответ.
  • POST для всего (уровень 0/1 под видом REST). Теряете кэш, ретраи, семантику; «getUser» через POST.
  • Offset-пагинация на больших данных: и дрейф, и O(offset) по производительности. Под нагрузкой деградирует.
  • Курсор без уникального тай-брейкера: при равных created_at строки пропадают/дублируются — всегда добавляйте id в ключ сортировки.
  • Утечка internals в ошибках: stack trace/SQL в ответе = и UX-проблема, и security-риск (раскрытие схемы).
  • Idempotency без проверки тела: один ключ с разным body должен давать 422/409, а не молча отдавать старый ответ.
  • Stateful через session affinity: липкие сессии в памяти ломают масштабирование и отказоустойчивость.
  • Версия только в коде клиента: без Sunset/Deprecation старые клиенты внезапно ломаются — нужна политика устаревания.
  • Кэш без Vary: при content negotiation прокси может отдать JSON клиенту, попросившему CSV.
  • Retry-After забыт на 429/503 — клиенты ретраят агрессивно и усугубляют перегрузку.
  • PUT для создания со server-generated ID — путаница; для серверного ID используйте POST + Location.

Вопросы на собеседовании#

В: Что значит «stateless» в REST и какие практические следствия? О: Сервер не хранит состояние клиента между запросами; каждый запрос самодостаточен (включая аутентификацию). Следствия: любой инстанс обрабатывает любой запрос → горизонтальное масштабирование и отказоустойчивость; нет серверных сессий в памяти (отсюда JWT/токены); состояние данных — в БД (по URI), состояние приложения — у клиента. Цена: каждый запрос тащит аутентификацию/контекст, больше трафика.

В: Чем идемпотентность отличается от безопасности (safety) метода? Идемпотентен ли DELETE, PATCH? О: Safe — метод не меняет состояние (GET, HEAD). Idempotent — N повторов = эффект как от одного (на состоянии сервера), но метод может менять состояние (PUT, DELETE). DELETE идемпотентен: первый удаляет, повтор → 404, но ресурса всё равно нет — состояние одинаково. PATCH не гарантированно идемпотентен: зависит от семантики (Merge Patch с абсолютными значениями — да; JSON Patch add в массив — нет).

В: Зачем нужен Idempotency-Key и как его реализовать для платежей? О: POST неидемпотентен, при таймауте клиент не знает исход и ретрай создаёт дубль списания. Клиент шлёт уникальный ключ; сервер атомарно столбит его (unique-индекс/SETNX), при первом запросе выполняет операцию в транзакции вместе с сохранением ответа, при повторе — возвращает сохранённый ответ без повторного выполнения. Проверяем совпадение тела (иначе 422), обрабатываем гонку параллельных запросов (409/ожидание), ставим TTL.

В: 400 vs 422 и 401 vs 403? О: 400 — запрос не разобрать (битый JSON, неверный формат); 422 — разобрали, но не прошла семантическая/бизнес-валидация (age=-5). 401 — не аутентифицирован (нет/протух токен, нужно переаутентифицироваться); 403 — аутентифицирован, но нет прав. Иногда вместо 403 отдают 404, чтобы не раскрывать существование ресурса.

В: Сравните способы версионирования API. Что выбрать? О: URL path (/v1/) — просто, видно, легко роутить/кэшировать, но «не по REST» и дублирует роуты. Media-type (Accept: ...+json;v=2) — чисто по REST, URI стабилен, но сложнее тестировать/кэшировать и невидимо. Query/custom header — компромиссы с минусами кэширования/видимости. Для публичных API чаще URL path; для строгого REST — media-type. Главное: версионировать только breaking changes, минимизировать число живых версий, иметь политику Deprecation/Sunset.

В: Чем cursor/keyset пагинация лучше offset на больших данных? О: Offset дрейфует при вставках/удалениях (дубли/пропуски) и медленный: OFFSET N отсчитывает и выбрасывает N строк (O(N)). Keyset кодирует позицию через значения сортируемых колонок последней строки (WHERE (created_at, id) < (?, ?)), использует индекс → O(log n) независимо от глубины и стабилен при изменениях. Минус: нет прыжков на произвольную страницу; нужен уникальный тай-брейкер (id) в сортировке.

В: Как спроектировать формат ошибок API? О: Единый формат (RFC 7807/9457 Problem Details, application/problem+json): type, title, status, detail, instance + расширения (code, errors[], trace_id). Стабильные машинные code для ветвления клиентов (не по тексту/HTTP). Не светить internals (stack trace, SQL, схему) — их в лог под trace_id, наружу общее сообщение. Консистентность на всех эндпоинтах.

В: Что такое HATEOAS и почему его редко используют? О: Уровень 3 RMM: ответ несёт гиперссылки на доступные действия/переходы (_links), клиент не хардкодит URI, сервер управляет состоянием приложения. Редко применяют из-за сложности и объёма, отсутствия единого медиа-типа (HAL/JSON:API/Siren), того что клиенты всё равно хардкодят URL, и недостаточной выгоды для типичных SPA/мобайла — цена выше пользы.

В: Как работают ETag и условные запросы? О: Сервер отдаёт ETag (версия представления). GET с If-None-Match304 Not Modified без тела (экономия трафика, валидация кэша). PUT/PATCH/DELETE с If-Match → если версия изменилась, 412 Precondition Failed — это оптимистичная блокировка против потери обновлений. ETag бывают strong (побайтово) и weak (W/, семантически). Аналог по времени — Last-Modified + If-Modified-Since, но гранулярность в секундах.

В: Когда отдавать 202 Accepted? О: Когда запрос принят, но обработка асинхронная (поставлен в очередь) и результата ещё нет. Возвращают ссылку на ресурс статуса/результата (Location на job), клиент опрашивает или получает через вебхук/SSE. Полезно для долгих операций, чтобы не держать соединение.

На что копают на senior+#

  • Идемпотентность под капотом: атомарность «застолбить ключ + выполнить операцию + сохранить ответ» в одной транзакции; что будет при падении между шагами; гонки параллельных запросов с одним ключом; TTL и хранилище ключей (Redis vs БД); согласованность с outbox при отправке событий.
  • Семантика vs реализация: почему DELETE идемпотентен, хотя статусы разные; различие «идемпотентность HTTP-метода» и «бизнес-идемпотентность операции».
  • Пагинация и консистентность: read-skew при offset, snapshot-пагинация, стабильная сортировка, шифрование/подпись курсора (чтобы не утекали внутренние поля и нельзя было подделать), пагинация при сортировке по неуникальному/изменяемому полю.
  • Кэширование: Cache-Control (public/private, max-age, s-maxage, stale-while-revalidate), Vary, ETag-генерация (не хешировать весь ответ дорого), инвалидация, согласование с CDN.
  • Эволюция API без версий: толерантный ридер, аддитивные изменения, feature flags, contract testing (Pact), обратная/прямая совместимость.
  • Ошибки и безопасность: единый формат + не раскрывать схему/существование ресурсов, маппинг доменных ошибок на HTTP, трассировка (trace_id) сквозь сервисы.
  • Когда REST не подходит: gRPC для внутренних высоконагруженных вызовов, GraphQL для гибких выборок, async/event-driven для интеграций; trade-off’ы.
  • Конкурентность и оптимистичные блокировки: ETag/If-Match vs version-колонка, 409 vs 412, разрешение конфликтов.
  • Rate limiting: 429, Retry-After, алгоритмы (token/leaky bucket), заголовки X-RateLimit-*, поведение клиента (экспоненциальный backoff + jitter).