HTTP API¶
Правила для HTTP-транспорта: роутер, middleware, формат request/response, пагинация, health и metrics endpoint'ы. Все сервисы следуют этим правилам — клиенту не нужно помнить, как именно отдаёт ошибки каждый конкретный сервис.
Путь одного запроса сверху вниз:
flowchart TD
C[Client / Gateway]
C -->|HTTP| MW1[RequestID]
MW1 --> MW2[RealIP]
MW2 --> MW3[Recoverer]
MW3 --> MW4[Logger]
MW4 --> MW5[Timeout 30s]
MW5 --> MW6{"Route match?"}
MW6 -->|/v1/*| AUTH[GatewayAuth<br/>HMAC headers]
MW6 -->|/internal/*| ITK[InternalToken]
MW6 -->|/healthz<br/>/readyz<br/>/metrics| NOAUTH[skip auth]
AUTH --> RL[RateLimit]
ITK --> H[handler]
RL --> H
NOAUTH --> H
H -->|service err| MAP[mapServiceError]
H -->|success| RESP["writeJSON<br/>{data: ...}"]
MAP --> ERR["writeError<br/>{error: {code, message, request_id}}"]
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 на корневом роутере подключаются строго в таком порядке:
| 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 — массив:
Ошибка¶
{
"error": {
"code": "invalid_credentials",
"message": "invalid login or password",
"request_id": "01HZ3G..."
}
}
code— машиночитаемый,snake_case, стабильный (клиент может на него ветвиться).message— человекочитаемый, без внутренних деталей (см.../conventions/security.md).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.md.
Пагинация¶
Cursor-based — по умолчанию¶
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 review.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¶
Допустим только на 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.md.
Фильтрация и сортировка¶
Сортировка¶
Формат: список полей через запятую. Префикс - — desc, без префикса — asc.
Поля, по которым разрешена сортировка, — whitelist в handler'е; всё
остальное → 400. Не пробрасывай ?sort= прямо в SQL.
Фильтры¶
Парсинг:
Разрешённые ключи фильтров — тоже whitelist. Ошибка в ключе → 400.
Content-Type¶
POST,PUT,PATCHс телом — требуемContent-Type: application/json. Иное → 415 Unsupported Media Type.GET,DELETE— тело игнорируется (даже если пришло).- Ответ всегда
Content-Type: application/json; charset=utf-8.
Таймауты¶
На корневом роутере:
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¶
- Не под 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.md— пошаговый рецепт добавления endpoint'а.../checklists/new-endpoint.md— чеклист перед PR.security.md— авторизация,GatewayAuth,InternalToken, заголовки, rate limiting.error-handling.md— error mapping в HTTP-коды.observability.md— метрики и трейсинг на HTTP уровне.../patterns/retry-and-circuit-breaker.md— retry/CB на клиентской стороне HTTP.../patterns/api-composition.md— batch-endpoint'ы, параллельная склейка данных.slo-and-budget.md— как задать SLO для HTTP-endpoint'а.