Модуль: 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.