voidlearn
 

10.2HTTP-сервер в Go

Проследи, как net/http принимает соединение, вызывает handler и удерживает ресурсы на время запроса. Настрой таймауты по фазам, размер тела и shutdown так, чтобы медленный клиент или SIGTERM не оставляли процесс без контроля.

Путь запроса: от Listener до твоего хендлера

Внутри http.ListenAndServe(addr, handler) всего три строчки: создать &http.Server{Addr: addr, Handler: handler}, открыть слушающий сокет через net.Listen("tcp", addr) и уйти в srv.Serve(l). Дальше начинается цикл, который стоит уметь рассказать пошагово.

  1. l.Accept() в бесконечном цикле в горутине, вызвавшей Serve. Если Accept вернул временную ошибку (например, кончились файловые дескрипторы), сервер не падает, а спит по нарастающей (5 мс, 10, 20… до 1 с) и пробует снова.
  2. На принятое соединение создаётся c := srv.newConn(rw) и сразу запускается go c.serve(connCtx). Пула воркеров нет: одна горутина на одно TCP-соединение. В HTTP/1.x не на запрос, а именно на соединение; HTTP/2 внутри соединения запускает ещё по горутине на каждый поток.
  3. Внутри c.serve для HTTPS сначала идёт TLS-handshake (там же согласуется ALPN: h2 или http/1.1), потом цикл чтения запросов.
  4. c.readRequest(ctx) парсит стартовую строку и заголовки в *http.Request. Тело не читается: в r.Body кладётся обёртка над буферизованным ридером соединения, и байты потекут, только когда хендлер сам вызовет Read.
  5. serverHandler{srv}.ServeHTTP(w, req) при srv.Handler == nil подставляет http.DefaultServeMux. Затем роутер выбирает нужный Handler и вызывает его ServeHTTP. Это уже твой код.
  6. Когда хендлер вернулся, w.finishRequest() сбрасывает буфер ответа в сокет, дочитывает и выбрасывает остаток тела запроса (иначе соединение нельзя переиспользовать) и закрывает chunked-поток.
  7. Если клиент и сервер согласны на keep-alive и ошибок не было, цикл делает continue, и та же горутина ждёт следующий запрос на том же соединении. Иначе вызывается c.close(), и горутина завершается.
main-горутина srv.ListenAndServe() l, _ := net.Listen(tcp, addr) for { rw, err := l.Accept() } c := srv.newConn(rw) go c.serve(connCtx) горутина соединения: c.serve(ctx) если TLS: handshake + ALPN (h2 / http1.1) w, req := c.readRequest(ctx) serverHandler{srv}.ServeHTTP(w, req) mux.ServeHTTP: match паттерна твой Handler.ServeHTTP(w, r) finishRequest: flush + дочитать тело keep-alive и не было ошибок? да - тот же conn берёт следующий запрос нет - c.close() Что из этого важно на собесе В HTTP/1.x горутина на СОЕДИНЕНИЕ, а не на запрос. Пула воркеров и лимита на соединения нет - 10 000 клиентов дадут 10 000 горутин. Ограничивать надо самому: netutil.LimitListener, лимиты на LB, ulimit по дескрипторам.
Обработка запроса в net/http. Слева работает принимающий цикл, справа - жизнь одного соединения. Правый цикл крутится, пока живёт keep-alive, и всё это время держит одну горутину.
Глубже: почему в Go «горутина на соединение» не антипаттерн

В C++/Java эта модель считается антипаттерном: поток ОС стоит мегабайты стека и переключение через ядро. В Go горутина стартует со стеком от 2 КиБ (с Go 1.19 рантайм может сразу дать больше, по среднему размеру стеков на прошлой сборке мусора), а все сетевые вызовы проходят через netpoller (epoll/kqueue внутри рантайма). Когда горутина упирается в Read на сокете без данных, рантайм её паркует (gopark) и снимает с потока ОС; поток уходит выполнять другие горутины. Получается модель, которая пишется как блокирующая, а работает как асинхронная. За это платят памятью: каждое соединение держит горутину, буфер чтения (4 КиБ) и буфер записи (4 КиБ), то есть примерно 10–20 КиБ на idle-соединение. Отсюда вывод: IdleTimeout нужен, чтобы сервер не держал десятки тысяч бесполезных горутин.

http.Handler и HandlerFunc: почему это одно и то же

Весь net/http стоит на интерфейсе из одного метода:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Писать структуру ради одного метода лень, а обычная функция интерфейс не реализует. Выручает адаптер на именованном функциональном типе:

type HandlerFunc func(ResponseWriter, *Request)

// метод на функциональном типе просто вызывает f
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Запись http.HandlerFunc(myFunc) ничего не вызывает и не заворачивает в структуру. Это конверсия типа: та же функция, но теперь у неё есть метод ServeHTTP, значит, она удовлетворяет Handler. Разница между mux.Handle и mux.HandleFunc ровно в этом - второй делает конверсию за тебя.

// три эквивалентные записи
mux.Handle("GET /ping", http.HandlerFunc(ping))
mux.HandleFunc("GET /ping", ping)
mux.Handle("GET /ping", pingHandler{}) // структура со своим ServeHTTP
Формулировка, которую ждут

«Handler - интерфейс, HandlerFunc - функциональный тип с методом ServeHTTP, который просто вызывает себя. Это стандартный приём адаптера в Go: превратить функцию в реализацию интерфейса без структуры. Тот же паттерн у expvar.Func и у RoundTripperFunc, который пишут руками.» Из этого же растут middleware: интерфейс Handler можно обернуть другим Handler.

Когда нужна структура, а не функция? Когда хендлеру нужны зависимости: репозиторий, логгер, конфиг. Подходов два, и оба в ходу:

// структура-хендлер
type PostsHandler struct {
    repo Repo
    log  *slog.Logger
}

func (h PostsHandler) ServeHTTP(
    w http.ResponseWriter, r *http.Request) {
    // h.repo, h.log доступны
}
// замыкание-конструктор (чаще)
func GetPost(repo Repo) http.HandlerFunc {
    return func(w http.ResponseWriter,
        r *http.Request) {
        // repo захвачен замыканием
    }
}
mux.Handle("GET /v1/posts/{id}", GetPost(repo))
Подвох: глобальное состояние в хендлере

Хендлер вызывается конкурентно из множества горутин. Если он пишет в поле своей структуры, это гонка. Поля должны быть либо read-only после старта, либо под мьютексом, либо atomic. Классический провал: счётчик h.count++ в хендлере и удивление, почему -race ругается.

Роутинг: ServeMux после Go 1.22 против chi/gin/echo

До Go 1.22 стандартный ServeMux умел стыдно мало: только префиксы и точные пути, никаких методов и параметров. Поэтому все тащили сторонний роутер. В Go 1.22 мукс переписали, и теперь паттерн выглядит так: [МЕТОД ][хост]/путь.

mux := http.NewServeMux()

mux.HandleFunc("GET /v1/posts",          listPosts)   // только GET (и HEAD автоматически)
mux.HandleFunc("POST /v1/posts",         createPosts)
mux.HandleFunc("GET /v1/posts/{id}",     getPost)     // сегмент-параметр
mux.HandleFunc("GET /v1/files/{path...}", serveFile)  // «остаток пути», строго последним
mux.HandleFunc("GET /v1/posts/{$}",      exactOnly)   // {$} = конец пути, без префиксного матча
mux.HandleFunc("api.example.com/", apiOnly)           // матч по хосту

func getPost(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")     // Go 1.22
    pattern := r.Pattern        // Go 1.23: "GET /v1/posts/{id}", годится как метка метрики
    _ = id
    _ = pattern
}

Что новый ServeMux умеет:

  • Метод в паттерне; GET автоматически покрывает HEAD.
  • Параметры пути {id} и хвост {rest...}, значения через r.PathValue / r.SetPathValue.
  • Конфликты решаются по специфичности, а не по порядку регистрации: /v1/posts/{id} проигрывает более конкретному /v1/posts/latest. Если два паттерна пересекаются, а специфичнее ни один, мукс паникует при регистрации, а не затирает молча.
  • Если путь совпал, а метод - нет, мукс отдаёт 405 с заголовком Allow. Раньше был 404, и это сбивало клиента с толку.
  • {id} матчится по распаршенному сегменту: %2F внутри сегмента не «расклеит» путь, в отличие от наивных роутеров на strings.Split.

Чего не умеет и за чем всё ещё идут в chi/gin/echo:

  • Группы роутов со своим набором middleware. В ServeMux middleware либо один на весь сервер, либо навешивается вручную на каждый хендлер. Можно завести отдельный mux под /admin/ плюс http.StripPrefix - работает, но громоздко.
  • Ограничений на параметры ({id:[0-9]+}) нет, валидируй сам.
  • Опциональные сегменты, необязательный завершающий слэш: мукс сам редиректит /x на зарегистрированный /x/ - и это бывает сюрпризом для клиентов, которые не следуют редиректам на POST. До Go 1.26 это был 301, и он был вдвойне опасен: браузер кэширует «навсегда», а на 301 POST исторически превращается в GET с потерей тела. С Go 1.26 код сменили на 307: такой ответ не кэшируется и обязан сохранять метод и тело (проверено на go1.27.0: POST /x → 307 Location: /x/). Тот же 307 теперь приходит и при чистке пути, вроде /a//b → /a/b.
  • Кастомные 404/405-хендлеры, интроспекция списка роутов, генерация URL по имени роута.
  • Биндинг и валидация тела, рендеринг, контекст фреймворка (*gin.Context).
Критерийnet/http ServeMux (1.22+)chiginecho
Зависимостиноль, stdlibноль сторонних (только stdlib)несколько (binding, валидатор)несколько
Совместимость с http.Handlerполнаяполная - весь экосистемный middleware работаетсвоя сигнатура func(*gin.Context), нужен адаптерсвоя func(echo.Context) error
Алгоритмдерево сегментов + правило специфичностиradix trieradix trieradix trie
Группы + per-group middlewareнетr.Route/r.Group/r.Useестьесть
Регэкспы в параметрахнетестьнетнет
Биндинг + валидация теласамсамc.ShouldBindJSON + теги binding:c.Bind + валидатор
Скорость роутингадостаточная (сотни нс)достаточнаяочень быстрый, zero-allocочень быстрый
Когда братьсервис на 10–40 ручек, хочется меньше магиисредний/большой REST, нужны группы и стандартные интерфейсыбыстро слепить, команда уже на нёмто же, что gin
Как отвечать на «что использовал»

Ответ «gin, потому что он быстрый» не годится: на роутинге разница в наносекундах, а один поход в БД стоит миллисекунды. Выбирают по другим признакам: совместимость с http.Handler (не придётся переписывать middleware и переезжать), нужны ли группы, сколько магии готовы терпеть. Хороший ответ звучит так: «Сейчас на новых сервисах беру стандартный ServeMux - после 1.22 его хватает, ноль зависимостей и никакого своего контекста. Там, где много групп и версий, беру chi, потому что он остаётся в терминах http.Handler. С gin работал на легаси-сервисе, главный минус - свой *gin.Context протекает во все слои.»

Подвох: http.DefaultServeMux

http.HandleFunc(...) без mux регистрирует в глобальный DefaultServeMux. Туда же в init() прописывает свои ручки net/http/pprof. Достаточно одного import _ "net/http/pprof" в любой зависимости, и, если сервер поднят с Handler: nil, наружу торчат /debug/pprof/*: дампы горутин, профайлер (который ещё и тормозит процесс) и /debug/pprof/cmdline с аргументами запуска. Правило: всегда свой mux, а pprof выносят на отдельный порт, закрытый от внешнего мира.

Middleware: цепочка «луковицей»

Middleware - это функция func(http.Handler) http.Handler: берёт хендлер, возвращает хендлер, который что-то делает до, вызывает next.ServeHTTP и что-то делает после. Цепочка собирается вложенными вызовами, поэтому структура выходит концентрической.

type Middleware func(http.Handler) http.Handler

func Chain(mws ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        // идём с конца, чтобы mws[0] оказался самым внешним
        for i := len(mws) - 1; i >= 0; i-- {
            final = mws[i](final)
        }
        return final
    }
}

stack := Chain(RequestID, Logging(log), Recover(log), Auth(keys), RateLimit(lim))
srv.Handler = stack(mux)
// эквивалентно: RequestID(Logging(Recover(Auth(RateLimit(mux)))))
1 RequestID 8 ответ уходит клиенту 2 Logging (снимает start := time.Now()) 7 пишет метод, путь, статус, латенси 3 Recover (ставит defer recover) 6 паники не было - просто выход 4 Auth (проверяет токен) 5 возврат из хендлера Handler: бизнес-логика запрос ответ Порядок имеет значение Recover должен быть ВНУТРИ Logging, иначе паника не попадёт в лог со статусом. RequestID - самым внешним, чтобы id был у всех, включая лог паники. RateLimit и Auth - до тяжёлой работы, чтобы отсекать дёшево.
Луковица. Первый в списке middleware оказывается самым внешним: он первым видит запрос и последним видит ответ. Код до next.ServeHTTP идёт «внутрь», код после - «наружу».

Логирующий middleware: перехват статуса и латенси

Сложнее всего со статусом ответа. http.ResponseWriter его не отдаёт: узнать, что хендлер записал 404, можно только перехватив вызов WriteHeader. Значит, нужна обёртка со встроенным ResponseWriter.

type statusWriter struct {
    http.ResponseWriter       // встраивание: остальные методы наследуются
    status int
    bytes  int
}

func (w *statusWriter) WriteHeader(code int) {
    if w.status != 0 {
        return // защита от повторного WriteHeader (иначе superfluous WriteHeader в логах)
    }
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
    if w.status == 0 {
        w.status = http.StatusOK // хендлер писал тело без явного WriteHeader
    }
    n, err := w.ResponseWriter.Write(b)
    w.bytes += n
    return n, err
}

// без него http.ResponseController не развернёт обёртку (Go 1.20+)
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func Logging(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            sw := &statusWriter{ResponseWriter: w}

            next.ServeHTTP(sw, r)

            if sw.status == 0 {
                sw.status = http.StatusOK // хендлер не написал вообще ничего
            }
            log.LogAttrs(r.Context(), slog.LevelInfo, "http_request",
                slog.String("method", r.Method),
                slog.String("path", r.URL.Path),
                slog.String("route", r.Pattern),          // Go 1.23: низкая кардинальность
                slog.Int("status", sw.status),
                slog.Int("bytes", sw.bytes),
                slog.Duration("latency", time.Since(start)),
                slog.String("request_id", RequestIDFrom(r.Context())),
                slog.String("ip", clientIP(r)),
            )
        })
    }
}
Ловушка обёртки: убитые Hijacker, Flusher, ReadFrom

В интерфейсе http.ResponseWriter три метода, но реальный *http.response дополнительно реализует http.Flusher (SSE), http.Hijacker (websocket-апгрейд), io.ReaderFrom (быстрая отдача файла через sendfile) и http.CloseNotifier. У твоей обёртки уже другой тип, и w.(http.Hijacker) внутри библиотеки websocket вернёт ok == false. Классический баг: «после добавления логирующего middleware сломался websocket».

Раньше обходились костылём: руками пробрасывали все интерфейсы и получали комбинаторный взрыв из 2N вариантов: для четырёх интерфейсов их уже 16, а httpsnoop генерирует десятки. Сейчас на обёртку добавляют метод Unwrap() http.ResponseWriter, а на стороне потребителя берут http.NewResponseController(w) (Go 1.20): контроллер сам разворачивает цепочку обёрток и находит настоящий writer.

// вместо w.(http.Flusher) и w.(http.Hijacker):
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil { /* не поддерживается */ }
_ = rc.SetWriteDeadline(time.Now().Add(30 * time.Second)) // продлить WriteTimeout для SSE
conn, buf, err := rc.Hijack()

Recovery middleware

func Recover(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            sw := &statusWriter{ResponseWriter: w}
            defer func() {
                rec := recover()
                if rec == nil {
                    return
                }
                // штатный способ оборвать ответ: пробрасываем дальше, сервер его ждёт
                if rec == http.ErrAbortHandler {
                    panic(rec)
                }
                log.Error("panic in handler",
                    "err", rec,
                    "path", r.URL.Path,
                    "request_id", RequestIDFrom(r.Context()),
                    "stack", string(debug.Stack()))

                // заголовки уже ушли: 500 не написать, рвём соединение
                if sw.status != 0 {
                    panic(http.ErrAbortHandler)
                }
                w.Header().Set("Content-Type", "application/problem+json")
                w.WriteHeader(http.StatusInternalServerError)
                _, _ = io.WriteString(w,
                    `{"type":"about:blank","title":"internal error","status":500}`)
            }()
            next.ServeHTTP(sw, r)
        })
    }
}
Три вещи про recover, которые проверяют
  • recover() работает только внутри defer той же горутины. Паника в горутине, которую хендлер запустил сам (go doWork()), убьёт весь процесс - middleware её не поймает. Поэтому в каждой новой горутине нужен свой defer recover.
  • Сам net/http тоже ловит панику в conn.serve, пишет стек в ErrorLog и закрывает соединение, так что процесс не падает. Но клиент получит оборванное соединение, а не 500, и в метриках это будет выглядеть как сетевая ошибка. Свой recovery нужен ради корректного ответа и своего лога.
  • Не отдавать наружу стек или текст паники: это утечка внутренностей (пути, имена таблиц, версии). Клиенту уходит обезличенный 500 с request_id, всё остальное идёт в лог.

Request ID через контекст

type ctxKey int                    // неэкспортируемый тип: чужой пакет не подменит
const requestIDKey ctxKey = iota

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-Id")
        if id == "" || len(id) > 64 || !isSafeID(id) {
            id = uuid.New().String()  // не доверяем чужому значению вслепую
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-Id", id)          // вернуть клиенту для саппорта
        next.ServeHTTP(w, r.WithContext(ctx))       // r.WithContext делает shallow copy
    })
}

func RequestIDFrom(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey).(string)       // type assertion с ok не паникует
    return id
}
Go 1.27: uuid переехал в стандартную библиотеку

Тот uuid в примере годами означал github.com/google/uuid - модуль, который тянули почти в каждый сервис. С Go 1.27 пакет uuid есть в стандартной библиотеке (RFC 9562), и внешняя зависимость для этого больше не нужна.

import "uuid"

u := uuid.New()        // случайный, синоним uuid.NewV4()
u = uuid.NewV7()       // сортируемый по времени: первые 48 бит = миллисекунды Unix
s := u.String()        // "01a041dd-3cde-794b-a425-da2cd9f4379f"
v, err := uuid.Parse(s)
_ = uuid.MustParse(s)  // паникует вместо ошибки: только для констант в коде
_ = uuid.Nil()         // 00000000-…-000000000000
_ = uuid.Max()         // ffffffff-…-ffffffffffff

Два практических следствия. Первое: тип объявлен как UUID [16]byte, обычный массив. Он сравним через ==, годится ключом map и не аллоцирует; в fmt и в JSON сам превращается в привычную строку с дефисами (String() и MarshalText), а database/sql знает про него напрямую - и как параметр запроса, и как приёмник в Scan, без всяких driver.Valuer у прикладного кода (проверено на go1.27.0). Второе: отдельной функции NewString() здесь нет - пишут uuid.New().String(), и такой код одинаково работает и со старым модулем. А для первичных ключей бери NewV7(): случайный v4 разбрасывает вставки по всему B-дереву индекса, а v7 монотонно растёт и ложится в хвост.

Правила работы с context.Value
  • Ключом делают неэкспортируемый тип (type ctxKey struct{} или type ctxKey int), никогда не строку: строковый ключ может совпасть с чужим.
  • В контекст кладут только request-scoped метаданные: request id, trace id, user id, локаль. Зависимости (репозиторий, логгер, конфиг) не кладут, их передают явными аргументами или полями структуры. «Контекст как мешок для DI» на код-ревью считают красным флагом.
  • Значение ищется за O(n) по цепочке родителей, но n мал. Проблема не в скорости, а в неявности: компилятор не проверит, что значение положили.
  • Всегда value, ok := ctx.Value(k).(T) и разумный фолбэк, а не .(T) без ok.

Таймауты сервера: четыре штуки плюс TimeoutHandler

Нулевые таймауты в http.Server{} означают «без ограничений». Это дефолт http.ListenAndServe, и он небезопасен: одно медленное или злонамеренное соединение может держаться вечно, занимая горутину, дескриптор и буферы. Каждый таймаут режет свою фазу.

accept + TLS чтение заголовков чтение тела хендлер работает запись ответа idle (keep-alive) время ReadHeaderTimeout ReadTimeout WriteTimeout IdleTimeout http.TimeoutHandler Slowloris Атакующий открывает тысячи соединений и шлёт по одному байту заголовка раз в 10 секунд, не завершая запрос. Без ReadHeaderTimeout каждое такое соединение вечно держит горутину и дескриптор - сервер умирает без единого запроса.
Таймауты по фазам. WriteTimeout начинает тикать не с начала записи, а сразу после чтения заголовков, так что покрывает ещё и чтение тела, и работу хендлера.
ПолеЧто именно ограничиваетС какого момента тикаетЧто будет при срабатыванииРазумное значение
ReadHeaderTimeoutчтение стартовой строки и всех заголовковс начала чтения запросасоединение закрывается, хендлер не вызывался3–10 с
ReadTimeoutчтение всего запроса: заголовки + телос начала чтения запросаr.Body.Read вернёт ошибку, соединение закроется10–30 с (больше для загрузок)
WriteTimeoutчтение тела + работа хендлера + запись ответасразу после чтения заголовковw.Write вернёт ошибку, соединение рвётсяReadTimeout + бюджет хендлера
IdleTimeoutпростой между запросами на keep-alive-соединениипосле отправки ответасоединение закрывается60–120 с
MaxHeaderBytesсуммарный размер заголовков (дефолт 1 МиБ)-431 Request Header Fields Too Largeоставить дефолт или 64 КиБ
MaxHeaderValueCount Go 1.27суммарное количество значений заголовков (дефолт DefaultMaxHeaderValueCount = 500)-431 Request Header Fields Too Largeоставить дефолт
http.TimeoutHandlerтолько время работы хендлерас вызова хендлераклиенту корректный 503 с телом, соединение живопод SLA ручки
srv := &http.Server{
    Addr:                ":8080",
    Handler:             stack(mux),
    ReadHeaderTimeout:   5 * time.Second,   // главный щит от slowloris
    ReadTimeout:         15 * time.Second,
    WriteTimeout:        20 * time.Second,  // > ReadTimeout, иначе долгое тело съест бюджет ответа
    IdleTimeout:         90 * time.Second,
    MaxHeaderBytes:      1 << 20,
    MaxHeaderValueCount: 500,               // Go 1.27: потолок числа значений; дефолт тот же 500
    ErrorLog:            slog.NewLogLogger(handler, slog.LevelError), // вместо дефолтного stderr
    BaseContext:         func(net.Listener) context.Context { return rootCtx },
}
Три подвоха про таймауты
  • WriteTimeout не отменяет хендлер. Он ставит дедлайн на сокет: горутина хендлера молотит дальше, запрос к БД выполняется - просто ответ уже некому отдать. Отменяет работу только r.Context(), который закрывается, когда клиент отвалился, или http.TimeoutHandler, который отменяет контекст запроса.
  • WriteTimeout ломает SSE, long polling и большие выгрузки. Для таких ручек поднимают отдельный сервер без WriteTimeout или зовут http.NewResponseController(w).SetWriteDeadline(...) прямо из хендлера.
  • IdleTimeout == 0 означает «использовать ReadTimeout», а если и он ноль, простой не ограничен вовсе. Поэтому нельзя выставить только ReadHeaderTimeout и считать, что защитился.
Глубже: что ещё держит сервер, кроме таймаутов

Таймауты ограничивают одно соединение, но не их количество. Нужны ещё: netutil.LimitListener(l, N) (жёсткий потолок одновременных соединений), Server.ConnState для метрик по фазам (new/active/idle/hijacked/closed), Server.MaxHeaderBytes и Server.MaxHeaderValueCount, http.MaxBytesReader на тело, Server.ConnContext для проброса данных о соединении, а для HTTP/2 ещё и http2.Server{MaxConcurrentStreams}, потому что там тысяча стримов может прилететь в одном TCP-соединении (та самая атака Rapid Reset, CVE-2023-44487; в Go её чинили в 1.21.3/1.20.10). В Go 1.24 у Server и Transport появились поля Protocols/HTTP2, и настраивать HTTP/2 или включать его без TLS теперь можно без костылей с golang.org/x/net/http2.

Go 1.27: MaxHeaderValueCount и дыра, которую не закрывал MaxHeaderBytes

При заголовочном флуде клиент шлёт не один огромный заголовок, а очень много маленьких: десятки тысяч строк вида X-A: 1. По байтам такой запрос спокойно влезает в MaxHeaderBytes, зато в Request.Header получается map с десятками тысяч ключей: аллокации, память и время разбора растут, хотя формально лимит не превышен. В HTTP/2 тот же приём усиливается сжатием HPACK - несколько килобайт на проводе разворачиваются в мегабайты заголовков.

В Go 1.27 у Server появилось поле MaxHeaderValueCount и константа DefaultMaxHeaderValueCount = 500, которая применяется, если поле не задано. Считаются именно значения: перечисленные через запятую в одной строке идут за одно, а у заголовка, повторённого в нескольких строках, считается каждая строка. Превышение даёт 431 Request Header Fields Too Large (проверено запуском на go1.27.0). Обновление на 1.27 включает эту защиту само, но если у тебя есть законный клиент, который шлёт сотни повторяющихся заголовков, лимит придётся поднять руками.

Клиент: таймауты, транспорт и пул соединений

За http.Get(url) и http.DefaultClient стоит клиент с Timeout: 0, то есть без таймаута вообще. Если удалённый сервис принял соединение и замолчал, твоя горутина повиснет навсегда. Дальше сценарий известный: горутины копятся, каждая держит соединение и память, метрики показывают рост go_goroutines, через час - OOM. Это самый частый ответ на «падал ли у тебя прод?».

var transport = &http.Transport{
    Proxy: http.ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,   // установка TCP-соединения
        KeepAlive: 30 * time.Second,  // TCP keepalive-пробы
    }).DialContext,
    TLSHandshakeTimeout:   3 * time.Second,
    ResponseHeaderTimeout: 5 * time.Second,  // от конца запроса до первого байта заголовков
    ExpectContinueTimeout: 1 * time.Second,
    MaxIdleConns:          256,   // всего idle-соединений в пуле
    MaxIdleConnsPerHost:   128,   // дефолт 2, почти всегда узкое место
    MaxConnsPerHost:       256,   // потолок активных, 0 = без лимита
    IdleConnTimeout:       90 * time.Second,
    ForceAttemptHTTP2:     true,
}

var client = &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // общий потолок: dial + TLS + запрос + ответ + чтение тела
    CheckRedirect: func(r *http.Request, via []*http.Request) error {
        if len(via) >= 3 { return http.ErrUseLastResponse }
        return nil
    },
}
Client.Timeout против таймаутов Transport

Client.Timeout задаёт один общий дедлайн на всю операцию, включая чтение тела ответа. Он работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленный io.Copy из resp.Body. Таймауты Transport работают пофазно: быстро отсекают «сервис недоступен» (dial 3 с) и не ограничивают законно долгую выгрузку. Правильная схема: пофазные таймауты в транспорте + общий потолок в клиенте + http.NewRequestWithContext(ctx, ...) с дедлайном из контекста запроса. Контекст сильнее: при его отмене запрос обрывается, даже если Timeout ещё не вышел.

Пул соединений живёт в Transport, а не в Client. Отсюда правило: один Transport (и обычно один Client) на всё приложение, его создают один раз при старте. Новый http.Transport{} на каждый запрос даёт классическую утечку: у каждого свой пул (голый http.Client{} без Transport берёт общий DefaultTransport и не течёт), соединения не переиспользуются, порты уходят в TIME_WAIT, при высоком RPS кончаются эфемерные порты (cannot assign requested address).

MaxIdleConnsPerHost по умолчанию 2. Если сервис делает 500 RPS в один апстрим, то из пула переиспользуются два соединения, а остальные создаются и закрываются заново - на каждый запрос новый TCP-хендшейк и полный TLS-хендшейк. Симптом: латенси p99 выше, чем объясняет апстрим, и куча соединений в TIME_WAIT в ss -s. Лечится одной строкой.

// тело надо дочитать и закрыть, иначе соединение не вернётся в пул
resp, err := client.Do(req)
if err != nil {
    return err
}
defer func() {
    // дочитываем остаток (с лимитом, чтобы злой сервер не залил нас гигабайтом)
    _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
    _ = resp.Body.Close()
}()

if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("upstream: %s", resp.Status)  // тело всё равно дочитается в defer
}
Подвох: «я же сделал defer resp.Body.Close()»

Недостаточно. Close() на недочитанном теле заставляет транспорт закрыть соединение, а не вернуть его в пул: остаток тела всё ещё в сокете, и переиспользовать такой сокет нельзя. Особенно больно на ветках ошибок, где тело обычно игнорируют: пул «худеет» ровно во время инцидента, когда апстрим сыплет пятисотками. Поэтому сначала дочитать в io.Discard (с LimitReader), потом закрыть. Второй частый промах: defer resp.Body.Close() до проверки err != nil. При ошибке resp равен nil, и получаешь панику вместо понятного лога.

Что изменилось в Go 1.27. HTTP/1-транспорт теперь при Close() сам пробует дочитать недочитанный остаток и всё-таки вернуть соединение в пул, но при двух жёстких условиях: тело не длиннее 256 КиБ (или его длина неизвестна) и остаток успевает вычитаться за 50 мс. То есть маленькие тела ошибок (типичный problem+json) с 1.27 прощаются сами, а большая или медленная выгрузка соединения по-прежнему стоит. Явный io.Copy(io.Discard, io.LimitReader(...)) остаётся правильной привычкой: он работает одинаково на всех версиях Go и не зависит от того, знал ли апстрим длину заранее.

Go 1.27: тест без реального порта через httptest.NewTestServer

И сервер, и клиента из предыдущих разделов надо чем-то тестировать, и проще всего для этого поднять фейковый сервер. Обычный httptest.NewServer открывает при этом настоящий сокет на 127.0.0.1 со случайным портом. Чаще всего это незаметно, но в CI даёт знакомые неприятности: тесты конкурируют за порты, песочница может вообще не разрешать listen, а после Close() остаётся TIME_WAIT.

// Go 1.27: сервер живёт на фейковой сети в памяти, сокета и порта нет вовсе
ts := httptest.NewTestServer(t, mux)     // t.Cleanup(ts.Close) регистрируется сам
client := ts.Client()                    // ходить надо именно этим клиентом
resp, err := client.Get(ts.URL + "/v1/orders/1")
// ts.URL == "http://example.com": имя синтетическое, DNS и порт не участвуют
// https://example.com/... тоже работает: TLS поднят на той же фейковой сети

Соединения идут через подменённый DialContext транспорта, поэтому сторонний клиент со своим транспортом до такого сервера не достучится. Тестируй либо через ts.Client(), либо прокидывай его Transport в свой код. Взамен получаешь тест без портов, без гонок за них и без сетевых таймаутов, а паника в хендлере прилетает в вывод теста, а не в stderr. Проверено на go1.27.0.

Graceful shutdown

Задача: получив SIGTERM (его шлёт Kubernetes при выкатке), перестать принимать новые запросы, дать доработать текущим, закрыть внешние ресурсы и выйти с нулевым кодом. Успеть надо за terminationGracePeriodSeconds, иначе прилетит SIGKILL.

func main() {
    // 1. ловим сигналы: ctx закроется по SIGINT/SIGTERM
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{ /* ... таймауты ... */ }

    errCh := make(chan error, 1)
    go func() {
        // ErrServerClosed: штатный возврат после Shutdown, не ошибка
        if err := srv.ListenAndServe(); err != nil &&
            !errors.Is(err, http.ErrServerClosed) {
            errCh <- err
        }
    }()

    select {
    case err := <-errCh:
        log.Error("сервер не поднялся", "err", err)
        os.Exit(1)
    case <-ctx.Done():
        // Go 1.26: NotifyContext отменяет контекст через CancelCauseFunc,
        // поэтому причина отмены прямо называет пришедший сигнал.
        //   SIGTERM -> "terminated signal received"
        //   SIGINT  -> "interrupt signal received"
        //   stop()  -> context.Canceled
        log.Info("начинаем graceful shutdown", "cause", context.Cause(ctx))
    }
    stop() // вернуть дефолтное поведение: второй Ctrl+C убьёт процесс немедленно

    // 2. readiness начинает отдавать 503, но запросы ещё принимаем
    ready.Store(false)
    time.Sleep(5 * time.Second) // дать балансировщику убрать нас из пула

    // 3. перестать принимать новые, дождаться активных
    shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := srv.Shutdown(shCtx); err != nil {
        log.Error("не успели за отведённое время, рвём соединения", "err", err)
        _ = srv.Close() // жёстко
    }

    // 4. только теперь закрываем ресурсы: до этого ими пользуются хендлеры
    consumer.Stop()          // остановить читателей Kafka, закоммитить оффсеты
    _ = db.Close()           // закрыть пул соединений с БД
    _ = tracerProvider.Shutdown(context.Background()) // выплюнуть накопленные спаны
    if f, ok := log.Handler().(interface{ Flush() error }); ok {
        _ = f.Flush() // у JSONHandler и TextHandler Flush нет
    }
}
Go 1.26: у отмены появилась причина - видно, каким сигналом нас погасили

До 1.26 signal.NotifyContext просто отменял контекст, и в логе оставалось безликое context canceled: пришёл SIGTERM от Kubernetes при штатной выкатке или человек нажал Ctrl+C - по логам не различить. С Go 1.26 внутри работает context.CancelCauseFunc - та же отмена, но с прикреплённой причиной: ошибкой, которую потом достают через context.Cause(ctx).

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
<-ctx.Done()

ctx.Err()              // context.Canceled, как раньше, ничего не говорит
context.Cause(ctx)     // "terminated signal received", а вот это уже SIGTERM

errors.Is(context.Cause(ctx), context.Canceled) // true: старые проверки не ломаются

Зачем это на практике. Во-первых, логи выкатки: строка «остановлены по SIGTERM» отличает штатный роллинг-апдейт от Ctrl+C на стенде и от SIGHUP при потере терминала. Во-вторых, поведение можно развести: по SIGTERM отработать полный цикл с preStop-паузой, а по SIGINT от разработчика гаситься быстро. Причина приходит строкой (os.Signal из неё не достать), так что для типизированного сигнала по-прежнему нужен собственный signal.Notify в канал. Проверено на go1.27.0.

Kubernetes Балансировщик Наш процесс БД / брокер / трейсы 1. SIGTERM процессу 2. Pod убран из Endpoints 3. readiness = 503, ready.Store(false) 4. запросы ещё долетают: preStop sleep 5-10 с 5. srv.Shutdown: listener закрыт, idle-conn закрыты 6. активные хендлеры дорабатывают, Connection: close если ctx истёк: srv.Close() рвёт всё принудительно 7. consumer.Stop(), db.Close(), tracer.Shutdown() 8. return из main, exit 0 Чего Shutdown НЕ делает Не закрывает hijacked-соединения: websocket после Hijack он не считает активным запросом. Такие закрывай сам через RegisterOnShutdown. И не отменяет r.Context() у хендлеров: висящий 10 минут хендлер продержит Shutdown 10 минут.
Порядок остановки. Чаще всего пропускают пункты 3 и 4, и балансировщик ещё несколько секунд шлёт трафик на уже закрытый listener: клиент видит connection refused вместо мягкой деградации.
Порядок остановки: об этом на самом деле и спрашивают
  1. Сказать миру, что мы уходим: readiness-проба отдаёт 503, ждём 5–10 секунд (или preStop: sleep в манифесте), пока балансировщик и service mesh перестанут слать трафик.
  2. Перестать принимать новое: Shutdown закрывает listener и все idle keep-alive-соединения.
  3. Дождаться текущего: Shutdown блокируется, пока активные запросы не завершатся, и добавляет в их ответы Connection: close.
  4. Только потом закрывать ресурсы: БД, брокер, кэш, экспортёры трейсов и метрик. Если закрыть db до Shutdown, доигрывающие хендлеры получат sql: database is closed и отдадут 500.
  5. Таймаут Shutdown должен быть меньше terminationGracePeriodSeconds (обычно 30 с), иначе SIGKILL прилетит посреди уборки.

Тело запроса: Decoder против Unmarshal, лимиты, стриминг

// json.Unmarshal
b, err := io.ReadAll(r.Body)   // всё в память
if err != nil { ... }
err = json.Unmarshal(b, &in)

// + можно перечитать/залогировать сырое тело
// + понятные ошибки с позицией
// - память = размер тела
// - без MaxBytesReader это DoS
// json.Decoder
dec := json.NewDecoder(r.Body)  // стрим
dec.DisallowUnknownFields()
err := dec.Decode(&in)

// + не держит тело в памяти целиком
// + умеет поток JSON-объектов (dec.More)
// + UseNumber(), Token() для больших данных
// - сырое тело уже не залогируешь

В обычной API-ручке Decoder ставят поверх MaxBytesReader. Потоковую загрузку (NDJSON, батчи) тот же Decoder читает в цикле по dec.More(), и память не растёт с размером входа.

func createPost(w http.ResponseWriter, r *http.Request) {
    // 1. жёсткий потолок на тело; w нужен, чтобы сервер закрыл соединение корректно
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 МиБ

    // 2. на публичных API стоит проверить и Content-Type
    if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
        writeProblem(w, http.StatusUnsupportedMediaType, "нужен application/json")
        return
    }

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // поле с опечаткой даст 400, а не пропадёт молча

    var in createPostReq
    if err := dec.Decode(&in); err != nil {
        var maxErr *http.MaxBytesError      // Go 1.19+
        var synErr *json.SyntaxError
        var typErr *json.UnmarshalTypeError
        switch {
        case errors.As(err, &maxErr):
            writeProblem(w, http.StatusRequestEntityTooLarge, "тело больше 1 МиБ")
        case errors.As(err, &synErr):
            writeProblem(w, http.StatusBadRequest,
                fmt.Sprintf("битый JSON на позиции %d", synErr.Offset))
        case errors.As(err, &typErr):
            writeProblem(w, http.StatusBadRequest,
                fmt.Sprintf("поле %q ожидает тип %s", typErr.Field, typErr.Type))
        case errors.Is(err, io.EOF):
            writeProblem(w, http.StatusBadRequest, "пустое тело")
        default:
            writeProblem(w, http.StatusBadRequest, "не удалось разобрать тело")
        }
        return
    }

    // 3. в теле ровно один объект, иначе {"a":1}{"b":2} проедет молча
    if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
        writeProblem(w, http.StatusBadRequest, "ожидается ровно один JSON-объект")
        return
    }

    if err := in.Validate(); err != nil { /* 422 */ }
}
Go 1.26: errors.AsType[T] вместо трёх переменных заранее

Схема «объяви переменную нужного типа, потом errors.As(err, &v)» появилась не от хорошей жизни: когда писали errors.As, дженериков в Go ещё не было. С Go 1.26 есть дженерик-версия: тип передаётся параметром, переменная объявляется прямо в условии, а промах, на котором errors.As паникует в рантайме (цель не указатель), с AsType просто не скомпилируется.

// Go 1.26: тип в параметре, переменная прямо в условии
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
    writeProblem(w, http.StatusRequestEntityTooLarge, "тело больше 1 МиБ")
} else if se, ok := errors.AsType[*json.SyntaxError](err); ok {
    writeProblem(w, http.StatusBadRequest, fmt.Sprintf("битый JSON на позиции %d", se.Offset))
} else if te, ok := errors.AsType[*json.UnmarshalTypeError](err); ok {
    writeProblem(w, http.StatusBadRequest, fmt.Sprintf("поле %q ожидает тип %s", te.Field, te.Type))
}

Старый errors.As никуда не делся и в switch из примера выше по-прежнему удобнее: там переменные объявлены один раз, а case-ветки читаются в столбик. AsType выигрывает там, где проверка одна-две и заводить переменную заранее не хочется. Проверено на go1.27.0.

Подвох: «поле не пришло» против «поле пришло пустым»

json.Unmarshal в структуру не различает отсутствующее поле и нулевое значение: {"title":""} и {} дадут одинаковый in.Title == "". А PATCH должен знать, чего от него хотят: «не трогать поле» или «затереть в пустоту». Лечится это указателями (*string: nil = не пришло), обёрткой type Optional[T any] struct{ Value T; Set bool } со своим UnmarshalJSON или разбором в map[string]json.RawMessage с проверкой ключа. И не путай DisallowUnknownFields с required: обязательность полей encoding/json не проверяет вообще, это работа валидатора.

Загрузка файлов и multipart

Браузерная форма с файлом шлёт Content-Type: multipart/form-data; boundary=.... Тело состоит из частей, разделённых boundary, и у каждой свои заголовки (Content-Disposition, Content-Type). В Go его разбирают двумя способами.

// Вариант A: ParseMultipartForm - просто, для небольших файлов
func upload(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 32<<20) // общий потолок 32 МиБ

    // аргумент задаёт порог памяти; остальное уйдёт во временные файлы в os.TempDir
    if err := r.ParseMultipartForm(8 << 20); err != nil {
        writeProblem(w, http.StatusBadRequest, "битая форма")
        return
    }
    defer func() { _ = r.MultipartForm.RemoveAll() }() // сервер чистит только форму исходного r, не копии

    file, hdr, err := r.FormFile("avatar")
    if err != nil { /* 400 */ }
    defer file.Close()

    // hdr.Filename и hdr.Header.Get("Content-Type") прислал клиент, им не доверяем
    name := filepath.Base(hdr.Filename)               // с Go 1.17 путь отрезан и так, это страховка
    if hdr.Size > 5<<20 { /* 413 */ }

    // определяем реальный тип по содержимому, а не по расширению
    head := make([]byte, 512)
    n, _ := io.ReadFull(file, head)
    mime := http.DetectContentType(head[:n])
    if mime != "image/jpeg" && mime != "image/png" { /* 415 */ }
    if _, err := file.Seek(0, io.SeekStart); err != nil { /* 500 */ }

    dst, err := os.CreateTemp(uploadDir, "up-*")      // имя генерируем сами
    if err != nil { /* 500 */ }
    defer dst.Close()
    if _, err := io.Copy(dst, file); err != nil { /* 500 */ }
    _ = name
}
// Вариант B: MultipartReader - потоково, без буферизации, годится для гигабайтов
func uploadStream(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 256<<20)
    mr, err := r.MultipartReader() // до этого нельзя вызывать r.FormValue/r.FormFile: съедят тело
    if err != nil {
        writeProblem(w, http.StatusUnsupportedMediaType, "ожидается multipart/form-data")
        return
    }
    for {
        part, err := mr.NextPart()
        if errors.Is(err, io.EOF) {
            break
        }
        if err != nil { /* 400 */ return }

        switch part.FormName() {
        case "meta":
            var meta Meta
            if err := json.NewDecoder(io.LimitReader(part, 64<<10)).Decode(&meta); err != nil {
                /* 400 */ return
            }
        case "file":
            // сразу в S3/minio, минуя диск и память
            // не LimitReader: тот молча обрежет файл, и в хранилище уйдёт обрубок
            _, err = uploader.Upload(r.Context(), key, http.MaxBytesReader(w, part, 200<<20))
            if err != nil { /* 500 */ return }
        }
        _ = part.Close()
    }
}
Чек-лист безопасной загрузки файлов
  • MaxBytesReader на всё тело и лимит на каждую часть. Для файла такой, что вернёт ошибку: io.LimitReader молча обрежет.
  • Имя файла от клиента пропускай только через filepath.Base, а лучше не используй вовсе: генерируй uuid, оригинальное имя храни в метаданных.
  • Тип определяй по http.DetectContentType (первые 512 байт), а не по расширению и не по Content-Type части. Разрешённые типы задаёт whitelist, не blacklist.
  • Отдавай загруженное с Content-Type из whitelist, X-Content-Type-Options: nosniff и Content-Disposition: attachment - и с отдельного домена, чтобы залитый HTML не выполнился в origin приложения.
  • Никогда не клади загруженные файлы в директорию, откуда сервер исполняет код.
  • Большие файлы пусть идут по presigned URL прямо в объектное хранилище: бэкенд не должен проксировать гигабайты.
  • Вызывай RemoveAll() сам. Сервер чистит только форму исходного запроса, а за middleware с r.WithContext хендлер работает с копией, и /tmp постепенно забивается.

Вопросы

8
Суть: цикл Accept в одной горутине, на каждое принятое соединение запускается отдельная горутина conn.serve, которая в цикле читает запросы и вызывает Handler.ServeHTTP. Handler - интерфейс с одним методом, HandlerFunc - функциональный тип с методом ServeHTTP, вызывающим сам себя.

Пошагово

  1. ListenAndServe → net.Listen("tcp", addr) → srv.Serve(l).
  2. В цикле l.Accept(). Временные ошибки не роняют сервер: ретрай с экспоненциальной паузой от 5 мс до 1 с.
  3. c := srv.newConn(rw); go c.serve(connCtx) запускает горутину на соединение. Пула воркеров нет, лимита на число соединений тоже.
  4. Горутина делает TLS-handshake (если нужно) и входит в цикл c.readRequest(): разбирает стартовую строку и заголовки. Тело не читается, r.Body остаётся ленивым ридером поверх сокета.
  5. serverHandler{srv}.ServeHTTP(w, req); если srv.Handler == nil, подставляется http.DefaultServeMux. Роутер выбирает хендлер.
  6. Когда хендлер вернулся, finishRequest сбрасывает буфер и дочитывает остаток тела (иначе соединение не переиспользовать).
  7. Keep-alive → continue, та же горутина обслуживает следующий запрос. Иначе c.close().

Handler и HandlerFunc

type Handler interface { ServeHTTP(ResponseWriter, *Request) }

type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }

В http.HandlerFunc(fn) нет обёртки в структуру, только конверсия типа: функция та же, но теперь с методом, и она удовлетворяет интерфейсу. Классический приём «функция как реализация интерфейса». На интерфейсе Handler напрямую держатся middleware: func(http.Handler) http.Handler.

Чем добить ответ

  • Модель «горутина на соединение» работает потому, что горутина стоит от 2 КиБ стека, а блокирующие сетевые вызовы уходят в netpoller (epoll/kqueue): рантайм паркует горутину и освобождает поток ОС. Код пишешь блокирующий, а ведёт он себя как асинхронный.
  • Соединение обходится в горутину (стек быстро дорастает до 8 КиБ) и два буфера по 4 КиБ, на чтение и на запись, всего около 20 КиБ. На 50 000 idle-соединений набегает около гигабайта, поэтому IdleTimeout и нужен.
  • Хендлер вызывается конкурентно, так что любое разделяемое изменяемое состояние в нём даёт гонку.
  • Ограничить число соединений сам net/http не умеет, для этого есть netutil.LimitListener(l, N).
  • При HTTP/2 схема другая: одно TCP-соединение, много стримов, и горутина создаётся на стрим, а не на соединение.
На чём ловят

На фразе «горутина на запрос». При keep-alive одна горутина обслуживает десятки запросов подряд, поэтому ReadHeaderTimeout и IdleTimeout и нужны: без них соединение (а с ним и горутина) висит бесконечно.

Суть: с Go 1.22 ServeMux умеет метод в паттерне, {id}, {rest...}, разрешение конфликтов по специфичности и корректный 405 - для типового REST-сервиса этого хватает. Внешний роутер берут ради групп с per-group middleware, регэкспов в параметрах и биндинга.

Что дал Go 1.22

mux.HandleFunc("GET /v1/posts/{id}", getPost)
mux.HandleFunc("GET /v1/files/{path...}", files)   // хвост пути
mux.HandleFunc("GET /v1/posts/{$}", exact)         // без префиксного матча
id := r.PathValue("id")
route := r.Pattern    // Go 1.23: готовая метка для метрик
  • Конфликты решаются по специфичности, а не по порядку регистрации. Пересекающиеся паттерны, из которых ни один не специфичнее, вызывают панику при регистрации - баг всплывает на старте, а не в проде.
  • Совпал путь, не совпал метод → 405 + заголовок Allow (раньше был 404).
  • {id} берётся из распаршенного сегмента, поэтому %2F не «расклеит» путь.

Чего нет

  • Групп со своим набором middleware. Остаётся общий стек на сервер, обёртка на каждый хендлер или вложенный ServeMux, обёрнутый в свой middleware.
  • Ограничений на параметры ({id:[0-9]+}), кастомных 404/405, интроспекции роутов, генерации URL.
  • Биндинга и валидации тела.
  • Есть неочевидность: мукс сам редиректит /x на /x/ и чистит путь, а POST-клиенты, которые по редиректам не ходят, получают сюрприз. Тут важна версия: до Go 1.26 отдавался 301 (браузер кэширует его надолго, POST по дороге вырождается в GET), с Go 1.26 отдаётся 307, который не кэшируется и сохраняет метод с телом.

Как звучит хороший ответ

«На новых сервисах беру стандартный ServeMux: после 1.22 функциональности хватает, ноль зависимостей, никакого своего Context, всё в терминах http.Handler. Где много версий и групп с разными middleware, ставлю chi: он тоже держится стандартных интерфейсов, и миграция туда-обратно почти бесплатная. На легаси работал с gin. Главный минус: *gin.Context протекает в сервисный слой и привязывает код к фреймворку, а выигрыш в скорости роутинга (наносекунды) тонет в походе в БД (миллисекунды).»

Отдельный вопрос, который любят

«Чем опасен http.DefaultServeMux?» Тем, что net/http/pprof регистрирует в нём свои ручки в init(). Хватит одного import _ "net/http/pprof" в транзитивной зависимости и Handler: nil, чтобы наружу торчали /debug/pprof/*. Всегда заводи свой mux, а pprof вешай на отдельный внутренний порт.

Суть: middleware - это func(http.Handler) http.Handler; цепочка собирается вложенными вызовами и работает «луковицей»: первый в списке - самый внешний. Статус ответа перехватывается обёрткой над ResponseWriter, request ID прокидывается через context.WithValue с неэкспортируемым ключом.

Цепочка

type Middleware func(http.Handler) http.Handler

func Chain(mws ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        for i := len(mws) - 1; i >= 0; i-- { // с конца: mws[0] станет внешним
            final = mws[i](final)
        }
        return final
    }
}
srv.Handler = Chain(RequestID, Logging(log), Recover(log), Auth)(mux)

Код до next.ServeHTTP выполняется на пути внутрь в порядке объявления, а код после отрабатывает на пути наружу, в обратном порядке. Отсюда правила: RequestID ставят самым внешним (id нужен всем, включая лог паники), Recover кладут внутрь Logging (иначе паника не попадёт в access-лог со статусом), а Auth и RateLimit пускают до тяжёлой работы.

Логирующий middleware

type statusWriter struct {
    http.ResponseWriter
    status, bytes int
}
func (w *statusWriter) WriteHeader(code int) {
    if w.status != 0 { return }             // гасим повторный WriteHeader
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
    if w.status == 0 { w.status = http.StatusOK }
    n, err := w.ResponseWriter.Write(b)
    w.bytes += n
    return n, err
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func Logging(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            sw := &statusWriter{ResponseWriter: w}
            next.ServeHTTP(sw, r)
            if sw.status == 0 { sw.status = http.StatusOK }
            log.Info("http",
                "method", r.Method, "path", r.URL.Path, "route", r.Pattern,
                "status", sw.status, "bytes", sw.bytes,
                "latency_ms", time.Since(start).Milliseconds(),
                "request_id", RequestIDFrom(r.Context()))
        })
    }
}
Главный подвох этого вопроса

Обёртка над ResponseWriter «съедает» опциональные интерфейсы: http.Flusher (SSE), http.Hijacker (websocket), io.ReaderFrom (sendfile). После такого middleware w.(http.Hijacker) вернёт ok == false и websocket сломается. Правильный ответ: дать обёртке метод Unwrap() http.ResponseWriter и работать через http.NewResponseController(w) (Go 1.20), который сам разворачивает цепочку обёрток. Старый способ: руками пробрасывать все комбинации интерфейсов, как делает httpsnoop.

Recovery

func Recover(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                rec := recover()
                if rec == nil { return }
                if rec == http.ErrAbortHandler { panic(rec) } // штатный обрыв, не глотаем
                log.Error("panic", "err", rec, "stack", string(debug.Stack()),
                    "request_id", RequestIDFrom(r.Context()))
                w.WriteHeader(http.StatusInternalServerError)
                _, _ = io.WriteString(w, `{"title":"internal error","status":500}`)
            }()
            next.ServeHTTP(w, r)
        })
    }
}
  • recover() работает только в своей горутине: паника в go doWork(), запущенной хендлером, уронит процесс. Каждой новой горутине нужен свой defer recover.
  • Сам net/http тоже ловит панику в conn.serve, но клиент получает оборванное соединение, а не 500. Свой recovery нужен ради корректного ответа, метрики и лога.
  • Если заголовки уже ушли, WriteHeader(500) ничего не даст - останется только оборвать ответ (для этого и есть panic(http.ErrAbortHandler)).
  • Наружу уходит обезличенный 500 с request_id, стек только в лог.

Request ID

type ctxKey int
const requestIDKey ctxKey = iota   // неэкспортируемый тип: чужой пакет не подменит ключ

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-Id")
        if id == "" || len(id) > 64 { id = uuid.New().String() }  // Go 1.27: uuid уже в stdlib
        w.Header().Set("X-Request-Id", id)
        next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
    })
}
func RequestIDFrom(ctx context.Context) string { s, _ := ctx.Value(requestIDKey).(string); return s }

Дальше id уезжает в логи всех слоёв (log.With("request_id", ...) в контексте) и заголовком в исходящие запросы - так и собирается сквозная трассировка. В проде вместо самописного id обычно берут traceparent из W3C Trace Context и OpenTelemetry, механика та же.

Про context.Value

Ключом делают неэкспортируемый тип, а не строку. В контекст кладут только request-scoped метаданные (request id, trace id, user id, локаль), зависимостям там не место: репозиторий и логгер передаются явно. Читают всегда через v, ok := ctx.Value(k).(T).

Суть: каждый таймаут режет свою фазу жизни соединения. Дефолт - ноль, то есть «без ограничений», и это дыра: одно медленное соединение держит горутину, дескриптор и буферы вечно. Slowloris - атака, которая ровно на этом и построена.
ТаймаутЧто ограничиваетС какого момента
ReadHeaderTimeoutчтение стартовой строки и заголовковс начала чтения запроса
ReadTimeoutчтение всего запроса: заголовки + телос начала чтения запроса
WriteTimeoutчтение тела + работа хендлера + запись ответасразу после чтения заголовков
IdleTimeoutпростой между запросами на keep-aliveпосле отправки ответа
http.TimeoutHandlerтолько время работы хендлерас вызова хендлера

Slowloris

Атакуют ресурсы, а не полосу. Клиент открывает тысячи соединений и в каждом шлёт по одному байту заголовка раз в несколько секунд, никогда не завершая блок заголовков пустой строкой. Запрос формально ещё не пришёл - хендлер не вызывался, логов нет, трафик мизерный. Но каждое такое соединение держит горутину, файловый дескриптор и буферы. Сервер перестаёт принимать легитимных клиентов и «умирает» без единого обработанного запроса. Та же идея для тела зовётся slow POST / R-U-Dead-Yet: клиент заявляет Content-Length: 10000000 и шлёт по байту в секунду.

Лечат это ReadHeaderTimeout (от slowloris), ReadTimeout (от slow POST), MaxHeaderBytes, netutil.LimitListener на число соединений и лимитами на соединения с одного IP на балансировщике. Поэтому голый http.ListenAndServe(":8080", mux) в продакшене сразу настораживает.

Закончить ответ можно свежим дополнением: в Go 1.27 появилось Server.MaxHeaderValueCount (по умолчанию берётся константа DefaultMaxHeaderValueCount, равная 500). Оно закрывает соседний сценарий - заголовочный флуд: вместо одного гигантского заголовка идут десятки тысяч крошечных, которые по байтам влезают в MaxHeaderBytes, но раздувают Request.Header и время разбора. Превышение даёт те же 431 Request Header Fields Too Large.

Тонкости, которые отличают сильный ответ

  • WriteTimeout не останавливает хендлер. Это дедлайн на сокет: горутина продолжает работать, запрос в БД продолжает выполняться, просто ответ уже некому отдать. Отменяют работу только r.Context() и http.TimeoutHandler.
  • WriteTimeout тикает с момента, когда прочитаны заголовки, а не с начала записи. Значит, он должен быть больше ReadTimeout, иначе долгая загрузка тела съест весь бюджет ответа.
  • WriteTimeout убивает SSE, long polling и большие выгрузки. Им нужен отдельный сервер или http.NewResponseController(w).SetWriteDeadline().
  • IdleTimeout == 0 означает «взять ReadTimeout», а если и он ноль, простой не ограничен. Выставить один ReadHeaderTimeout и считать, что закрылся, не выйдет.
  • Для HTTP/2 отдельная история: тысяча стримов в одном соединении, нужен MaxConcurrentStreams (атака Rapid Reset, CVE-2023-44487).
Суть: у http.DefaultClient Timeout == 0 - запрос может висеть вечно и копить горутины до OOM. Нужен свой клиент: общий потолок в Client.Timeout, пофазные таймауты в Transport, увеличенный MaxIdleConnsPerHost (дефолт 2) и обязательное дочитывание resp.Body.

Два уровня таймаутов

  • Client.Timeout ставит один дедлайн на всю операцию: dial + TLS + отправка запроса + получение заголовков + чтение тела. Работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленный io.Copy(dst, resp.Body).
  • Таймауты Transport действуют пофазно: DialContext.Timeout (установка TCP), TLSHandshakeTimeout, ResponseHeaderTimeout (сколько ждать заголовков ответа после отправки запроса), ExpectContinueTimeout, IdleConnTimeout (сколько соединение живёт в пуле без дела).
  • context сильнее обоих: http.NewRequestWithContext(ctx, ...) обрывает запрос при отмене родительского контекста, даже если Timeout не вышел. Им дедлайн входящего запроса и пробрасывают в исходящие.
var transport = &http.Transport{
    DialContext:           (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
    TLSHandshakeTimeout:   3 * time.Second,
    ResponseHeaderTimeout: 5 * time.Second,
    MaxIdleConns:          256,
    MaxIdleConnsPerHost:   128,  // дефолт 2
    MaxConnsPerHost:       256,
    IdleConnTimeout:       90 * time.Second,
    ForceAttemptHTTP2:     true,
}
var client = &http.Client{Transport: transport, Timeout: 10 * time.Second}

Пул соединений

Пул принадлежит Transport, а не Client, отсюда два правила. Первое: один Transport на приложение, созданный при старте. Новый &http.Transport{} на каждый запрос даёт утечку: у каждого свой пул (голый &http.Client{} без Transport берёт общий DefaultTransport и не течёт), переиспользования нет, сокеты уходят в TIME_WAIT, при высоком RPS кончаются эфемерные порты (cannot assign requested address). Второе: MaxIdleConnsPerHost = 2 по умолчанию, и при 500 RPS в один апстрим переиспользуются два соединения, а остальные каждый раз пересоздаются с полным TCP- и TLS-хендшейком. Симптом: p99 выше, чем объясняет апстрим, и гора TIME_WAIT в ss -s.

Почему обязательно закрывать и дочитывать тело

resp, err := client.Do(req)
if err != nil { return err }          // сначала err: при ошибке resp == nil
defer func() {
    _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) // дочитать
    _ = resp.Body.Close()
}()

Если тело не закрыть, соединение и горутина readLoop утекут навсегда. Если закрыть, но не дочитать, соединение не вернётся в пул, а закроется: в сокете остались непрочитанные байты. Больнее всего это на ветках ошибок, где тело обычно игнорируют, - пул «худеет» ровно во время инцидента. LimitReader нужен, чтобы недобросовестный апстрим не залил тебя гигабайтом в io.Discard.

Оговорка про версию: с Go 1.27 HTTP/1-транспорт при Close() сам пробует дочитать остаток, если Content-Length ≤ 256 КиБ и остаток вычитывается за 50 мс. Мелкие тела ошибок с 1.27 прощаются сами, крупные и медленные нет, так что явное дочитывание по-прежнему нужно.

Чем добить

Назови ещё три вещи: (1) http.DefaultTransport общий на весь процесс, включая сторонние библиотеки - его поля глобально не меняют, а делают свой; (2) Client.CheckRedirect по умолчанию идёт до 10 редиректов и перекладывает заголовки: при редиректе на чужой хост Authorization отбрасывается, но кастомные заголовки с секретами могут утечь, а бесконтрольный редирект заодно открывает дорогу SSRF; (3) без MaxConnsPerHost клиент может открыть тысячи соединений и сам положить апстрим, так что лимит на клиенте бережёт и чужой сервис.

Суть: ловим SIGTERM через signal.NotifyContext, сначала выключаем readiness и ждём, пока балансировщик уберёт нас из пула, потом srv.Shutdown(ctx) с таймаутом меньше terminationGracePeriodSeconds, и только после этого закрываем БД, брокер и экспортёры.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := &http.Server{Addr: ":8080", Handler: h, ReadHeaderTimeout: 5 * time.Second}

errCh := make(chan error, 1)
go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        errCh <- err
    }
}()

select {
case err := <-errCh:
    log.Error("не поднялись", "err", err); os.Exit(1)
case <-ctx.Done():
    // Go 1.26: причина отмены называет сигнал - "terminated signal received" и т. п.
    log.Info("получен сигнал", "cause", context.Cause(ctx))
}
stop()                        // второй Ctrl+C убьёт процесс сразу

ready.Store(false)            // /readyz отдаёт 503
time.Sleep(5 * time.Second)   // ждём, пока нас выведут из балансировки

shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
    log.Error("не успели", "err", err)
    _ = srv.Close()           // рвём остатки
}

consumer.Stop()               // и только теперь ресурсы
_ = db.Close()
_ = tracer.Shutdown(context.Background())

Порядок и почему он такой

  1. Объявить о выходе: readiness → 503, подождать 5–10 с. Kubernetes помечает Pod неготовым в EndpointSlice асинхронно с отправкой SIGTERM: если закрыть listener сразу, часть трафика упрётся в connection refused. Тот же эффект даёт встроенное действие preStop: sleep в манифесте; exec sleep 10 в distroless-образе не сработает - там нет бинарника sleep.
  2. Перестать принимать новое: Shutdown закрывает listener и все idle keep-alive-соединения.
  3. Дождаться текущих: Shutdown блокируется до завершения активных запросов и добавляет им в ответ Connection: close, чтобы клиент не пытался переиспользовать соединение.
  4. Потом ресурсы: вызовешь db.Close() до Shutdown, и доигрывающие хендлеры получат sql: database is closed и отдадут 500.

Что Shutdown НЕ делает

  • Не закрывает hijacked-соединения: websocket после Hijack он не считает активным запросом и просто про него не знает. Закрывай такие сам через srv.RegisterOnShutdown(func(){ hub.CloseAll() }) и свой broadcast-канал. SSE обходится без Hijack: это обычный активный запрос, и Shutdown прождёт его до таймаута, так что стримы гаси тем же хуком.
  • Не отменяет r.Context() у работающих хендлеров: висящий 10 минут хендлер продержит Shutdown все 10 минут. Поэтому нужен отдельный «shutdown-контекст», который хендлеры уважают, и таймаут на Shutdown с фолбэком в Close().
  • Close() в отличие от Shutdown рвёт всё немедленно, включая активные запросы.
  • После Shutdown сервер нельзя переиспользовать: ListenAndServe сразу вернёт ErrServerClosed.
Свежая деталь: какой сигнал пришёл

С Go 1.26 signal.NotifyContext отменяет контекст через context.CancelCauseFunc, поэтому context.Cause(ctx) возвращает ошибку с текстом вида terminated signal received (для SIGTERM) или interrupt signal received (для SIGINT). Раньше в логе оставалось безликое context canceled, и штатная выкатка не отличалась от Ctrl+C на стенде. ctx.Err() и errors.Is(cause, context.Canceled) при этом работают по-старому, так что существующий код не ломается.

Что сказать про Kubernetes

Бюджет: terminationGracePeriodSeconds (по умолчанию 30 с) = preStop sleep + таймаут Shutdown + закрытие ресурсов, и всё это должно уложиться с запасом, иначе прилетит SIGKILL посреди уборки. Отдельно проговори, что процесс обязан сам обрабатывать SIGTERM: если приложение запущено через shell-обёртку, сигнал может до него не дойти (PID 1 и отсутствие проброса) - отсюда и «необъяснимые» обрывы при выкатке.

Суть: Unmarshal требует всё тело в памяти, Decoder читает потоком. В хендлере - всегда http.MaxBytesReader + Decoder с DisallowUnknownFields и проверкой, что после объекта в теле ничего нет.
json.Unmarshaljson.Decoder
Вход[]byte - нужен io.ReadAllio.Reader - читает по мере разбора
Памятьвесь документ + результатбуфер декодера + результат
Поток объектовнетда: for dec.More() { dec.Decode(&v) } - NDJSON, батчи
Сырое тело для лога/подписиестьнет (нужен io.TeeReader)
Строгий режимнетDisallowUnknownFields(), UseNumber()
Типичное применениетело из очереди, конфиг, ответ апстрима под подписьHTTP-хендлер, большие потоки

Скелет хендлера

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1 МиБ, w нужен для корректного закрытия
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()

var in createReq
if err := dec.Decode(&in); err != nil {
    var maxErr *http.MaxBytesError                // Go 1.19+
    var typErr *json.UnmarshalTypeError
    switch {
    case errors.As(err, &maxErr): // 413
    case errors.As(err, &typErr): // 400 с именем поля и ожидаемым типом
    case errors.Is(err, io.EOF):  // 400 «пустое тело»
    }
    return
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
    // в теле было {"a":1}{"b":2} - второй объект молча проехал бы
    return
}

Почему MaxBytesReader, а не io.LimitReader

io.LimitReader на границе просто выдаст io.EOF - тело будет выглядеть как валидно закончившееся, и клиент получит невнятный 400. http.MaxBytesReader возвращает типизированную *http.MaxBytesError (можно отдать честный 413) и через ResponseWriter велит закрыть соединение после ответа, чтобы сервер не читал остаток гигабайтного тела. Лимит ставят до первого чтения, и лучше не в каждом хендлере, а в middleware с дефолтом и точечными исключениями для загрузок.

Тонкости, за которые дают плюс

  • Отсутствующее поле и нулевое значение неразличимы. {} и {"title":""} дают одинаковый результат. Для PATCH берут указатели (*string), обёртку Optional[T] со своим UnmarshalJSON или разбор в map[string]json.RawMessage.
  • DisallowUnknownFields ловит опечатки клиента, но ломает обратную совместимость наоборот: если клиент шлёт поле из будущей версии, ты его отвергнешь. На внутренних API его включают, на публичных сначала думают.
  • Обязательность полей encoding/json не проверяет вообще - это работа валидатора (go-playground/validator или ручной Validate()).
  • Большие числа: float64 по умолчанию теряет точность на int64 больше 2^53, спасают dec.UseNumber() или json.Number в структуре.
  • Очень большой массив разбирают потоково через dec.Token(): читают открывающую скобку, потом элементы по одному.
  • Нужны и сырое тело (для HMAC-подписи вебхука), и разбор? Ставь io.TeeReader в hash.Hash вместо двойного чтения.
Суть: два способа - ParseMultipartForm (просто, часть в памяти, часть во временных файлах) и MultipartReader (потоково, ничего не буферизуем). В обоих - MaxBytesReader, whitelist типов по содержимому, свои имена файлов и обязательный RemoveAll().

Как устроен multipart

Тело состоит из частей, разделённых строкой-boundary из Content-Type: multipart/form-data; boundary=.... У каждой части свои заголовки: Content-Disposition: form-data; name="file"; filename="cat.png" и необязательный Content-Type. Всё это прислал клиент, значит ничему из этого доверять нельзя.

// A: ParseMultipartForm
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
    return // 400
}
defer r.MultipartForm.RemoveAll() // /tmp!

f, hdr, err := r.FormFile("avatar")
if err != nil {
    return // 400
}
defer f.Close()
// hdr.Filename, hdr.Size, hdr.Header
// B: MultipartReader - стриминг
mr, err := r.MultipartReader()
if err != nil { return } // 415
for {
    p, err := mr.NextPart()
    if errors.Is(err, io.EOF) { break }
    if err != nil { return } // 400
    if p.FormName() == "file" {
        uploader.Upload(ctx, key,
            http.MaxBytesReader(w, p, 200<<20))
    }
    p.Close()
}

Аргумент ParseMultipartForm(n) не лимит: он говорит, сколько держать в памяти, а остальное молча уезжает во временные файлы в os.TempDir(). Без MaxBytesReader и RemoveAll() так недолго забить диск. MultipartReader после FormValue или FormFile не сработает: они уже разобрали тело. ParseForm multipart-тело не трогает.

Чек-лист безопасности

  • Лимиты: MaxBytesReader на всё тело, io.LimitReader на каждую часть, ограничение на количество частей.
  • Имя: не доверяй ему. С Go 1.17 FileName() сам отрезает путь вроде ../../etc/cron.d/x, но лучше генерировать uuid и хранить оригинал в метаданных.
  • Тип: по содержимому, через http.DetectContentType(first512), а не по расширению и не по Content-Type части. Whitelist, не blacklist. Картинки ещё и перекодируют: так гибнет полезный груз, вшитый в EXIF.
  • Отдача: с другого домена или через прокси-ручку, с X-Content-Type-Options: nosniff, Content-Disposition: attachment и жёстко заданным Content-Type - иначе залитый HTML/SVG выполнится как страница в origin приложения (хранимый XSS).
  • Хранилище: не в директорию, откуда исполняется код. Большие файлы грузят по presigned URL прямо в S3/MinIO, гонять гигабайты через бэкенд незачем.
  • Антивирус/санитайзинг для пользовательского контента, если файлы потом видят другие пользователи.
Частая боевая ошибка

Забыть defer r.MultipartForm.RemoveAll() в надежде на сервер. net/http чистит только форму исходного запроса, а любой middleware с r.WithContext отдаёт хендлеру копию. Симптом приходит через месяц: no space left on device в совершенно другом месте сервиса, потому что /tmp забит файлами multipart-*.

Что стоит запомнить

  • ReadHeaderTimeout защищает приём заголовков; ReadTimeout, WriteTimeout и IdleTimeout относятся к другим фазам соединения.
  • Response body клиента нужно закрыть, чтобы transport мог вернуть соединение в пул; для reuse его обычно дочитывают в ограниченном объёме.
  • Shutdown прекращает принимать новые соединения и ждёт активные handlers до заданного контекста.

Частые ошибки

  • Не полагайся на дефолты http.Client/http.Server для production latency и защиты ресурсов.
  • Не пиши 500 после того, как заголовки уже ушли клиенту; в этот момент остаётся логирование/закрытие.
  • Не делай shutdown timeout неограниченным: он должен укладываться в termination grace period.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Найди пределы сервера

Какой риск ограничивает каждый из заданных параметров? Что не покрыто этими настройками?

srv := &http.Server{
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout: 15 * time.Second,
    WriteTimeout: 20 * time.Second,
    IdleTimeout: 90 * time.Second,
    MaxHeaderBytes: 1 << 20,
}
Ответ и разбор

ReadHeaderTimeout ограничивает медленную отправку заголовков; ReadTimeout ограничивает чтение запроса; WriteTimeout - запись ответа; IdleTimeout - ожидание следующего запроса на keep-alive соединении; MaxHeaderBytes ограничивает размер заголовков. Эти поля сами не задают лимит тела запроса и не организуют graceful shutdown.

Код-ревью

2. Исправь утечку соединения клиента

На ошибочном статусе функция выходит до закрытия response body. Исправь управление телом, чтобы соединение могло вернуться в пул и не читался бесконечный ответ.

resp, err := client.Do(req)
if err != nil { return err }
if resp.StatusCode != http.StatusOK { return fmt.Errorf("upstream: %s", resp.Status) }
return json.NewDecoder(resp.Body).Decode(&out)
Ответ и разбор

Сразу после успешного Do поставь defer Close; при reuse дочитай остаток с ограничением через io.Copy(io.Discard, io.LimitReader(...)), затем закрой body. Неограниченно дочитывать тело злого/ошибочного upstream тоже опасно. Ошибки чтения и закрытия не замалчивай там, где они меняют исход операции.

Применение

3. Спланируй graceful shutdown

Pod получает SIGTERM и имеет terminationSeconds=30. Составь порядок действий для http.Server и объясни, что делать, если активные запросы не завершились за отведённое время.

Ответ и разбор

Сигнал переводит readiness в false, сервер прекращает принимать новые соединения через Shutdown(ctx), а активные handlers получают ограниченное время на завершение. Задай deadline меньше 30 секунд, оставив время на завершение процесса и закрытие зависимостей. При истечении deadline вызови Close как аварийный предел и залогируй незавершённые запросы; handlers должны передавать ctx в I/O.

Применение

4. Ограничь входящий JSON

Публичный endpoint читает JSON в структуру. Назови четыре проверки до бизнес-операции и объясни, почему Decoder сам по себе не защищает от слишком большого тела.

Ответ и разбор

Проверь Content-Type, ограничь тело MaxBytesReader, декодируй в отдельный request DTO с DisallowUnknownFields, затем валидируй поля и бизнес-инварианты. Decoder читает поток, но без ограничения может принять очень большой документ и занять память/время. Преобразуй ошибки формата и валидации в стабильный публичный Problem response.