HTTP API
Правила для HTTP-транспорта: роутер, middleware, формат request/response, пагинация, health и metrics endpoint’ы. Все сервисы следуют этим правилам — клиенту не нужно помнить, как именно отдаёт ошибки каждый конкретный сервис.
Путь одного запроса сверху вниз:
Middleware порядок строгий — ошибка в порядке часто ломает корреляцию в логах или приводит к тому, что handler отвечает после deadline.
Содержание
- Роутер
- Порядок middleware
- Декодирование запроса
- Валидация
- Формат ответа
- Маппинг ошибок
- Логирование запросов
- Пагинация
- Фильтрация и сортировка
- Content-Type
- Таймауты
- Health endpoints
- Metrics endpoint
/v1/*vs/internal/*- Что не делать
- См. также
Роутер
Стандарт — chi. Никаких других роутеров
(gorilla/mux, gin, echo) в проекте не заводим.
Корневой роутер собирается в internal/handler/router.go:
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func NewRouter(d Deps) http.Handler {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Recoverer)
r.Use(chimw.Logger)
r.Use(chimw.Timeout(30 * time.Second))
// health / meta — без auth
r.Get("/healthz", d.Health.Live)
r.Get("/readyz", d.Health.Ready)
r.Handle("/metrics", promhttp.Handler())
// /v1/* — публичный API через gateway
r.Route("/v1", func(r chi.Router) {
r.Use(middleware.GatewayAuth(d.Cfg.Gateway.HMACKey))
r.Mount("/reviews", d.Review.Routes())
r.Mount("/profile", d.Profile.Routes())
})
// /internal/* — сервис-к-сервису
r.Route("/internal", func(r chi.Router) {
r.Use(middleware.InternalToken(d.Cfg.Internal.APIToken))
r.Get("/reviews/{id}", d.Internal.GetReview)
})
return r
}Sub-router одного ресурса группируется методом Mount или Route. Каждый
handler-пакет возвращает chi.Router или функцию, принимающую
chi.Router — не хардкодь пути в разных местах.
Порядок middleware
Middleware на корневом роутере подключаются строго в таком порядке:
RequestID → RealIP → Recoverer → Logger → Timeout → Auth → RateLimit → handler| Middleware | Почему здесь |
|---|---|
RequestID | Первым — иначе последующие middleware не смогут писать request-id в логи / header’ы. |
RealIP | До Logger — иначе в лог попадёт адрес прокси, а не клиента. |
Recoverer | До всего бизнес-кода — чтобы panic из любого нижнего middleware/handler превращался в 500, а не валил процесс. |
Logger | После Recoverer — чтобы паники логировались как ошибки, не оставляя «дыр» в access-логе. |
Timeout | До Auth — чтобы медленный токен-чекер не держал запрос дольше SLA. |
Auth | После Timeout — иначе за таймаут ответим 504, хотя клиент прислал невалидный токен. |
RateLimit | После Auth — считаем лимиты per-user, а не per-IP, где это возможно. |
Порядок внутри sub-router’ов (например, /v1/admin): GatewayAuth → RequireRole → RateLimit (если нужен) → handler.
Декодирование запроса
JSON-тело
func decodeJSON(r *http.Request, v any) error {
r.Body = http.MaxBytesReader(nil, r.Body, 64<<10)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(v)
}MaxBytesReaderограничивает размер тела — без него злонамеренный клиент может отправить гигабайт JSON и съесть память.DisallowUnknownFields()отклоняет запросы с опечатками ("rating"→"ratting"). Без этого опечатка клиента молча потеряет поле.- Для
POST/PUT/PATCHпроверяйContent-Type: если не начинается сapplication/json— отвечай 415.
Query и path параметры
// path: /v1/reviews/{id}
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "review id must be integer")
return
}
// query: ?limit=20&cursor=abc
limitStr := r.URL.Query().Get("limit")
cursor := r.URL.Query().Get("cursor")Никакой «авто-парсинг» query-параметров через магические библиотеки —
только r.URL.Query().Get() и явный парсинг в нужный тип.
Валидация
Стандарт — github.com/go-playground/validator/v10. Теги в struct:
type CreateReviewRequest struct {
PlaceID int64 `json:"place_id" validate:"required,gt=0"`
Rating int16 `json:"rating" validate:"required,min=1,max=5"`
Text string `json:"text" validate:"max=2000"`
}Один глобальный экземпляр на пакет:
var validatorInstance = validator.New(validator.WithRequiredStructEnabled())
func validate(in any) error {
return validatorInstance.Struct(in)
}Ошибки валидации → 400 с деталями по каждому невалидному полю:
if err := validate(req); err != nil {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
writeValidationError(w, verrs)
return
}
writeError(w, http.StatusBadRequest, "invalid_request", "validation failed")
return
}writeValidationError отдаёт список {"field": "rating", "rule": "max", "param": "5"}.
Формат ответа
Успех
{
"data": {
"id": 42,
"rating": 5,
"text": "ok"
}
}Для списков data — массив:
{
"data": [ {"id": 1}, {"id": 2} ],
"next_cursor": "eyJsYXN0X2lkIjoyfQ=="
}Ошибка
{
"error": {
"code": "invalid_credentials",
"message": "invalid login or password",
"request_id": "01HZ3G..."
}
}code— машиночитаемый,snake_case, стабильный (клиент может на него ветвиться).message— человекочитаемый, без внутренних деталей (см.../conventions/security).request_id— изX-Request-Idheader’а, чтобы клиент мог сослаться на конкретный запрос в support-тикете.
Маппинг ошибок
Service-слой возвращает sentinel-ошибки. Handler превращает их в HTTP-статус через один централизованный маппер:
func mapServiceError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, service.ErrValidation):
writeError(w, http.StatusBadRequest, "invalid_input", err.Error())
case errors.Is(err, service.ErrUnauthorized):
writeError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
case errors.Is(err, service.ErrForbidden):
writeError(w, http.StatusForbidden, "forbidden", "forbidden")
case errors.Is(err, service.ErrConflict):
writeError(w, http.StatusConflict, "conflict", err.Error())
case errors.Is(err, service.ErrRateLimit):
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many requests")
default:
writeError(w, http.StatusInternalServerError, "internal", "internal error")
}
}Таблица соответствия:
| Sentinel из service | HTTP | code |
|---|---|---|
ErrNotFound | 404 | not_found |
ErrValidation | 400 | invalid_input |
ErrUnauthorized | 401 | unauthorized |
ErrForbidden | 403 | forbidden |
ErrConflict | 409 | conflict |
ErrRateLimit | 429 | rate_limited |
| default | 500 | internal |
Никаких http.StatusX констант прямо в handler-коде. Хочешь новый статус —
добавь sentinel в service и правило в mapServiceError.
Логирование запросов
Access-лог пишется middleware (chimw.Logger или кастомным), не в
каждом handler’е. Handler пишет только бизнес-события (slog.Info("review created", "review_id", r.ID)), не «got request for /v1/…».
Формат, поля и маскирование PII — см. logging.
Пагинация
Cursor-based — по умолчанию
GET /v1/reviews?cursor=<opaque>&limit=20{
"data": [ ... ],
"next_cursor": "eyJsYXN0X2lkIjo0Mn0="
}cursor— base64-encoded JSON или компактная opaque-строка. Содержимое — деталь реализации сервера, клиент её не парсит.limit— число, максимум 100, дефолт 20. Если клиент прислал больше — отвечаем 400.- Пустой
next_cursor(или отсутствует) — значит, страниц больше нет.
Cursor должен быть устойчив к параллельным вставкам: кодируй в него
(created_at, id) или аналог, чтобы WHERE (created_at, id) < (cursor) давал
детерминированный порядок.
Cursor vs offset: почему именно cursor
| Cursor-based | Offset-based | |
|---|---|---|
| Производительность на глубине | WHERE (cts, id) < (...) + LIMIT → index scan, O(limit) | OFFSET 100000 LIMIT 20 → index scan + skip 100k, O(offset) |
| При вставке новых строк между страницами | Стабилен: показываешь ровно то, что было «до курсора» | Сдвиг: новая строка вставилась → страница 3 показывает часть того, что было на странице 2 |
| Duplicate/skip | Не бывает (курсор — точка в упорядоченном потоке) | Бывает: на границах страниц при вставках/удалениях |
| «Прыгнуть на страницу 50» | Нельзя (только вперёд через next) | Можно |
| Deep pagination на больших таблицах | Работает на 10M+ строках | Падает по latency на 100k+ OFFSET |
Deep pagination — практическая причина, почему cursor дефолтный. На
таблице reviews в 50M строк OFFSET 100000 LIMIT 20 с ORDER BY created_at DESC занимает ~4 секунды; cursor — ~20ms при том же индексе.
Реализация cursor
Cursor кодирует последнюю возвращённую строку. Для ORDER BY
created_at DESC:
type cursor struct {
CreatedAt time.Time `json:"c"`
ID int64 `json:"i"`
}
func encodeCursor(c cursor) string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeCursor(s string) (*cursor, error) {
if s == "" { return nil, nil }
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil { return nil, err }
var c cursor
if err := json.Unmarshal(b, &c); err != nil { return nil, err }
return &c, nil
}Запрос:
const q = `
SELECT id, rating, text, created_at
FROM reviews
WHERE place_id = $1
AND deleted_at IS NULL
AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
ORDER BY created_at DESC, id DESC
LIMIT $4
`
rows, err := pool.Query(ctx, q, placeID, cursor.CreatedAt, cursor.ID, limit+1)Мы запрашиваем limit+1 строк — если вернулось limit+1, значит есть
следующая страница, возвращаем первые limit и cursor от последней
из них.
Индекс под такой запрос — композитный по (place_id, created_at DESC, id DESC) с WHERE deleted_at IS NULL. Без него запрос деградирует до
seq scan на больших таблицах.
Offset-based — только для admin
GET /v1/admin/users?page=3&per_page=50Допустим только на admin/backoffice-endpoint’ах, где ожидается малая аудитория, маленькая таблица (< 100k строк) и клиенту нужна возможность «прыгнуть» на конкретную страницу. На публичных endpoint’ах — строго cursor-based.
N+1 при api-composition
List-view часто собирает данные из нескольких сервисов: список reviews
- автор каждого ревью. Наивная имплементация:
// ПЛОХО: N+1
reviews, _ := r.reviews.List(ctx, placeID, limit)
for i, rev := range reviews {
user, _ := r.userClient.Get(ctx, rev.UserID) // HTTP-запрос на каждый
reviews[i].Author = user
}На 20 reviews → 21 HTTP-запрос, latency = N × RTT + host connection pool contention.
Правильно — batch-запрос:
reviews, _ := r.reviews.List(ctx, placeID, limit)
userIDs := make([]int64, 0, len(reviews))
for _, rev := range reviews { userIDs = append(userIDs, rev.UserID) }
users, _ := r.userClient.GetBatch(ctx, userIDs) // один HTTP-запрос
userMap := make(map[int64]*User, len(users))
for _, u := range users { userMap[u.ID] = u }
for i, rev := range reviews {
reviews[i].Author = userMap[rev.UserID]
}Требует, чтобы downstream-сервис выставлял /internal/users:batch
endpoint (GET /internal/users?ids=1,2,3 или POST /internal/users:batch
с body {ids: [...]}). Это требование архитектуры — любой сервис,
на данные которого делается api-composition, обязан дать batch-endpoint.
Параллельные запросы через errgroup — когда нужно склеивать данные
из двух и более downstream-сервисов:
g, gctx := errgroup.WithContext(ctx)
var users []*User
var places []*Place
g.Go(func() error {
u, err := r.userClient.GetBatch(gctx, userIDs)
users = u; return err
})
g.Go(func() error {
p, err := r.placeClient.GetBatch(gctx, placeIDs)
places = p; return err
})
if err := g.Wait(); err != nil { return nil, err }Оба запроса идут одновременно — latency = max(user, place) вместо
user + place.
Подробно — ../patterns/api-composition.
Фильтрация и сортировка
Сортировка
GET /v1/reviews?sort=-created_at,ratingФормат: список полей через запятую. Префикс - — desc, без префикса — asc.
Поля, по которым разрешена сортировка, — whitelist в handler’е; всё
остальное → 400. Не пробрасывай ?sort= прямо в SQL.
Фильтры
GET /v1/reviews?filter[status]=active&filter[place_id]=42Парсинг:
status := r.URL.Query().Get("filter[status]")
placeIDStr := r.URL.Query().Get("filter[place_id]")Разрешённые ключи фильтров — тоже whitelist. Ошибка в ключе → 400.
Content-Type
POST,PUT,PATCHс телом — требуемContent-Type: application/json. Иное → 415 Unsupported Media Type.GET,DELETE— тело игнорируется (даже если пришло).- Ответ всегда
Content-Type: application/json; charset=utf-8.
Таймауты
На корневом роутере:
r.Use(chimw.Timeout(30 * time.Second))Handler видит ctx.Done() по истечении и обязан учитывать это в
долгих операциях (SQL, HTTP-клиенты, Kafka). Не игнорируй ctx в
handler’е.
На HTTP-сервере тоже явные таймауты:
srv := &http.Server{
Addr: cfg.Service.Addr(),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}Без ReadHeaderTimeout сервис уязвим к slowloris-атакам (клиент тянет
headers вечно).
Health endpoints
/healthz— liveness. Просто возвращает 200 OK, если процесс жив. Не проверяет БД/Redis/Kafka: перезапускать pod при сетевом глюке соседа нельзя./readyz— readiness. Проверяет, что сервис готов обслуживать трафик:pool.Ping,redis.Ping, Kafka-broker reachable. При любой недоступности — 503.
Оба endpoint’а — не под auth. Kubernetes probe ходит без headers.
func (h *HealthHandler) Live(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.pool.Ping(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "db_unavailable", "db unavailable")
return
}
if err := h.kafka.Ping(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "kafka_unavailable", "kafka unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}Metrics endpoint
r.Handle("/metrics", promhttp.Handler())- Не под auth: внутренний Prometheus ходит без токена.
- Gateway не роутит
/metricsнаружу. В публичный Интернет этот путь не попадает. - Не добавляй кастомные middleware на
/metrics— не логируй его в access-лог (иначе логи будут забиты scrape-запросами).
/v1/* vs /internal/*
Сервис выставляет два набора endpoint’ов:
-
/v1/*— публичные. Приходят через API Gateway, у них есть JWT- авторизация (подписанные headers от gateway) и rate-limit. -
/internal/*— сервис-к-сервису. Защищены отдельным токеном:r.Route("/internal", func(r chi.Router) { r.Use(middleware.InternalToken(cfg.Internal.APIToken)) r.Get("/reviews/{id}", h.Internal.GetReview) })INTERNAL_API_TOKEN— единый для всех/internal/*endpoint’ов одного сервиса. Сравнение черезsubtle.ConstantTimeCompare, чтобы не протекать по тайм-сайду. Плюс на уровне Kubernetes/internal/*закрыт NetworkPolicy: трафик разрешён только от других pod’ов кластера, извне — нет.
Никаких «полупубличных» путей (/v1/internal/..., /admin/internal/...) —
два строго разделённых пространства имён.
Что не делать
- Не возвращай «голый» payload (
{"id":1}без оборачивания вdata). Это ломает единый формат и мешает добавлять в ответ метаинформацию (cursor, warnings). - Не пиши в handler’е
returnпослеwriteError, забыв проreturn— оба вызова выполнятся, в ответ уйдут два JSON-а подряд. - Не делай offset-пагинацию на публичных list-endpoint’ах.
OFFSET 100000сканирует 100000 строк на каждый запрос. - Не пиши status code прямо в handler (
w.WriteHeader(404)). Всегда черезmapServiceError/writeError. - Не читай
r.Header.Get("X-Forwarded-For")напрямую в handler’е — это делаетRealIPmiddleware. Адрес клиента достаёшь черезr.RemoteAddrпосле middleware.
См. также
../how-to/add-http-endpoint— пошаговый рецепт добавления endpoint’а.../checklists/new-endpoint— чеклист перед PR.security— авторизация,GatewayAuth,InternalToken, заголовки, rate limiting.error-handling— error mapping в HTTP-коды.observability— метрики и трейсинг на HTTP уровне.../patterns/retry-and-circuit-breaker— retry/CB на клиентской стороне HTTP.../patterns/api-composition— batch-endpoint’ы, параллельная склейка данных.slo-and-budget— как задать SLO для HTTP-endpoint’а.