Справочник

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 Requestinvalid_argument
не аутентифицирован401 Unauthorizedunauthenticated
нет прав403 Forbiddenpermission_denied
не найдено404 Not Foundnot_found
конфликт версий или уникальности409 Conflictconflict
нарушено правило домена422 Unprocessable Entityfailed_precondition
слишком много запросов429 Too Many Requestsresource_exhausted
внутренняя ошибка500 Internal Server Errorinternal
таймаут или недоступность зависимости503 Service Unavailableunavailable
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.Timeout0 — без ограниченияжёсткий предел на весь запрос
Transport.MaxIdleConns100размер общего пула
Transport.MaxIdleConnsPerHost2параллелизм к одному хосту
Transport.IdleConnTimeout90 свремя жизни простаивающего соединения
Transport.TLSHandshakeTimeout10 смедленное TLS-рукопожатие
Transport.ResponseHeaderTimeout0ожидание заголовков ответа
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. Первоисточники