Модуль: Backend · Уровень: Middle+/Senior

TL;DR#

  • http.Server — это конфигурация сервера; ListenAndServe запускает accept-loop, на каждое соединение создаётся отдельная горутина (conn.serve).
  • Handler — интерфейс с единственным методом ServeHTTP(w, r). HandlerFunc — адаптер, превращающий обычную функцию в Handler.
  • В Go 1.22 ServeMux научился методам и wildcard-паттернам: "GET /items/{id}", {path...}, доступ через r.PathValue("id"), есть детерминированный приоритет специфичности и паника при конфликтах.
  • Таймауты — обязательны в проде. Дефолтный http.Server без таймаутов уязвим к Slowloris (медленные клиенты держат соединения вечно). Минимум: ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout.
  • http.Client и http.Transport потокобезопасны и переиспользуют TCP/TLS-соединения через пул. Создавать клиент на каждый запрос — антипаттерн (утечка соединений и портов). Всегда defer resp.Body.Close() и вычитывать тело до конца.
  • r.Context() отменяется при разрыве клиентского соединения и при истечении server-таймаутов. context.WithValue — только для request-scoped данных, не для DI и не для опциональных параметров.

Теория#

http.Server: поля и accept loop#

http.ListenAndServe(addr, handler) — это удобная обёртка, которая создаёт &http.Server{Addr: addr, Handler: handler} без единого таймаута. В проде так делать нельзя — нужно конфигурировать http.Server явно.

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      15 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20, // 1 MiB на заголовки
    ErrorLog:          log.New(os.Stderr, "http: ", log.LstdFlags),
    BaseContext:       func(net.Listener) context.Context { return rootCtx },
    ConnContext:       func(ctx context.Context, c net.Conn) context.Context { return ctx },
}
log.Fatal(srv.ListenAndServe())

Ключевые поля:

ПолеНазначение
Addrадрес host:port
Handlerкорневой Handler; если nil — используется http.DefaultServeMux
ReadTimeout / ReadHeaderTimeout / WriteTimeout / IdleTimeoutтаймауты (см. ниже)
MaxHeaderBytesлимит на размер заголовков (по умолчанию 1 MiB)
TLSConfigконфигурация TLS для ListenAndServeTLS
BaseContextбазовый контекст для всех входящих запросов
ConnContextмодификация контекста per-connection
ConnStateхук на смену состояния соединения (New/Active/Idle/Closed)
ErrorLogлоггер ошибок сервера

Как работает accept loop. ListenAndServe вызывает net.Listen("tcp", addr), затем srv.Serve(ln). В Serve крутится бесконечный цикл:

for {
    rw, err := l.Accept()        // блокирующий accept нового соединения
    if err != nil { /* backoff при временной ошибке, иначе return */ }
    c := srv.newConn(rw)
    go c.serve(connCtx)          // ОТДЕЛЬНАЯ горутина на каждое соединение
}

Важные следствия модели “горутина на соединение”:

  • Нет фиксированного пула воркеров — число горутин растёт линейно с числом активных соединений. При 100k keep-alive соединений — 100k горутин (это ок для Go, но память на стеки и планировщик не бесплатны).
  • Внутри одной горутины соединения HTTP-запросы обрабатываются последовательно (для HTTP/1.1 keep-alive). Для HTTP/2 один TCP-conn мультиплексирует много стримов, и под каждый стрим тоже выделяется горутина.
  • Паника в ServeHTTP ловится conn.serve через recover, логируется и закрывает соединение — она не роняет сервер, но и не превращается в 500 автоматически (тело может быть уже частично записано). Свой recover-middleware всё равно нужен.

Graceful shutdown:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Printf("graceful shutdown failed: %v", err)
    _ = srv.Close() // жёсткое закрытие
}

Shutdown перестаёт принимать новые соединения, закрывает idle-соединения и ждёт завершения активных запросов (до дедлайна ctx). Close рвёт всё немедленно. ListenAndServe при штатном shutdown возвращает http.ErrServerClosed — это не ошибка.

Handler, HandlerFunc, Handle/HandleFunc#

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

HandlerFunc — адаптер: тип-функция, у которого метод ServeHTTP просто вызывает саму функцию. Это классический пример “function-as-interface”:

type HandlerFunc func(ResponseWriter, *Request)

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

Поэтому любую func(w, r) можно привести: http.HandlerFunc(myFunc) — и она станет Handler.

  • http.Handle(pattern, handler) — регистрирует Handler в DefaultServeMux.
  • http.HandleFunc(pattern, fn) — то же, но принимает функцию (внутри оборачивает в HandlerFunc).

Middleware строится как функция func(http.Handler) http.Handler:

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}

ServeMux: классический и роутинг Go 1.22#

Классический (до 1.22). Паттерны без методов. Различались два вида:

  • Точный путь: "/items" — совпадает только с /items.
  • Subtree (с завершающим /): "/items/" — совпадает с /items/ и всем, что под ним. Также делает редирект /items/items/ (301). Выбирается самый длинный совпавший паттерн. Метода и path-параметров нет — приходилось парсить путь вручную или брать сторонний роутер (chi, gorilla/mux, httprouter).

Go 1.22: enhanced routing. Паттерн теперь может содержать метод и wildcards.

mux := http.NewServeMux()

// Метод в паттерне
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("POST /items", createItem)
mux.HandleFunc("DELETE /items/{id}", deleteItem)

// Wildcard на остаток пути (только в конце)
mux.HandleFunc("GET /files/{path...}", serveFile)

// Хост в паттерне
mux.HandleFunc("api.example.com/health", health)

func getItem(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")           // извлечение значения wildcard
    path := r.PathValue("path")       // для {path...} — весь хвост
    _ = id; _ = path
}

Правила паттернов:

КонструкцияСмысл
GET /xметод GET; GET также неявно покрывает HEAD
/x (без метода)любой метод
{id}один сегмент пути; доступен через r.PathValue("id")
{path...}многосегментный остаток (только в самом конце паттерна)
/x/{$}конец пути — совпадает только с /x/, но не с /x/y
{_}анонимный wildcard (совпадает, но имя не сохраняется)
example.com/xпривязка к хосту

Приоритет паттернов (специфичность). Если запрос подходит под несколько паттернов, выигрывает более специфичный, а не более длинный. Формально: паттерн P1 специфичнее P2, если P1 совпадает со строгим подмножеством запросов P2. Литеральный сегмент специфичнее wildcard, а {id} специфичнее {path...}.

mux.HandleFunc("GET /items/{id}", byID)   // специфичнее
mux.HandleFunc("GET /items/latest", latest) // ещё специфичнее (литерал)
// Запрос GET /items/latest -> latest (литерал бьёт wildcard)
// Запрос GET /items/42     -> byID

Конфликты паттернов. Если два паттерна совпадают с одним и тем же множеством запросов и ни один не специфичнее другого, ServeMux паникует при регистрации (а не молча выбирает один). Пример конфликта:

mux.HandleFunc("GET /a/{x}/b", h1)
mux.HandleFunc("GET /a/c/{y}", h2)
// /a/c/b подходит под оба, и ни один не специфичнее -> panic при HandleFunc

Прочие особенности 1.22:

  • При несовпадении метода (путь есть, метод другой) ServeMux отдаёт 405 Method Not Allowed и заполняет заголовок Allow.
  • ServeMux теперь очищает и нормализует путь (.., //), делая редирект на канонический URL.
  • Совместимость: можно отключить новое поведение через GODEBUG=httpmuxgo121=1.

Wildcards не “съедают” /: {id} — ровно один сегмент. Для жадного захвата используйте {rest...}.

Таймауты сервера (критично!)#

Почему дефолт опасен — Slowloris. Без таймаутов злоумышленник (или просто кривой/медленный клиент) открывает соединение и шлёт заголовки по одному байту в секунду, никогда не завершая запрос. Соединение и его горутина живут бесконечно. Несколько тысяч таких соединений исчерпывают файловые дескрипторы / память — отказ в обслуживании без всякого флуда трафиком. http.ListenAndServe и http.Server{} без таймаутов уязвимы по умолчанию.

ТаймаутЧто покрываетНа что влияет, риск без него
ReadHeaderTimeoutот accept до конца чтения заголовков запросапрямая защита от Slowloris на заголовках; самый дешёвый и безопасный таймаут
ReadTimeoutот accept до конца чтения всего тела запросазащита от медленной отправки тела; но обрезает легитимные большие/долгие аплоады
WriteTimeoutот конца чтения заголовков до конца записи ответазащита от медленных читателей; обрезает долгие ответы/стриминг
IdleTimeoutсколько keep-alive соединение живёт между запросамибез него idle-соединения копятся; если 0 — используется ReadTimeout
MaxHeaderBytesлимит размера заголовковзащита от раздувания памяти заголовками

Тонкости:

  • ReadTimeout и WriteTimeout — это дедлайны на уровне net.Conn (SetReadDeadline/SetWriteDeadline), они не знают про логику хендлера. WriteTimeout отсчитывается грубо с момента чтения заголовков, поэтому он ограничивает суммарное время обработки + записи. Для долгих стримов (SSE, скачивание больших файлов) WriteTimeout нужно ставить большим или 0, а защищать иначе.
  • Для гранулярного per-handler таймаута есть http.TimeoutHandler:
h := http.TimeoutHandler(slowHandler, 2*time.Second, "request timed out")

TimeoutHandler запускает хендлер в отдельной горутине, и если тот не уложился — отдаёт 503 с заданным сообщением. Важно: он буферизует ответ (использует свой ResponseWriter), поэтому не работает со стримингом / Flusher / Hijacker, и хендлер-горутина может продолжать жить после таймаута (контекст запроса при этом отменяется).

  • Рекомендуемый “безопасный по умолчанию” минимум: всегда задавать ReadHeaderTimeout (даже если остальное специфично для эндпоинтов), затем IdleTimeout, затем Read/WriteTimeout под профиль нагрузки.

http.Client, Transport и пул соединений#

var ErrClientCannotBeRecreated = errors.New("don't do this")

// ПЛОХО: новый клиент (а значит и новый Transport, и новый пул) на каждый вызов
func badFetch(url string) (*http.Response, error) {
    return (&http.Client{}).Get(url) // соединения не переиспользуются между вызовами
}

// ХОРОШО: один долгоживущий клиент на весь процесс/зависимость
var httpClient = &http.Client{
    Timeout: 10 * time.Second, // общий дедлайн на весь запрос
    Transport: &http.Transport{
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   100, // ВАЖНО: дефолт всего 2!
        MaxConnsPerHost:       0,   // 0 = без лимита
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   5 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
        ResponseHeaderTimeout: 5 * time.Second,
        ForceAttemptHTTP2:     true,
    },
}

Почему нельзя создавать клиент на каждый запрос. Пул keep-alive соединений живёт внутри http.Transport. Новый клиент = новый Transport = пустой пул: каждый запрос делает новый TCP+TLS handshake (дорого), а старые соединения уходят в TIME_WAIT. Под нагрузкой это приводит к исчерпанию эфемерных портов и деградации латентности. http.Client и http.Transport потокобезопасны — один экземпляр обслуживает любое число параллельных горутин.

Параметры пула:

ПараметрЗначениеДефолт
MaxIdleConnsмаксимум idle-соединений во всём пуле100
MaxIdleConnsPerHostмаксимум idle-соединений на один хост2 (частая причина деградации!)
MaxConnsPerHostжёсткий лимит активных+idle на хост (блокирует/ждёт при превышении)0 (без лимита)
IdleConnTimeoutсколько idle-соединение живёт до закрытия90s

Если приложение ходит интенсивно к одному upstream, дефолтный MaxIdleConnsPerHost = 2 означает, что при >2 параллельных запросах лишние соединения после ответа сразу закрываются и не переиспользуются — поднимайте этот лимит.

Обязательно закрывать resp.Body и вычитывать его.

resp, err := httpClient.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()

// Чтобы соединение вернулось в пул, тело нужно дочитать ДО конца:
_, _ = io.Copy(io.Discard, resp.Body)

Если не закрыть Body — утечка соединения и горутины (соединение никогда не вернётся в пул). Если закрыть, но не дочитать тело до EOF, Transport не сможет переиспользовать соединение и закроет его (для маленьких ответов это норм, но при потоке запросов теряется весь смысл keep-alive). Поэтому идиома: defer Close() + io.Copy(io.Discard, body) после обработки.

Таймауты клиента и контекст.

  • http.Client.Timeout — общий дедлайн на весь запрос: соединение, отправка, ожидание заголовков, чтение всего тела. Если тело читается долго, Timeout оборвёт чтение в середине. Поэтому для стриминга Timeout неудобен.
  • Гранулярные таймауты — в Transport: TLSHandshakeTimeout, ResponseHeaderTimeout (до получения заголовков ответа, не считая чтение тела), ExpectContinueTimeout.
  • Отмена через контекст — предпочтительный способ для per-request дедлайнов и отмены:
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := httpClient.Do(req) // прервётся при отмене ctx или дедлайне

Контекст отменяет и in-flight запрос (закрывает соединение), и чтение тела.

Контекст запроса (сервер)#

r.Context() возвращает контекст входящего запроса. Он:

  • Отменяется, когда клиент рвёт соединение (и при HTTP/2 reset стрима), а также при Server.Shutdown. Это позволяет хендлеру и нисходящим вызовам (БД, upstream) прекратить бесполезную работу:
func handler(w http.ResponseWriter, r *http.Request) {
    rows, err := db.QueryContext(r.Context(), "SELECT ...") // отменится при разрыве
    ...
}
  • Несёт request-scoped значения через middleware (request ID, аутентифицированный пользователь, трейс):
type ctxKey int
const userKey ctxKey = 0

func auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        u := authenticate(r)
        ctx := context.WithValue(r.Context(), userKey, u)
        next.ServeHTTP(w, r.WithContext(ctx)) // r.WithContext возвращает копию
    })
}

func userFromCtx(ctx context.Context) (*User, bool) {
    u, ok := ctx.Value(userKey).(*User)
    return u, ok
}

Антипаттерны context.WithValue:

  • Ключ типа string/встроенного типа — риск коллизий. Используйте неэкспортируемый тип ключа (type ctxKey int).
  • Прятать в контекст обязательные зависимости (логгер, конфиг, сервисы) вместо явной передачи через параметры/структуру — это скрытый DI, который ломает читаемость и типобезопасность.
  • Класть в контекст параметры функции, которые могли бы быть аргументами. Контекст — для request-scoped метаданных, проходящих сквозь границы API, а не для “удобного глобального мешка”.
  • Изменяемые значения в контексте: контекст иммутабелен по дизайну, мутируемое состояние через него — источник гонок.

ResponseWriter: порядок записи, Flusher, Hijacker#

type ResponseWriter interface {
    Header() Header
    Write([]byte) (int, error)
    WriteHeader(statusCode int)
}

Правила:

  • Заголовки задаются до тела. Менять w.Header() нужно до первого Write или WriteHeader. Первый Write неявно вызывает WriteHeader(200), после чего изменения заголовков уже отправлены и игнорируются (Go залогирует http: superfluous response.WriteHeader call при повторном вызове).
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // статус ДО тела
_ = json.NewEncoder(w).Encode(payload)
// w.Header().Set(...) здесь УЖЕ бесполезно
  • WriteHeader можно вызвать только один раз; повторный — no-op с логом.
  • http.Flusher — для стриминга (SSE, chunked). Позволяет вытолкнуть буфер клиенту, не дожидаясь конца ответа:
func sse(w http.ResponseWriter, r *http.Request) {
    fl, ok := w.(http.Flusher)
    if !ok { http.Error(w, "streaming unsupported", 500); return }
    w.Header().Set("Content-Type", "text/event-stream")
    for {
        select {
        case <-r.Context().Done():
            return
        case ev := <-events:
            fmt.Fprintf(w, "data: %s\n\n", ev)
            fl.Flush() // немедленно отправить клиенту
        }
    }
}
  • http.Hijacker — забирает сырое TCP-соединение (для WebSocket и т.п.). После Hijack() сервер перестаёт управлять соединением: нельзя больше использовать ResponseWriter, писать через него или вызывать WriteHeader — вы отвечаете за чтение/запись и закрытие net.Conn сами.
hj, ok := w.(http.Hijacker)
if !ok { http.Error(w, "hijack unsupported", 500); return }
conn, bufrw, err := hj.Hijack()
if err != nil { return }
defer conn.Close()
// дальше — только conn/bufrw; w использовать НЕЛЬЗЯ
  • Тип, реализующий ResponseWriter, опционально реализует Flusher/Hijacker/Pusher — проверяйте type-assertion. Обёртки-middleware над ResponseWriter (например, для подсчёта статуса) теряют эти интерфейсы, если их не прокинуть явно — частый баг, ломающий стриминг/WebSocket.

Подводные камни / gotchas#

  • http.ListenAndServe без таймаутов — уязвимость Slowloris. Никогда не использовать в проде; всегда конфигурировать http.Server с таймаутами.
  • MaxIdleConnsPerHost = 2 по умолчанию — при интенсивных запросах к одному хосту соединения не переиспользуются. Тихая деградация производительности.
  • Создание http.Client/Transport на каждый запрос — пустой пул, лишние handshakes, исчерпание портов (TIME_WAIT).
  • Незакрытый resp.Body — утечка соединения и горутины. Закрытый, но недочитанный Body — соединение не возвращается в пул.
  • WriteTimeout обрезает долгие ответы/стриминг — для SSE и больших скачиваний ставьте больше/0 и защищайтесь иначе.
  • http.TimeoutHandler несовместим со стримингом (буферизует ответ, прячет Flusher/Hijacker); хендлер-горутина может пережить таймаут.
  • Изменение заголовков после первого Write не имеет эффекта (superfluous WriteHeader).
  • Обёртка над ResponseWriter теряет Flusher/Hijacker — ломает стриминг и WebSocket, если интерфейсы не прокинуты.
  • Конфликт паттернов в ServeMux 1.22 — паника при старте (а не молчаливый выбор). Это фича, но ловит врасплох.
  • Wildcard {id} — ровно один сегмент, не жадный; для остатка пути нужен {path...}.
  • Паника в хендлере ловится сервером, но не превращается в 500 автоматически и может оставить полузаписанный ответ — нужен свой recover-middleware.
  • context.WithValue со строковым ключом — риск коллизий; используйте приватный тип ключа.

Вопросы на собеседовании#

В: Какая конкурентная модель у net/http сервера? О: Accept-loop в Server.Serve принимает соединения и запускает по горутине на каждое соединение (go c.serve). HTTP/1.1 keep-alive: запросы в одном соединении обрабатываются последовательно той же горутиной. HTTP/2: один TCP-conn мультиплексирует стримы, под каждый стрим — своя горутина. Фиксированного пула воркеров нет, число горутин растёт с числом соединений.

В: Почему дефолтный http.Server опасен в проде и что такое Slowloris? О: Без таймаутов медленный/злонамеренный клиент держит соединение открытым бесконечно (шлёт заголовки/тело по байту), занимая дескриптор и горутину. Тысячи таких соединений → отказ в обслуживании без флуда трафиком. Лечится ReadHeaderTimeout (минимум), плюс ReadTimeout, WriteTimeout, IdleTimeout, MaxHeaderBytes.

В: Чем отличаются ReadTimeout, ReadHeaderTimeout, WriteTimeout, IdleTimeout? О: ReadHeaderTimeout — дедлайн на чтение заголовков (от accept). ReadTimeout — на чтение всего запроса включая тело. WriteTimeout — от конца чтения заголовков до конца записи ответа (фактически ограничивает обработку+запись). IdleTimeout — время жизни keep-alive соединения между запросами; если 0, берётся ReadTimeout. Это net-deadline’ы, они не знают логики хендлера; для per-handler — http.TimeoutHandler или контекст.

В: Что появилось в роутинге ServeMux в Go 1.22? О: Методы в паттернах ("GET /items/{id}"), wildcards {id} (один сегмент) и {path...} (остаток), r.PathValue(), {$} для точного конца, привязка к хосту. Приоритет — по специфичности (литерал > wildcard), а не по длине. Конфликтующие паттерны вызывают панику при регистрации. Несовпадение метода → 405 с заголовком Allow. Путь нормализуется с редиректом на канонический.

В: Как определяется победитель при пересечении паттернов и что такое конфликт? О: Побеждает более специфичный паттерн — тот, чьё множество совпадающих запросов является строгим подмножеством другого (литерал специфичнее wildcard). Если ни один не специфичнее и множества пересекаются неоднозначно — это конфликт, и ServeMux паникует при Handle/HandleFunc.

В: Почему нельзя создавать http.Client на каждый запрос? О: Пул keep-alive соединений живёт в Transport. Новый клиент → новый Transport → пустой пул: каждый раз TCP+TLS handshake, старые соединения уходят в TIME_WAIT, исчерпываются эфемерные порты. Client/Transport потокобезопасны — нужно держать один на процесс/зависимость и тюнить MaxIdleConnsPerHost (дефолт 2).

В: Почему обязательно закрывать и дочитывать resp.Body? О: Close() возвращает соединение в пул и освобождает ресурсы; без него — утечка соединения/горутины. Но если тело не дочитано до EOF, Transport не может переиспользовать соединение и закроет его. Идиома: defer resp.Body.Close() плюс io.Copy(io.Discard, resp.Body).

В: Как правильно отменять исходящий HTTP-запрос по таймауту? О: Предпочтительно через контекст: http.NewRequestWithContext + context.WithTimeout. Это отменяет и установление соединения, и ожидание ответа, и чтение тела. Client.Timeout — грубый общий дедлайн на весь запрос (мешает стримингу). Гранулярные TLSHandshakeTimeout/ResponseHeaderTimeout — в Transport.

В: Когда отменяется r.Context() и зачем его прокидывать вниз? О: Отменяется при разрыве клиентского соединения, reset HTTP/2-стрима и при Server.Shutdown. Прокидывание в QueryContext/исходящие запросы даёт раннюю отмену бесполезной работы и освобождение ресурсов, когда клиент уже ушёл.

В: Какие правила и подводные камни у ResponseWriter? О: Заголовки — только до первого Write/WriteHeader (первый Write неявно шлёт 200). WriteHeader — один раз. Для стриминга нужен http.Flusher. После Hijack() ResponseWriter использовать нельзя — соединение ваше. Обёртки над ResponseWriter теряют Flusher/Hijacker, если их не прокинуть.

В: Какие антипаттерны у context.WithValue? О: Строковый/встроенный тип ключа (коллизии) — нужен приватный тип. Передача обязательных зависимостей (логгер, сервисы, конфиг) — это скрытый DI, ломающий типобезопасность; такие вещи передают явно. Контекст — только для request-scoped метаданных, пересекающих границы API, и он иммутабелен.

На что копают на senior+#

  • Точная семантика WriteTimeout относительно момента чтения заголовков и почему он не годится для долгих стримов; альтернативы (TimeoutHandler, контекстные дедлайны, ручные SetWriteDeadline через http.ResponseController в Go 1.20+).
  • http.ResponseController (Go 1.20+) для per-request управления дедлайнами и Flush поверх обёрток ResponseWriter.
  • Поведение пула при HTTP/2 (мультиплексирование, отдельный путь, ForceAttemptHTTP2) и почему MaxConnsPerHost/MaxIdleConnsPerHost ведут себя иначе.
  • Что именно происходит с горутиной хендлера после срабатывания TimeoutHandler или отмены контекста (она не убивается принудительно — нужно самому реагировать на ctx.Done()).
  • Внутреннее устройство accept-loop: backoff при временных Accept-ошибках, ConnState/BaseContext/ConnContext хуки.
  • Корректный graceful shutdown с дренажом, обработка ErrServerClosed, координация с балансировщиком (readiness-проба).
  • Алгоритм специфичности паттернов 1.22 и детерминированность конфликтов; как мигрировать со стороннего роутера и где net/http всё ещё уступает (отсутствие группировки роутов, middleware-цепочек из коробки).
  • Утечки портов/TIME_WAIT под нагрузкой, настройка MaxIdleConnsPerHost, влияние недочитанного тела на переиспользование соединений.
  • Безопасная обёртка ResponseWriter с сохранением опциональных интерфейсов (Flusher/Hijacker/Pusher) или через ResponseController.