10.1Дизайн API
Спроектируй API как контракт: ресурс, метод, статус, формат ошибки, пагинация и правила повторной записи. Проверь, что изменение остаётся совместимым с уже выпущенными клиентами.
Базовое правило: ресурс - существительное, действие - метод
URL именует ресурс (сущность или коллекцию), а действие над ним задаёт HTTP-метод.
Стоит в пути появиться глаголу (/getPost, /createPost,
/updatePostById), и получается RPC поверх HTTP: все механизмы протокола
(кэширование, идемпотентные ретраи прокси, понятные коды) перестают работать.
# так делать не надо
POST /api/getPosts
POST /api/createPost
POST /api/deletePostById?id=7
GET /api/posts/get_by_author_and_tag
# ресурсно
GET /v1/posts
POST /v1/posts
DELETE /v1/posts/7
GET /v1/posts?author=3&tag=go
Коллекции пишут во множественном числе и в нижнем регистре (/posts, а не
/Post). Вкладывают не глубже одного уровня: /posts/{id}/comments
читается нормально, /authors/{a}/posts/{p}/comments/{c}/likes уже нет. Вложенный
ресурс со своим идентификатором выносят на верхний уровень (/comments/{c}/likes).
Слова в сегменте пути разделяют дефисом (/blog-posts), поля JSON называют в snake_case
или camelCase, но одинаково по всему API.
CRUD API блога: полная таблица ручек
Эту таблицу и просят написать на доске. Метода и пути мало: проговаривай ещё код успеха, коды ошибок и что в теле. Как раз этого кандидату обычно не хватает.
| Метод и путь | Что делает | Успех | Ошибки | Тело запроса / ответа |
|---|---|---|---|---|
GET /v1/posts | Список постов | 200 | 400 (плохой курсор/сортировка) | - / {items:[…], next_cursor, has_more} |
POST /v1/posts | Создать пост | 201 + Location: /v1/posts/42 | 400, 401, 403, 422, 429 | {title, body, tags, status} / созданный ресурс |
GET /v1/posts/{id} | Один пост | 200 + ETag | 404, 304 (если If-None-Match) | - / ресурс |
PUT /v1/posts/{id} | Заменить целиком | 200 (или 204) | 404, 409, 412, 422 | полный ресурс / обновлённый ресурс |
PATCH /v1/posts/{id} | Частичное изменение | 200 | 404, 409, 422 | только изменяемые поля / ресурс |
DELETE /v1/posts/{id} | Удалить | 204 | 403, 404 (или тоже 204) | - / - |
POST /v1/posts/{id}/publish | Смена состояния (не CRUD) | 200 / 202 | 404, 409 (уже опубликован) | - / ресурс со статусом |
GET /v1/posts/{id}/comments | Комментарии поста | 200 | 404 | - / страница комментариев |
POST /v1/posts/{id}/comments | Добавить комментарий | 201 + Location | 401, 404, 422, 429 | {body, reply_to} / комментарий |
DELETE /v1/comments/{id} | Удалить комментарий | 204 | 403, 404 | - / - |
PUT /v1/posts/{id}/likes/me | Поставить лайк | 204 | 401, 404 | - / - (идемпотентно by design) |
DELETE /v1/posts/{id}/likes/me | Снять лайк | 204 | 401 | - / - |
/likes/meчерезPUT, а неPOST /like. Лайк сводится к тому, существует ли ресурс «мой лайк на этом посте».PUTидемпотентен: два клика подряд не сломают счётчик, ретрай безопасен.- Действия, которые не ложатся на CRUD (
publish,cancel,refund) оформляются как под-ресурс сPOST. REST это не нарушает, приём давно устоялся (так делают Stripe и GitHub). 201обязательно сLocation: так клиент узнаёт URL созданного ресурса и не вычисляет его по формуле.422vs400:400означает «я не смог разобрать запрос» (битый JSON, плохой тип),422- «разобрал, но данные не проходят бизнес-валидацию» (пустой title, тег длиннее 32 символов). По коду клиент понимает, чинить парсер или показывать форму.
Коды ответа: минимальный набор, который надо знать наизусть
| Код | Когда | Что часто путают |
|---|---|---|
200 OK | Успех с телом | Не отдавать 200 с {"error": …} внутри - это главный грех API |
201 Created | Создан ресурс | Нужен Location |
202 Accepted | Принято в обработку, результата ещё нет | Отдавать вместе со ссылкой на статус-ресурс |
204 No Content | Успех без тела | Тела быть не должно, иначе часть клиентов сломается |
304 Not Modified | Ответ на If-None-Match/If-Modified-Since | Экономит трафик, нужен валидатор: ETag или Last-Modified |
400 Bad Request | Синтаксис запроса неверен | Не использовать как «универсальную ошибку» |
401 Unauthorized | Не аутентифицирован: токена нет или он протух | Название историческое: это про аутентификацию |
403 Forbidden | Аутентифицирован, но нет прав | Иногда осознанно заменяют на 404, чтобы не раскрывать существование ресурса |
404 Not Found | Ресурса нет | Не путать с 410 Gone - «был и удалён навсегда» |
405 Method Not Allowed | Путь есть, метод не тот | Обязателен заголовок Allow. ServeMux с Go 1.22 отдаёт его сам |
409 Conflict | Конфликт состояния: дубль, устаревшая версия | Типично для оптимистичных блокировок |
412 Precondition Failed | If-Match не совпал | Основа безопасного concurrent-update |
413 Content Too Large | Тело больше лимита | Его пишет хендлер, получив от MaxBytesReader ошибку *http.MaxBytesError |
415 Unsupported Media Type | Не тот Content-Type | Полезно как защита от CSRF-формы |
422 Unprocessable Content | Валидация не прошла | Здесь отдают список ошибок по полям |
429 Too Many Requests | Rate limit | Желателен Retry-After |
500 / 502 / 503 / 504 | Наша ошибка / плохой апстрим / недоступен / таймаут апстрима | В теле - никаких подробностей, только trace id |
Версионирование
Версия нужна ровно тогда, когда предстоит ломающее изменение. Всё, что можно добавить совместимо, версии не требует. Способов три, и у каждого своя цена.
| Способ | Как выглядит | Плюсы | Минусы |
|---|---|---|---|
| В пути (URI) | GET /v1/posts |
Видно глазами в логах и метриках; тривиально роутить (/v1/* → сервис A, /v2/* → B); легко curl-ить; кэши различают версии из коробки |
Формально нарушает «один ресурс - один URI»: /v1/posts/7 и /v2/posts/7 - та же сущность; ссылки, сохранённые клиентом, привязаны к версии |
| В заголовке | Accept: application/vnd.blog.v2+jsonили X-API-Version: 2 |
URI стабилен; можно версионировать отдельные представления; «чистый» с точки зрения HTTP content negotiation | Не видно в браузере и логах доступа; сложнее отлаживать; обязателен Vary, иначе кэш и CDN перепутают версии; клиенты забывают заголовок → нужен дефолт |
| В query | GET /posts?version=2 |
Проще всего прикрутить к существующему API; удобно для быстрых экспериментов | Мешается с фильтрами; легко потерять при копировании ссылки; ломает кэш, если CDN настроен игнорировать query-строку в ключе |
«По умолчанию кладу major-версию в путь: для публичного API это самый практичный вариант,
его выбрали Stripe (частично) и Twilio. Заголовок оправдан, когда версий много и они
мелкие - тогда это дата-версионирование вида Stripe-Version: 2024-06-20,
привязанное к аккаунту клиента. Query-параметр в проде не использую: он смешивается с
бизнес-фильтрами и ломает кэширование». Ещё проговори: версию меняет только major,
minor-изменения её не получают и обязаны быть совместимыми.
Пагинация, фильтрация, сортировка
У любой ручки-коллекции должны быть лимит по умолчанию и жёсткий максимум.
Без них первый же клиент напишет ?limit=1000000 и уронит сервис, причём по памяти,
а не по сети: миллион строк надо сначала собрать в слайс, потом сериализовать.
const (
defaultLimit = 20
maxLimit = 100
)
func parseLimit(q url.Values) (int, error) {
raw := q.Get("limit")
if raw == "" {
return defaultLimit, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0, fmt.Errorf("limit: ожидается целое >= 1")
}
if n > maxLimit {
n = maxLimit // молча зажимаем вместо 400, клиенту так удобнее
}
return n, nil
}
OFFSET 1000000 может выполняться секунды.# фильтры и сортировка в плоских query-параметрах
GET /v1/posts?status=published&author_id=3&tag=go&created_after=2026-01-01
GET /v1/posts?sort=-created_at,title&limit=20&cursor=eyJ0IjoiMjAyNi0wOC0wMSIsImlkIjo0Mn0
# ответ с курсором
{
"items": [ ... 20 постов ... ],
"next_cursor": "eyJ0IjoiMjAyNi0wNy0zMSIsImlkIjozMX0",
"has_more": true
}
Ссылку на следующую страницу почти всегда собирают из URL текущего запроса: скопировать всё,
что прислал клиент, и заменить один параметр. И тут же ловят классическую ошибку: поле
r.URL объявлено как указатель, а url.Values как map.
Присвоишь такую переменную другой, и копия будет смотреть на те же данные, поэтому «поправил копию»
на деле значит «поправил запрос, который сейчас обрабатывают остальные слои». Раньше
глубокую копию писали руками, поле за полем, а в Go 1.27 появились готовые
URL.Clone() и Values.Clone().
// Go 1.27
func nextPageURL(r *http.Request, cursor string) string {
u := r.URL.Clone() // глубокая копия: оригинальный r.URL не трогаем
q := u.Query() // Query() и так парсит заново, копия не нужна
q.Set("cursor", cursor)
u.RawQuery = q.Encode()
return u.String()
}
// Values.Clone() нужен, когда параметры лежат в поле или в общей переменной
var base = url.Values{"status": {"published"}, "limit": {"20"}}
page := base.Clone() // без Clone() строка ниже испортила бы сам base
page.Set("cursor", cursor)
Сортировку задают одним параметром sort со списком полей, минус означает DESC
(?sort=-created_at,title). По безопасности правило жёсткое: имя поля
никогда не подставляется в SQL напрямую, только через белый список. Иначе получишь
SQL-инъекцию в ORDER BY, а плейсхолдеры туда не подставить.
var sortable = map[string]string{
"created_at": "p.created_at",
"title": "p.title",
"likes": "p.likes_count",
}
func orderBy(sort string) (string, error) {
if sort == "" {
return "p.created_at DESC, p.id DESC", nil // всегда добиваем уникальным полем
}
var parts []string
for _, f := range strings.Split(sort, ",") {
dir := "ASC"
if strings.HasPrefix(f, "-") {
dir, f = "DESC", f[1:]
}
col, ok := sortable[f] // ORDER BY защищает только белый список
if !ok {
return "", fmt.Errorf("sort: неизвестное поле %q", f)
}
parts = append(parts, col+" "+dir)
}
return strings.Join(parts, ", ") + ", p.id DESC", nil
}
ORDER BY created_at DESC LIMIT 20 OFFSET 20 при одинаковых created_at
у нескольких строк даёт неопределённый порядок: СУБД вправе вернуть их в любой
последовательности, и на разных страницах она может быть разной. Записи пропадают и
задваиваются даже без всяких вставок. Лечится это уникальным полем в конце
сортировки (, id DESC). Любимая добивка после вопроса про пагинацию.
Идемпотентность и Idempotency-Key
Идемпотентная операция при повторе даёт тот же результат, что и при однократном
вызове. Речь об эффекте на сервере, тело ответа тут ни при чём: у
DELETE второй вызов может вернуть 404 вместо 204, и метод
всё равно идемпотентен, потому что состояние системы не изменилось.
| Метод | Безопасный (safe) | Идемпотентный | Комментарий |
|---|---|---|---|
GET, HEAD, OPTIONS | да | да | Не меняют состояние вообще |
PUT | нет | да | «Сделай так, чтобы стало вот так» - сколько ни повторяй, результат один |
DELETE | нет | да | После первого вызова ресурса нет, дальше ничего не меняется |
POST | нет | нет | Каждый вызов создаёт новую сущность - отсюда двойные списания |
PATCH | нет | зависит | {"title":"x"} идемпотентен, {"op":"increment","by":1} - нет |
Клиент не может отличить «запрос не дошёл» от «дошёл, но ответ потерялся».
Платёж мог пройти и при таймауте на стороне клиента, и при разрыве TCP, и при перезапуске
пода. Ретрай без защиты спишет деньги второй раз. Поэтому клиент генерирует
уникальный ключ (UUIDv4) на бизнес-операцию, а не на HTTP-запрос, и посылает его
в заголовке Idempotency-Key. Сервер хранит ключ вместе с ответом.
in_progress и получит 409, а не повторный платёж.CREATE TABLE idempotency_keys (
key text NOT NULL,
user_id bigint NOT NULL,
endpoint text NOT NULL,
request_hash bytea NOT NULL, -- sha256 от тела: защита от «тот же ключ, другой запрос»
state text NOT NULL, -- in_progress | done
status_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL, -- TTL 24 часа, чистим фоновым джобом
PRIMARY KEY (user_id, key) -- ключ уникален в пределах клиента
);
CREATE INDEX ON idempotency_keys (expires_at);
func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
writeProblem(w, http.StatusBadRequest, "missing-idempotency-key",
"Заголовок Idempotency-Key обязателен для этой операции")
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil { // MaxBytesReader сам 413 не пишет
writeProblem(w, http.StatusRequestEntityTooLarge, "body-too-large", "Тело больше 1 МБ")
return
}
sum := sha256.Sum256(body)
// 1. Пытаемся занять ключ. Уникальный индекс делает это атомарным.
rec, fresh, err := h.keys.Acquire(r.Context(), key, userID(r), r.URL.Path, sum[:])
switch {
case err != nil:
writeProblem(w, 500, "internal", "")
return
case !fresh && !bytes.Equal(rec.RequestHash, sum[:]):
// ключ тот же, а тело другое: клиент ошибся, это не ретрай
writeProblem(w, http.StatusUnprocessableEntity, "idempotency-key-reuse",
"Idempotency-Key уже использован с другим телом запроса")
return
case !fresh && rec.State == "done":
// честный ретрай: отдаём сохранённый ответ, ничего не выполняя
w.Header().Set("Idempotent-Replay", "true")
w.WriteHeader(rec.StatusCode)
w.Write(rec.ResponseBody)
return
case !fresh: // state == in_progress: параллельный дубль
w.Header().Set("Retry-After", "1")
writeProblem(w, http.StatusConflict, "request-in-progress",
"Запрос с этим ключом ещё выполняется")
return
}
// 2. Ключ наш, выполняем операцию.
resp, code, err := h.charge(r.Context(), body)
if err != nil {
h.keys.Release(r.Context(), key) // освобождаем, чтобы ретрай мог попробовать снова
writeProblem(w, 502, "psp-unavailable", "")
return
}
// 3. Сохраняем ответ до отправки клиенту.
_ = h.keys.Complete(r.Context(), key, code, resp)
w.WriteHeader(code)
w.Write(resp)
}
В идеале всё лежит в одной транзакции БД: и вставка в idempotency_keys,
и списание с баланса. Тогда «выполнили, но не записали ключ» невозможно физически. Как только
побочный эффект уходит во внешнюю систему (банк, брокер), транзакцию на неё не растянешь.
Спасает только то, что ключ занимается раньше внешнего вызова, плюс свой
идемпотентный ключ для PSP (merchant_order_id). Если PSP такого ключа не принимает,
остаётся паттерн outbox: сначала запись в БД в одной транзакции, потом отдельный воркер
дожимает вызов с ретраями. На собесе достаточно сказать: «ключ занимаем до эффекта,
завершаем после, а внешний вызов делаем идемпотентным своим ключом».
Единый формат ошибок: RFC 7807
RFC 7807 (обновлён как RFC 9457) описывает media type application/problem+json,
то есть стандартное тело ошибки. В ядре пять полей: type (URI класса
проблемы), title (человекочитаемое короткое название, стабильное для типа),
status (дублирует HTTP-код), detail (подробности этого случая),
instance (URI конкретного вхождения). Свои поля добавлять можно, стандарт это разрешает.
// HTTP/1.1 422 Unprocessable Content
// Content-Type: application/problem+json
{
"type": "https://api.blog.ru/errors/validation-failed",
"title": "Validation failed",
"status": 422,
"detail": "Поле title обязательно и не длиннее 200 символов",
"instance": "/v1/posts",
"trace_id": "01J8Z9K2R3Q4",
"errors": [
{"field": "title", "code": "required"},
{"field": "tags", "code": "too_many", "max": 10}
]
}
// Так нельзя: утечка внутренностей
func handle(w http.ResponseWriter, r *http.Request) {
if err := svc.Do(r.Context()); err != nil {
http.Error(w, err.Error(), 500)
// pq: duplicate key value violates
// unique constraint "users_email_key"
// ...в ответе клиенту
}
}
// Так надо: наружу код, внутрь детали
func handle(w http.ResponseWriter, r *http.Request) {
if err := svc.Do(r.Context()); err != nil {
tid := middleware.TraceID(r.Context())
slog.ErrorContext(r.Context(), "create user",
"err", err, "trace_id", tid)
writeProblem(w, 500, "internal",
"Внутренняя ошибка", tid)
}
}
- Текст ошибки СУБД выдаёт имена таблиц, колонок, констрейнтов, тип и версию базы. Это готовая карта для SQL-инъекции и разведки.
- Стек-трейс и пути файлов раскрывают структуру проекта, имена внутренних пакетов, версию Go и путь до домашней директории сборочного агента.
- Адреса внутренних сервисов в ошибках вида
dial tcp 10.0.3.17:5432: connect: connection refusedрисуют топологию внутренней сети. - Разные формулировки для «нет пользователя» и «неверный пароль» превращают форму входа в перечислитель существующих аккаунтов. Ответ должен быть один: «неверный логин или пароль».
- Разные коды на «нет доступа» и «нет объекта» в приватных ресурсах:
403подтверждает, что объект существует. Иногда правильнее отвечать404в обоих случаях.
Обратная совместимость: что ломает клиентов
| Изменение | Ломает? | Почему |
|---|---|---|
| Добавить необязательное поле в ответ | нет | Строгие парсеры - редкость; клиент игнорирует лишнее |
| Добавить необязательный параметр запроса | нет | Старые клиенты его не шлют, работает дефолт |
| Добавить новую ручку / новый метод | нет | - |
| Удалить или переименовать поле ответа | да | Клиент читает отсутствующее поле - nil / zero value / паника |
Сменить тип поля ("7" → 7, число → строка) | да | Ошибка десериализации. Особенно больно с int64-id в JS: там теряется точность, поэтому id часто отдают строкой - и менять это назад уже нельзя |
| Сделать необязательное поле обязательным | да | Старые клиенты его не шлют → 422 |
| Сузить формат значения (был свободный текст - стал enum) | да | Ранее валидные запросы начинают отбиваться |
| Добавить значение в enum в ответе | часто да | Клиенты с exhaustive-switch падают на неизвестном значении. Поэтому в контракте сразу пишут «неизвестные значения игнорируйте / трактуйте как unknown» |
Сменить код ответа (200 → 202) | да | Клиент проверяет == 200 |
| Изменить смысл поля при том же имени и типе | да, тихо | Самое опасное: компилируется, парсится, работает неправильно. Пример: amount был в рублях, стал в копейках |
Ужесточить лимиты (maxLimit 1000 → 100), новый rate limit | да | Логика клиента, рассчитанная на старые лимиты, начинает получать 429 |
| Изменить порядок элементов без явной сортировки | да | Клиенты полагаются на наблюдаемое поведение, а не на контракт |
| Удалить ручку | да | Только через deprecation: Deprecation/Sunset-заголовки, срок, метрики использования |
Ломающее изменение выкатывают новой major-версией рядом со старой, старую не правят.
Дальше: Deprecation: @1769904000 (RFC 9745: дата, с которой версия устарела) и Sunset: Sat, 01 Aug 2026 00:00:00 GMT
в ответах v1, метрика api_requests_total{version="v1",client_id=…},
по которой видно, кто ещё жив, рассылка владельцам, и только потом отключение. Есть ещё
приём «brownout»: за месяц до выключения ручка на 5 минут в день отдаёт 410 Gone,
и забытые интеграции находятся до финального дня, а не в момент отключения.
Вопросы
6Как строить ответ вслух
Сначала проговори модель. Сущности здесь post, comment,
author, like. Потом отношения: комментарии принадлежат посту,
лайк - паре (пост, пользователь). И только потом рисуй таблицу ручек. Так видно,
что ты проектируешь от домена, а не от «мне нужен endpoint».
GET /v1/posts 200 список + пагинация
POST /v1/posts 201 + Location создать
GET /v1/posts/{id} 200 / 404 / 304 получить
PUT /v1/posts/{id} 200 / 404 / 409 заменить целиком
PATCH /v1/posts/{id} 200 / 404 / 422 частично изменить
DELETE /v1/posts/{id} 204 / 404 удалить
POST /v1/posts/{id}/publish 200 / 409 смена состояния
GET /v1/posts/{id}/comments 200 / 404 комментарии поста
POST /v1/posts/{id}/comments 201 + Location добавить комментарий
DELETE /v1/comments/{id} 204 / 403 / 404 удалить комментарий
PUT /v1/posts/{id}/likes/me 204 лайк (идемпотентно)
DELETE /v1/posts/{id}/likes/me 204 снять лайк
Что обязательно проговорить
PUTvsPATCH.PUTзаменяет представление целиком и идемпотентен, отсутствующие поля трактуются как «сбросить».PATCHменяет ресурс частично; чтобы отличить «не прислал поле» от «прислал null», в Go нуженjson.RawMessageили свой тип сUnmarshalJSON: указатель*stringв обоих случаях останетсяnil. Иначе zero value затрёт данные.201сLocation, а204- без тела вообще.409для оптимистичной блокировки: клиент шлётIf-Match: "v7", сервер сравнивает с текущимETag. Не совпало, значит412 Precondition Failed, а если версия пришла в теле, то409 Conflict. Без этого два редактора молча затирают правки друг друга.- Действия вне CRUD (
publish,archive) оформляют под-ресурсом сPOST, а не черезPATCH {"status":"published"}, если публикация запускает побочные эффекты (рассылка, индексация): у неё своя авторизация и свои коды. - Идентификаторы. Автоинкремент выдаёт объём базы и позволяет перебирать объекты (IDOR). Для публичного API берут UUIDv7 (сортируемый по времени) или ULID, а в JSON отдают строкой: JS теряет точность на числах больше 2^53.
- Глаголы в путях:
POST /createPost. - Все ответы
200, а ошибка внутри тела. Так ломаются мониторинг, ретраи, кэш и любой стандартный клиент. - Ни слова про пагинацию у
GET /posts, хотя первый вопрос интервьюера будет ровно про неё. DELETEбез разговора про soft delete: для постов с комментариями почти всегда нуженdeleted_at, а не физическое удаление.
/v1/ в пути, заголовок оправдан для дата-версий, query -
почти никогда.В пути
GET /v1/posts/42
Плюсы: видно в логах, метриках и трейсах без разбора заголовков; тривиально
маршрутизируется на уровне ingress (/v1/* → старый деплоймент,
/v2/* → новый), и версии катятся независимо; кэши и CDN различают
версии автоматически, потому что различается URL; можно прислать коллеге ссылку.
Минусы: формально один и тот же ресурс получает два URI, и гипермедиа-ссылки
(HATEOAS) начинают вести в конкретную версию; сохранённые клиентами ссылки привязаны к
версии навсегда.
В заголовке
Accept: application/vnd.blog.v2+json
X-API-Version: 2
Stripe-Version: 2024-06-20
Плюсы: URI остаётся каноническим; это «правильный» HTTP через content negotiation;
можно версионировать представление отдельно от ресурса; удобна модель Stripe, где версия
привязана к аккаунту и меняется осознанно.
Минусы: ничего не видно в access-логах и в браузере, отлаживать больно; обязателен
Vary: Accept (или Vary: X-API-Version), иначе CDN отдаст клиенту
v1 ответ версии v2; нужен дефолт для клиентов, забывших заголовок, и этот дефолт со временем
становится ловушкой.
В query
GET /posts?version=2&tag=go
Плюсы: вкручивается в существующий API за пять минут, легко тестировать из браузера. Минусы: смешивается с бизнес-параметрами и теряется при построении ссылок; ломает кэш-ключи, если не добавить в них параметр; невозможно отроутить на уровне L7-балансировщика так же чисто, как префикс пути; по теории query входит в идентификатор ресурса и к представлению отношения не имеет.
«Лучшая версия - та, которой нет. Большинство изменений можно сделать совместимо: добавить поле, добавить необязательный параметр, добавить ручку. Версия нужна там, где меняется семантика. И ещё: версионировать надо и формат ответа, и поведение. Новый rate limit или новый обязательный заголовок ломает клиентов ровно так же, как удалённое поле». Можно добавить, что во внутренних межсервисных API вместо HTTP-версий обычно развивают protobuf-схему: новые поля с новыми тегами совместимы по определению.
Два вида пагинации
Offset/limit: ?page=3&per_page=20 или ?limit=20&offset=40.
Можно открыть любую страницу и узнать общее количество. Но за это платишь дважды:
СУБД обязана прочитать и выбросить все пропускаемые строки (глубокие страницы стоят
линейно дороже), а вставка или удаление сдвигает окно, и записи дублируются или пропадают
между страницами.
Keyset (cursor / seek): WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 20.
Курсор отдают непрозрачной base64-строкой с кортежем ключа последней отданной строки. По
индексу СУБД сразу встаёт на нужное место, цена страницы постоянна, окно не «плывёт». Минус
в том, что нельзя прыгнуть на произвольную страницу и дёшево посчитать total.
type Cursor struct {
CreatedAt time.Time `json:"t"`
ID int64 `json:"i"`
}
func encodeCursor(c Cursor) string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
// составной ключ сравниваем именно кортежем, чтобы работал индекс:
// WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3
Лимит по умолчанию
- Дефолт 20 (для «тяжёлых» объектов 10, для лёгких справочников 50). Ориентир: ответ должен укладываться в сотни килобайт, а не мегабайты.
- Максимум 100. Больше отдают только через отдельную ручку экспорта с потоковой отдачей (NDJSON) или асинхронной выгрузкой в файл.
- Лимит больше максимума зажимают молча, а не отвечают
400: клиенту нужен результат, а не спор. А вот наlimit=abcиlimit=-5ответ400. - Никогда не отдавать «всё», если параметра нет. Ручка без лимита похожа на мину: сегодня 300 строк, через год 3 миллиона.
Фильтрация и сортировка
- Плоские query-параметры:
?status=published&author_id=3&created_after=…. Язык запросов в URL не изобретают: сложные условия уходят в отдельнуюPOST /v1/posts/searchс телом (и да, это осознанное нарушение «POST не для чтения»). - Несколько значений передают повтором параметра (
?tag=go&tag=api), иr.URL.Query()["tag"]вернёт слайс. - Сортировку пишут как
?sort=-created_at,title, минус = DESC. Поле маппится через map белого списка на выражение SQL. Плейсхолдеры вORDER BYне работают, поэтому альтернативы белому списку нет. - У каждого фильтруемого и сортируемого поля должен быть индекс, иначе API «работает», только пока таблица маленькая.
- «Почему OFFSET 1000000 медленный?» Потому что план всё равно проходит миллион строк (пусть и по индексу) и выбрасывает их. Работа сделана впустую.
- «Как отдать total при keyset?» Дёшево никак. Варианты: не отдавать вовсе
(
has_moreхватает), отдавать приблизительное изpg_class.reltuplesили считать точно отдельной ручкой, которую клиент дёргает по требованию. - «Что если строки с одинаковым created_at?» Нужен tie-breaker: уникальное поле
последним в
ORDER BYи в кортеже курсора. Без него пагинация недетерминирована.
Idempotency-Key, сервер атомарно занимает ключ до списания, сохраняет
ответ и на повторе отдаёт сохранённое, не выполняя операцию.Определение и таблица методов
Операция идемпотентна, если N одинаковых вызовов оставляют систему в том же состоянии, что
и один. GET/HEAD/OPTIONS safe и идемпотентны,
PUT и DELETE идемпотентны, но не safe. POST ни то
ни другое, PATCH - как напишешь. У DELETE второй вызов
может вернуть 404, и идемпотентности это не отменяет: сравнивают состояние
сервера, а не тело ответа.
Зачем это платежам
Клиент, получивший таймаут, не знает, дошёл ли запрос. Сеть теряет ответ, под перезапускается посреди обработки, балансировщик ретраит сам. Без защиты каждый такой случай оборачивается вторым списанием. При этом просто «не ретраить» нельзя: тогда часть платежей теряется.
Механика ключа - по шагам
- Клиент генерирует UUIDv4 на бизнес-операцию (одна кнопка «оплатить» - один ключ) и переиспользует его во всех ретраях. Если генерировать ключ на каждый HTTP-запрос, толку от него нет.
- Сервер пытается вставить строку
(key, user_id, endpoint, sha256(body), in_progress). Уникальный индекс поkeyделает шаг атомарным: параллельную гонку выигрывает ровно один запрос. - Вставка удалась → выполняем операцию и идём в PSP со своим идемпотентным
идентификатором (
merchant_order_id), чтобы и там ретрай не задвоился. - Записываем
status_codeи тело ответа, переводим ключ вdone, в идеале в той же транзакции, где меняется баланс. - Повторный запрос с тем же ключом:
done→ отдаём сохранённый ответ (полезно добавитьIdempotent-Replay: true);in_progress→409 ConflictсRetry-After; тот же ключ, но другойsha256(body)→422: это ошибка клиента, а не ретрай. - Ключи живут ограниченное время (у Stripe 24 часа) и чистятся по
expires_at. Уникальность обычно проверяют в паре (клиент, ключ), чтобы ключ одного мерчанта не блокировал другого.
-- занимаем ключ атомарно одним запросом
INSERT INTO idempotency_keys (key, user_id, endpoint, request_hash, state, expires_at)
VALUES ($1, $2, $3, $4, 'in_progress', now() + interval '24 hours')
ON CONFLICT (user_id, key) DO NOTHING
RETURNING key;
-- пусто в ответе => ключ уже занят, читаем существующую строку и смотрим state
«Ключ занимается до побочного эффекта и завершается после. Тогда падать
можно в любой точке. Если упали до эффекта, ключ висит in_progress, ретрай
получит 409 и попробует позже, а фоновая чистка снимет зависшие ключи по
таймауту. Если упали после эффекта, но до записи ответа, дубль отсечёт уже
сам PSP по нашему merchant_order_id».
- Проверка через SELECT, потом INSERT без уникального индекса даёт классическую гонку: оба параллельных ретрая увидят «ключа нет».
- Хранение ключа в памяти процесса ломается, как только реплик больше одной.
- Не сохранять тело ответа: тогда на ретрай нечего отдать, и приходится выполнять запрос заново.
- Не проверять хеш тела: клиент случайно переиспользует ключ для другого платежа и молча получает чужой ответ, а деньги «пропадают».
- Ключ на
GET/PUTне нужен: они идемпотентны по протоколу.
RFC 7807 / 9457
Media type application/problem+json, поля: type (URI класса
проблемы, де-факто он и служит стабильным кодом ошибки), title (короткое
название, одинаковое для всех вхождений типа), status (дублирует HTTP-код,
чтобы не потерялся при проксировании), detail (что именно пошло не так в
этом вызове), instance (URI вхождения). В расширения кладут любые свои поля:
trace_id, errors[] с разбивкой по полям, retry_after.
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"`
TraceID string `json:"trace_id,omitempty"`
Errors []FieldError `json:"errors,omitempty"`
}
func writeProblem(w http.ResponseWriter, p Problem) {
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(p.Status)
_ = json.NewEncoder(w).Encode(p)
}
// доменные ошибки маппятся в HTTP в одном месте, а не по хендлерам
func toProblem(ctx context.Context, err error) Problem {
tid := trace.SpanContextFromContext(ctx).TraceID().String()
switch {
case errors.Is(err, domain.ErrNotFound):
return Problem{Type: "/errors/not-found", Title: "Not found", Status: 404, TraceID: tid}
case errors.Is(err, domain.ErrConflict):
return Problem{Type: "/errors/conflict", Title: "Conflict", Status: 409, TraceID: tid}
case errors.As(err, new(*domain.ValidationError)):
// ... 422 + разбивка по полям
}
slog.ErrorContext(ctx, "unhandled", "err", err, "trace_id", tid) // детали только в лог
return Problem{Type: "/errors/internal", Title: "Internal error", Status: 500, TraceID: tid}
}
Почему единый формат важнее его конкретного вида
Клиент должен уметь разобрать ошибку одной функцией и ветвиться по стабильному коду,
а не по тексту сообщения. Как только в API появляются три разных формата ошибок (один в
старых ручках, второй в новых, третий отдаёт nginx), клиентский код обрастает эвристиками
вида «если в теле есть слово error». RFC 7807 удобен как готовое решение: его
не надо обсуждать, а по Content-Type ошибку видно, даже
не читая тело.
Почему нельзя светить внутренности
- Разведка перед атакой. Текст
pq: relation "users" does not existсообщает СУБД, схему и имя таблицы. Дальше подбирать инъекцию гораздо проще. - Топология сети.
dial tcp 10.0.3.17:6379: i/o timeoutпоказывает внутренние адреса и состав инфраструктуры. - Версии и пути. Стек-трейс выдаёт версию Go, структуру пакетов, путь сборки, а иногда и имя приватного репозитория.
- Оракул перечисления. «Пользователь не найден» vs «неверный пароль» превращает
логин-форму в инструмент сбора валидных email. То же с
403vs404: разный код подтверждает существование объекта. - Тайминг тоже утечка. Если «нет пользователя» отвечает за 5 мс, а «неверный пароль» за 80 мс (там честно считается bcrypt), разницу видно. Лечится так: хеш считают от заглушки, даже если пользователя нет.
Наружу отдаём класс ошибки и trace id, внутрь пишем всё: исходную ошибку с
%w-цепочкой, SQL, параметры (кроме секретов), стек. Клиент приходит в поддержку с
trace_id, и по нему в логах находится ровно этот
запрос. Это и безопасно, и удобнее, чем текст ошибки в ответе.
Совместимые изменения
- Новое необязательное поле в ответе.
- Новый необязательный query-параметр или поле запроса с дефолтом.
- Новая ручка, новый метод у существующего пути.
- Ослабление валидации (было максимум 100 символов, стало 200).
- Новый заголовок ответа.
Ломающие изменения
- Удаление или переименование поля ответа. Клиент получает zero value и не отличает его от «пусто».
- Смена типа:
"42"→42, число → объект, скаляр → массив.encoding/jsonвернёт ошибку, статически типизированные клиенты упадут. - Новое обязательное поле запроса или новый обязательный заголовок.
- Сужение допустимых значений: свободная строка стала enum, лимит уменьшился, формат даты стал строже.
- Новое значение существующего enum в ответе ломает клиентов с полным перебором вариантов. Формально это добавление, а на деле авария. Поэтому в контракте изначально пишут: «неизвестные значения трактуйте как unknown».
- Смена кода ответа или превращение синхронной ручки в асинхронную
(
200→202). - Изменение семантики при том же имени и типе:
amountиз рублей стал копейками,timeoutиз секунд стал миллисекундами,dateсменил таймзону. Тихо испорченные данные хуже падения. - Изменение поведения: новый rate limit, ужесточённый
maxLimit, другой порядок элементов, другая длина или формат идентификатора (клиент положил его вvarchar(36), а ты выдал 40 символов). - Удаление ручки или изменение пути.
Закон Хайрама и его практический вывод
«При достаточном числе пользователей API неважно, что обещано в контракте: любое
наблюдаемое поведение системы кем-то используется». Поэтому в реальности ломающими
оказываются вещи, которых нет в документации: порядок элементов без ORDER BY,
наличие поля debug, конкретная длина токена, время ответа. Отсюда вывод:
явно писать в контракте, на что нельзя полагаться, и по возможности вносить
небольшой контролируемый хаос (перемешивать порядок там, где он не гарантирован).
- OpenAPI + diff в CI:
oasdiffвалит пайплайн на breaking change. - Contract-тесты (Pact) или просто golden-файлы ответов в тестах.
- Заголовки
DeprecationиSunset+ метрика использования старой версии с разбивкой поclient_id. - Для gRPC есть
buf breakingпо protobuf-схеме: нельзя менять номера тегов и типы полей, можно только добавлять новые.
Что стоит запомнить
- GET должен быть безопасным, а идемпотентность повторного POST обычно строится прикладным ключом.
- Пагинация должна задавать стабильный порядок; keyset полезен на глубокой выдаче и требует уникального tie-breaker.
- Единый problem response отделяет публичную причину от внутренних деталей ошибки.
Частые ошибки
- Не принимай произвольное имя колонки из sort и не подставляй SQL identifier как значение-плейсхолдер.
- Повтор POST без idempotency key может создать дубликат после потери ответа.
- Не меняй смысл поля или код ответа без оценки старых клиентов.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай пагинацию API
Дефолтный порядок и keyset условие заданы так. Какой будет порядок при одинаковом created_at и почему id нужен в курсоре?
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20
Ответ и разбор
Выдача идёт от новых к старым, а следующий сегмент начинается строго после последнего кортежа предыдущей страницы. id разрешает равенство timestamp стабильным уникальным порядком; без него строки с одинаковой датой могут повторяться или пропускаться на границе страниц.
Код-ревью
2. Исправь сортировку из запроса
В handler пользователь напрямую управляет фрагментом SQL. Перепиши так, чтобы поддержать только created_at DESC и title ASC.
sort := r.URL.Query().Get("sort")
q := "SELECT id FROM posts ORDER BY " + sort
Ответ и разбор
SQL-плейсхолдер не подставляет имя колонки или направление. Преобразуй ограниченный набор значений через allowlist: например, "newest" -> "created_at DESC, id DESC", "title" -> "title ASC, id ASC"; для неизвестного значения верни validation error или безопасный default. Не склеивай пользовательскую строку даже после экранирования.
Применение
3. Сделай POST повторяемым
Клиент создал платеж, но потерял HTTP-ответ и повторил запрос. Опиши роль Idempotency-Key, request hash и атомарной записи результата.
Ответ и разбор
Ключ идентифицирует логическую попытку клиента. Сохрани его с user_id/endpoint и хэшем тела; первый запрос атомарно занимает ключ и выполняет платёж/запись результата, повтор с тем же hash получает сохранённый статус и тело. Тот же ключ с другим телом нужно отклонить. Ограничь срок хранения и учитывай конкурентные запросы одним уникальным ограничением.
Применение
4. Выбери HTTP-код
POST /exports принимает задачу, которая будет готова через минуту. Затем клиент запрашивает результат по URL. Какие статусы логичны при приёме задачи и после готовности?
Ответ и разбор
На приёме верни 202 Accepted и ссылку/идентификатор операции, потому что экспорт ещё не готов. GET статуса может вернуть 200 с состоянием, а GET готового ресурса - 200 с результатом. 201 подходит для созданного ресурса, если сама задача/экспорт уже создан как ресурс; статус должен отражать фактическое завершение.