HTTP-сервис — справочник
Запрос от сокета до ответа: сервер и таймауты, ServeMux, middleware, лимиты тела, ошибки как контракт, graceful shutdown, клиент, наблюдаемость и тесты.
Собрано из документации net/http, release notes Go 1.22–1.27 и статей Go Concurrency Patterns: Context и Error handling and Go. Примеры проверены go build, go vet и httptest.
1. Карта запроса
net/http принимает соединение, читает запрос, выбирает обработчик по ServeMux и вызывает ServeHTTP. Обработчик получает ResponseWriter и *Request; ответ уходит по тому же соединению, если клиент его не закрыл.
| Шаг | Кто отвечает | Что происходит |
|---|---|---|
| 1. Соединение | Server | принимает TCP, читает заголовки с ограничением |
| 2. Маршрут | ServeMux | сопоставляет метод и путь с шаблоном |
| 3. Middleware | обёртки http.Handler | лог, восстановление после паники, request ID, аутентификация |
| 4. Handler | ваш код | читает тело, вызывает сервис, пишет ответ |
| 5. Сервис | домен | правила, транзакции, обращения к хранилищу |
| 6. Ответ | ResponseWriter | заголовки, статус, тело |
Порядок middleware — порядок обёрток: внешняя видит запрос первой, а ответ последней. Восстановление после паники ставят снаружи, чтобы поймать панику любого внутреннего слоя.
2. Server и таймауты
Нулевой таймаут означает «без ограничения». Это удобно для локальной разработки и опасно в production: медленный клиент держит соединение и горутину сколько угодно.
| Поле | Что ограничивает | Ноль |
|---|---|---|
ReadHeaderTimeout | чтение заголовков запроса | берётся ReadTimeout |
ReadTimeout | чтение всего запроса, включая тело | без ограничения |
WriteTimeout | запись ответа | без ограничения |
IdleTimeout | простой keep-alive соединения | берётся ReadTimeout |
MaxHeaderBytes | размер заголовков | DefaultMaxHeaderBytes = 1 МиБ |
BaseContext | корневой контекст всех запросов | context.Background() |
ConnContext | контекст отдельного соединения | без изменений |
srv := &http.Server{
Addr: ":8080",
Handler: api.Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
ReadHeaderTimeout защищает от медленных заголовков (Slowloris), ReadTimeout — от медленного тела, WriteTimeout — от медленного читателя. Для отдельного обработчика дедлайн можно поменять через http.NewResponseController [1.20].
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Now().Add(2 * time.Minute))
io.Copy(w, bigFile)
3. ServeMux и маршруты
С Go 1.22 шаблон ServeMux может содержать метод и подстановки. Метод указывается первым словом; шаблон с методом приоритетнее такого же без метода. Регистрация GET автоматически покрывает HEAD.
| Шаблон | Что совпадает |
|---|---|
/users | точный путь /users |
/users/ | поддерево: /users, /users/1, /users/1/posts |
GET /users/{id} | один сегмент; значение — r.PathValue("id") |
GET /files/{path...} | остаток пути, включая слэши |
GET /users/{$} | только точный /users, без поддерева |
example.com/ | запросы к указанному хосту |
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", a.getUser)
mux.HandleFunc("POST /users", a.createUser)
mux.HandleFunc("GET /files/{path...}", a.getFile)
func (a *API) getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
}
Если два шаблона пересекаются, выигрывает более конкретный: метод важнее его отсутствия, литеральный сегмент важнее подстановки, более длинный путь важнее короткого. Конфликтующие шаблоны ServeMux паникует при регистрации. С Go 1.23 сработавший шаблон доступен в r.Pattern.
4. Handler и middleware
http.Handler — интерфейс с одним методом ServeHTTP(w, r). http.HandlerFunc превращает функцию в обработчик. Middleware — функция, которая принимает и возвращает http.Handler.
func logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"dur", time.Since(start),
)
})
}
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
slog.Error("panic", "value", v, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal", "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
handler := recoverer(logger(mux))
Если ответ уже начат, writeError из recover не сможет поменять статус: заголовки ушли. Поэтому панику лучше не доводить до записи ответа, а восстановление всё равно оставить последней линией.
5. Request: тело, лимиты, контекст
| Что нужно | API |
|---|---|
| тело | r.Body, json.NewDecoder, io.ReadAll |
| ограничить тело | http.MaxBytesReader(w, r.Body, n) |
| query-параметры | r.URL.Query(), r.URL.Query().Get("q") |
| форма | r.ParseForm(), r.FormValue, r.PostFormValue |
| заголовки | r.Header.Get, r.UserAgent, r.Host |
| контекст | r.Context() |
| подстановка пути | r.PathValue("id") |
| отмена клиентом | <-r.Context().Done() |
func (a *API) createUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1 << 20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req User
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
writeJSON(w, http.StatusCreated, req)
}
Контекст запроса отменяется, когда клиент закрыл соединение, отменил запрос или обработчик вернулся. Всё, что делает handler, должно принимать ctx и проверять отмену: запросы к базе, HTTP-вызовы, тяжёлые циклы.
ctx := r.Context()
select {
case <-ctx.Done():
return ctx.Err()
case result := <-work:
writeJSON(w, http.StatusOK, result)
}
6. Response: статус, заголовки, JSON
ResponseWriter имеет три метода: Header(), Write([]byte), WriteHeader(status). Заголовки нужно установить до WriteHeader; первый Write без явного статуса отправляет 200 OK. Повторный WriteHeader игнорируется с предупреждением.
| Задача | Как |
|---|---|
| JSON-ответ | Content-Type: application/json; charset=utf-8, затем json.NewEncoder(w).Encode |
| ошибка | http.Error(w, msg, code) — текст и X-Content-Type-Options: nosniff |
| редирект | http.Redirect(w, r, url, code) |
| файл | http.ServeFile, http.FileServer |
| поток | http.Flusher, http.NewResponseController(w).Flush() |
| заголовки безопасности | X-Content-Type-Options, Content-Security-Policy, Strict-Transport-Security |
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
Если Encode вернул ошибку, статус уже отправлен: поменять его нельзя. Для ответов, которые могут не сериализоваться, сначала кодируют в буфер, затем пишут статус и тело.
7. Ошибки как контракт
HTTP-ответ должен быть предсказуемым: одинаковый JSON для ошибок, стабильные коды, понятное сообщение. Внутренние детали — текст ошибки базы, стек, имена таблиц — наружу не отдают; их пишут в лог вместе с request ID.
| Домен | HTTP-статус | Код в теле |
|---|---|---|
| неверный ввод | 400 Bad Request | invalid_argument |
| не аутентифицирован | 401 Unauthorized | unauthenticated |
| нет прав | 403 Forbidden | permission_denied |
| не найдено | 404 Not Found | not_found |
| конфликт версий или уникальности | 409 Conflict | conflict |
| нарушено правило домена | 422 Unprocessable Entity | failed_precondition |
| слишком много запросов | 429 Too Many Requests | resource_exhausted |
| внутренняя ошибка | 500 Internal Server Error | internal |
| таймаут или недоступность зависимости | 503 Service Unavailable | unavailable |
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, errorBody{Code: code, Message: msg})
}
func (a *API) getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
return
}
u, err := a.svc.Get(r.Context(), id)
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "user not found")
return
}
if err != nil {
slog.Error("get user", "err", err, "id", id)
writeError(w, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, http.StatusOK, u)
}
Отображение «ошибка домена → статус» держат в одном месте, рядом с обработчиком. Если каждая ручка придумывает статусы сама, клиент перестаёт понимать API.
8. Graceful shutdown
Server.Shutdown закрывает слушатель, перестаёт принимать новые соединения и ждёт, пока активные запросы завершатся. ListenAndServe после этого возвращает http.ErrServerClosed — это штатное завершение, а не ошибка.
func run(ctx context.Context) error {
srv := &http.Server{
Addr: ":8080",
Handler: api.Routes(),
ReadHeaderTimeout: 5 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
log.Fatal(err)
}
Shutdown не ждёт соединений, которые обработчик забрал через Hijacker (WebSocket, свой протокол): их закрывает приложение. Контекст самого сервера отменяется только после возврата из Shutdown.
9. Клиент
http.Client переиспользует соединения через Transport. Создавать новый клиент на каждый запрос бессмысленно: каждый получит свой пул соединений. Client.Timeout покрывает весь запрос: соединение, редиректы и чтение тела.
| Настройка | По умолчанию | Зачем менять |
|---|---|---|
Client.Timeout | 0 — без ограничения | жёсткий предел на весь запрос |
Transport.MaxIdleConns | 100 | размер общего пула |
Transport.MaxIdleConnsPerHost | 2 | параллелизм к одному хосту |
Transport.IdleConnTimeout | 90 с | время жизни простаивающего соединения |
Transport.TLSHandshakeTimeout | 10 с | медленное TLS-рукопожатие |
Transport.ResponseHeaderTimeout | 0 | ожидание заголовков ответа |
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
Тело ответа нужно закрывать всегда, иначе соединение не вернётся в пул. Для повторных попыток запрос должен быть идемпотентным, а тело — воспроизводимым: Transport повторяет только те запросы, которые можно повторить безопасно.
10. Наблюдаемость
Минимум: идентификатор запроса, структурный лог, метрики по коду ответа и длительности. Request ID кладут в контекст и в заголовок ответа, чтобы клиент мог назвать его в поддержке.
type ctxKey string
const requestIDKey ctxKey = "request_id"
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newID()
}
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Логи — через log/slog, с полями method, path, status, dur, request_id. Метрики — счётчик запросов и гистограмма длительности по маршруту и статусу; маршрут берут из r.Pattern, а не из сырого пути, иначе кардинальность взорвётся на /users/1, /users/2.
| Сигнал | Что отвечает |
|---|---|
| лог запроса | что именно произошло с конкретным запросом |
метрика http_requests_total | сколько запросов и с какими статусами |
гистограмма http_request_duration_seconds | распределение задержек, p95 и p99 |
| трассировка | где именно ушло время между сервисами |
11. Тестирование
httptest.NewRecorder проверяет обработчик без сети: запрос и ответ живут в памяти. httptest.NewServer поднимает настоящий сервер на случайном порту — нужен, когда проверяется клиент, middleware или поведение соединения.
func TestGetUser(t *testing.T) {
api := &API{svc: &Service{}}
srv := httptest.NewServer(api.Routes())
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/users/1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
Для табличных тестов обработчика:
req := httptest.NewRequest(http.MethodGet, "/users/abc", nil)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body)
}
httptest.NewRequest не запускает сеть и не требует порта; httptest.NewServer нужно закрывать через defer, иначе тест оставит слушатель.
12. Первоисточники
- pkg.go.dev/net/http —
Server,Client,ServeMux,Request,ResponseWriter - pkg.go.dev/net/http/httptest —
NewServer,NewRecorder,NewRequest - Go Concurrency Patterns: Context — контекст в сервере и клиенте
- Error handling and Go — ошибки как значения
- Release notes Go 1.20 —
http.NewResponseController - Release notes Go 1.22 — методы и подстановки в шаблонах
ServeMux - Release notes Go 1.23 —
Request.Pattern - Release notes Go 1.25 —
http.CrossOriginProtection