Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
- Middleware в идиоматическом Go — это функция
func(http.Handler) http.Handler: она принимает следующий хендлер, оборачивает его и возвращает новыйhttp.Handler. Это декоратор поверх интерфейсаhttp.Handler. - Цепочка строится композицией:
Chain(m1, m2, m3)(final). Внешний middleware оборачивает внутренний. На входе порядок прямой (m1 → m2 → m3 → handler), на выходе — LIFO (handler → m3 → m2 → m1), потому что код послеnext.ServeHTTPвыполняется при разворачивании стека. - Захват status code требует обёртки
http.ResponseWriter, т.к. интерфейс не отдаёт код наружу. Наивная обёртка теряетhttp.Flusher,http.Hijacker,http.Pusher— это классический баг. - Порядок критичен:
recovery— самым снаружи (ловит панику из всех остальных),CORS— рано (чтобы preflight отработал до auth),auth— до бизнес-логики,rate limit— обычно до тяжёлой работы. - Best practices: middleware должны быть дешёвыми, не блокировать, идемпотентными по эффекту, передавать данные через
context.Context(не через мутацию структур), не хардкодить зависимости (внедрять через замыкание/конструктор). - В
net/http1.22+ роутерhttp.ServeMuxумеет методы и path-параметры, но не имеет встроенной поддержки middleware-цепочек — оборачиватьmuxцеликом или использоватьchi.
Теория#
1. Каноническое определение#
Middleware — это декоратор над http.Handler. Базовая сигнатура:
type Middleware func(next http.Handler) http.HandlerПростейший пример — middleware, добавляющий заголовок:
func AddHeader(key, value string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(key, value)
next.ServeHTTP(w, r) // вызов следующего звена
})
}
}Ключевые моменты:
- Возвращаемое значение —
http.Handler(через адаптерhttp.HandlerFunc). next.ServeHTTP(w, r)— точка передачи управления дальше по цепочке.- Код до
next.ServeHTTPвыполняется на «входе», код после — на «выходе» (разворачивание стека). - Можно не вызывать
next(например, при провале auth или rate limit) — тогда цепочка прерывается (short-circuit).
2. Цепочки: ручное вложение vs хелпер#
Ручное вложение читается «наизнанку» и плохо масштабируется:
handler := Logging(Recovery(Auth(myHandler)))
// Выполнение на входе: Logging -> Recovery -> Auth -> myHandlerХелпер Chain делает порядок объявления = порядку выполнения:
func Chain(mws ...Middleware) Middleware {
return func(final http.Handler) http.Handler {
// Оборачиваем с конца, чтобы первый в списке оказался самым внешним.
for i := len(mws) - 1; i >= 0; i-- {
final = mws[i](final)
}
return final
}
}
// Использование: Logging — самый внешний, Auth — ближе к хендлеру.
h := Chain(Logging, Recovery, Auth)(myHandler)Важно: порядок цикла (
len-1 → 0) выбран так, чтобыmws[0]обернул всё остальное. Если итерировать с начала — порядок инвертируется. Это типовая ошибка в самописныхChain.
3. Порядок выполнения и LIFO на выходе#
Рассмотрим цепочку Chain(A, B, C)(H):
Запрос → A(before) → B(before) → C(before) → H → C(after) → B(after) → A(after) → Ответ- Вход (before): прямой порядок A → B → C (внешний → внутренний).
- Выход (after): обратный порядок C → B → A (LIFO, как стек вызовов).
Это прямое следствие того, что after-код стоит после next.ServeHTTP и выполняется при возврате из рекурсии вызовов.
func Trace(name string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("-> %s", name) // before
next.ServeHTTP(w, r)
log.Printf("<- %s", name) // after (LIFO)
})
}
}
// Chain(Trace("A"), Trace("B"))(h)
// Вывод: -> A, -> B, (handler), <- B, <- AПрактический вывод: если middleware измеряет общую длительность (timing), его after-код должен видеть результат всех вложенных — значит он должен быть снаружи.
4. Захват status code: обёртка ResponseWriter#
Интерфейс http.ResponseWriter не предоставляет способа прочитать записанный статус-код или количество байт — он только пишет. Поэтому для логирования статуса нужна обёртка:
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
wroteHeader bool
}
func (r *statusRecorder) WriteHeader(code int) {
if r.wroteHeader {
return // защита от двойного WriteHeader (иначе паника в net/http)
}
r.status = code
r.wroteHeader = true
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
// Имитируем поведение net/http: первый Write без WriteHeader => 200.
r.WriteHeader(http.StatusOK)
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}Нюансы:
- Если хендлер ни разу не вызвал
WriteHeaderи не писал тело — статус-код останется нулём; инициализируйтеstatusзначениемhttp.StatusOKили обрабатывайте0как 200. Writeнеявно выставляет 200 при первой записи — это надо повторить, иначе статус для тел без явногоWriteHeaderбудет неверным.- Защита от повторного
WriteHeaderважна: стандартныйnet/httpлогируетsuperfluous response.WriteHeader call, а ваша обёртка может затереть корректный статус.
5. Проблема потери интерфейсов (Flusher/Hijacker/Pusher)#
http.ResponseWriter от сервера дополнительно реализует опциональные интерфейсы:
| Интерфейс | Назначение | Кто использует |
|---|---|---|
http.Flusher | принудительный сброс буфера | SSE, стриминг, chunked |
http.Hijacker | захват TCP-соединения | WebSocket (gorilla/ws, nhooyr) |
http.Pusher | HTTP/2 Server Push | HTTP/2 push (deprecated в браузерах) |
io.ReaderFrom | оптимизация копирования (sendfile) | отдача файлов |
Когда вы оборачиваете writer структурой statusRecorder со встроенным http.ResponseWriter, встраивание промотит только методы интерфейса http.ResponseWriter (Header, Write, WriteHeader). Type assertion w.(http.Flusher) на вашей обёртке провалится, даже если оригинальный writer реализует Flusher — потому что обёртка как тип не объявляет этих методов через свой статический интерфейс…
Точнее: встроенное поле промотит и Flush, если базовый writer его имеет, но только если базовое поле имеет статический тип, реализующий Flusher. Поскольку поле имеет тип http.ResponseWriter (интерфейс без Flush), методы Flusher НЕ промотятся, и assertion recorder.(http.Flusher) вернёт false. Это и есть потеря интерфейсов.
// БАГ: SSE/WebSocket перестают работать за этим middleware.
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
// внутри handler: w.(http.Flusher) -> ok == falseРешения:
A. Явно проксировать нужные методы (если знаете, что они есть):
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("ResponseWriter does not support Hijack")
}
func (r *statusRecorder) Push(target string, opts *http.PushOptions) error {
if p, ok := r.ResponseWriter.(http.Pusher); ok {
return p.Push(target, opts)
}
return http.ErrNotSupported
}B. Использовать готовое решение — github.com/felixge/httpsnoop, которое динамически собирает writer, сохраняющий ровно тот набор опциональных интерфейсов, что был у оригинала (через комбинаторику типов):
m := httpsnoop.CaptureMetrics(next, w, r) // m.Code, m.Duration, m.WrittenЭто де-факто стандартный способ корректного захвата метрик без потери интерфейсов.
6. Типовые middleware#
6.1 Logging (с захватом status code)#
func Logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("http_request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
)
})
}
}6.2 Recovery (recover от паники → 500)#
func Recovery(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// ErrAbortHandler — спец-паника net/http, её не логируем как ошибку
if rec == http.ErrAbortHandler {
panic(rec)
}
logger.Error("panic recovered",
"err", rec,
"stack", string(debug.Stack()),
)
// Если заголовки ещё не отправлены — отдаём 500.
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("internal server error"))
}
}()
next.ServeHTTP(w, r)
})
}
}Нюансы:
recoverловит панику только в той же горутине. Паника вgo func(){...}()внутри хендлера обрушит весь процесс — middleware не поможет.- Если хендлер уже успел вызвать
WriteHeader, повторныйWriteHeader(500)ничего не изменит (статус уже на проводе) и вызоветsuperfluous WriteHeader. Это допустимо, но статус клиенту уйдёт старый. http.ErrAbortHandlerследует пробрасывать дальше — это штатный механизм прерывания.
6.3 Auth (проверка токена → user в context)#
type ctxKey int
const userKey ctxKey = iota
type User struct {
ID string
Roles []string
}
func Auth(verify func(token string) (*User, error)) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
token, ok := strings.CutPrefix(authz, "Bearer ")
if !ok || token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // short-circuit: next НЕ вызывается
}
user, err := verify(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx)) // прокидываем новый request
})
}
}
// Типобезопасный геттер для хендлеров.
func UserFromContext(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}Нюансы:
- Ключ контекста — приватный тип (
ctxKey), неstring. Это защищает от коллизий ключей между пакетами (требованиеgo vet/staticcheck). - Данные прокидываются через
r.WithContext(ctx)— request иммутабелен,WithContextсоздаёт shallow-копию. context.Value— только для request-scoped данных (user, trace-id, request-id), не для передачи опциональных параметров функций.
6.4 CORS (заголовки + preflight OPTIONS)#
func CORS(allowedOrigins []string) Middleware {
allowed := make(map[string]bool, len(allowedOrigins))
for _, o := range allowedOrigins {
allowed[o] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (allowed["*"] || allowed[origin]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") // важно для кеширования прокси/CDN
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
// Preflight: браузер шлёт OPTIONS перед "сложным" запросом.
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400") // кеш preflight
w.WriteHeader(http.StatusNoContent)
return // НЕ передаём дальше — preflight не доходит до auth/handler
}
next.ServeHTTP(w, r)
})
}
}Нюансы:
- При
Allow-Credentials: trueнельзя отдаватьAllow-Origin: *— браузер отклонит. Нужно эхо конкретногоOrigin. Vary: Originобязателен, иначе CDN закеширует ответ для одного origin и отдаст другому.- Preflight (
OPTIONS) надо обработать до auth: у preflight нетAuthorization, иначе он словит 401 и реальный запрос не пойдёт.
6.5 Rate limiting (golang.org/x/time/rate, per-IP)#
import "golang.org/x/time/rate"
type ipLimiter struct {
mu sync.Mutex
limiters map[string]*entry
rps rate.Limit
burst int
}
type entry struct {
lim *rate.Limiter
lastSeen time.Time
}
func newIPLimiter(rps rate.Limit, burst int) *ipLimiter {
l := &ipLimiter{limiters: map[string]*entry{}, rps: rps, burst: burst}
go l.cleanup() // фоновая чистка, иначе утечка памяти по IP
return l
}
func (l *ipLimiter) get(ip string) *rate.Limiter {
l.mu.Lock()
defer l.mu.Unlock()
e, ok := l.limiters[ip]
if !ok {
e = &entry{lim: rate.NewLimiter(l.rps, l.burst)}
l.limiters[ip] = e
}
e.lastSeen = time.Now()
return e.lim
}
func (l *ipLimiter) cleanup() {
for range time.Tick(time.Minute) {
l.mu.Lock()
for ip, e := range l.limiters {
if time.Since(e.lastSeen) > 3*time.Minute {
delete(l.limiters, ip)
}
}
l.mu.Unlock()
}
}
func RateLimit(l *ipLimiter) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if !l.get(ip).Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}Нюансы:
rate.Limiterоснован на token bucket:rate.Limit— пополнение токенов в секунду,burst— ёмкость ведра (допустимый всплеск).Allow()неблокирующий;Wait(ctx)блокирует до получения токена;Reserve()даёт резервацию с возможным откатом. В middleware обычноAllow().- За балансировщиком
r.RemoteAddr= IP прокси. Реальный IP надо брать изX-Forwarded-For/X-Real-IP, но только доверяя своему прокси (иначе клиент подделает заголовок и обойдёт лимит). - Без фоновой чистки
map[ip]растёт неограниченно — DoS по памяти.
7. Порядок middleware: рекомендуемая компоновка#
h := Chain(
Recovery(logger), // 1. снаружи всех — ловит панику отовсюду
RequestID(), // 2. id для трассировки во всех логах
Logging(logger), // 3. видит финальный статус (включая 500 от Recovery)
CORS(origins), // 4. рано: preflight OPTIONS не должен дойти до auth
RateLimit(limiter), // 5. до тяжёлой работы; иногда до auth, иногда после
Auth(verifyToken), // 6. до бизнес-логики; кладёт user в context
)(businessHandler)| Middleware | Позиция | Почему |
|---|---|---|
| Recovery | самый внешний | должен перехватить панику любого вложенного middleware и хендлера |
| RequestID | очень рано | чтобы id попал во все последующие логи |
| Logging | снаружи (но внутри Recovery) | чтобы залогировать итоговый статус, включая 500 от Recovery |
| CORS | рано | preflight OPTIONS обрабатывается до auth (нет токена) |
| RateLimit | до бизнес-логики | дешёвый отсев; защищает дорогие ресурсы |
| Auth | перед хендлером | бизнес-логике нужен готовый user в context |
Тонкость: Logging vs Recovery. Если Recovery снаружи Logging, то при панике Logging уже отработает свой
afterи залогирует статус, а Recovery поймает панику и отдаст 500 — но Logging может не увидеть этот 500. Часто Recovery ставят и снаружи (для отдачи 500), а метрики статуса считают через httpsnoop внутри Logging. Иногда Logging делают внешним, чтобы он точно зафиксировал даже паникующие запросы. Решение зависит от того, что важнее: гарантия 500 или гарантия лога.
8. Стандартные паттерны в роутерах#
chi#
import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"
r := chi.NewRouter()
r.Use(middleware.RequestID) // глобально, в порядке объявления
r.Use(middleware.Recoverer)
r.Use(middleware.Logger)
r.Group(func(r chi.Router) { // изолированная группа со своими middleware
r.Use(AuthMiddleware)
r.Get("/me", meHandler)
})chiиспользует ту же сигнатуруfunc(http.Handler) http.Handler— ваши middleware совместимы с chi без адаптеров.r.Useприменяет в порядке объявления (первый — внешний).r.Group/r.Routeпозволяют локальные цепочки (например, защищённые маршруты).middleware.WrapResponseWriterв chi корректно сохраняет Flusher/Hijacker.
net/http 1.22+#
Роутер http.ServeMux с 1.22 поддерживает методы и path-параметры (r.PathValue("id")), но не middleware-цепочки. Оборачивают весь mux:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser) // метод + path-param (1.22)
mux.HandleFunc("POST /users", createUser)
// Глобальные middleware — оборачиваем mux целиком.
handler := Chain(Recovery(logger), Logging(logger))(mux)
srv := &http.Server{Addr: ":8080", Handler: handler}
log.Fatal(srv.ListenAndServe())Для middleware на подмножество маршрутов в чистом net/http — оборачивают конкретные хендлеры вручную или используют вложенные mux.
Подводные камни / gotchas#
- Потеря опциональных интерфейсов при обёртке
ResponseWriter(Flusher/Hijacker/Pusher) — ломает SSE и WebSocket. Используйте httpsnoop или явно проксируйте методы. recoverне ловит панику из дочерних горутин хендлера — процесс упадёт. Recovery middleware защищает только основную горутину запроса.- Двойной
WriteHeader— паника/предупреждение в net/http. Обёртка должна защищаться флагомwroteHeader. - Изменение порядка ломает семантику: CORS после auth → preflight ловит 401; Logging снаружи Recovery vs внутри → разный статус в логах.
r.RemoteAddrза прокси = IP прокси. Rate limit/логи по нему бесполезны без аккуратной обработкиX-Forwarded-For(и доверия к прокси).- Утечка памяти в per-IP лимитере без фоновой очистки map.
- Мутация
r.Header/глобального состояния в middleware — не идемпотентно при ретраях, опасно при параллельной обработке. context.WithValueсо строковым ключом — коллизии между пакетами; всегда приватный тип ключа.- Тяжёлая работа в middleware (синхронный запрос в БД на каждый запрос для auth без кеша) добавляет латентность ко всем эндпоинтам.
- Запись в
wдоnextбез необходимости (например, заголовки) фиксирует header set — потомWriteHeaderс другим кодом не сработает. Allow-Origin: *вместе сAllow-Credentials: true— отклоняется браузером.- Забытый
Vary: Origin— CDN отдаёт чужой CORS-ответ.
Вопросы на собеседовании#
В: Почему идиоматическая сигнатура middleware — именно func(http.Handler) http.Handler, а не, скажем, func(w, r, next)?
О: Потому что http.Handler — единственный интерфейс, который понимает весь стандартный стек (http.Server, ServeMux, сторонние роутеры). Декоратор func(http.Handler) http.Handler сохраняет тип, поэтому цепочки композируются без адаптеров и совместимы с chi, gorilla и т.д. Сигнатура с явным next (как в Express/негони) требует своего раннера и не является нативной для net/http.
В: В каком порядке выполняются middleware в цепочке на входе и на выходе?
О: На входе — прямой порядок объявления (внешний → внутренний), до вызова next.ServeHTTP. На выходе — обратный, LIFO, потому что код после next.ServeHTTP срабатывает при разворачивании стека вызовов. Поэтому таймер общей длительности и recovery должны быть снаружи.
В: Зачем оборачивать http.ResponseWriter и какая главная опасность этой обёртки?
О: Интерфейс ResponseWriter не отдаёт записанный статус-код/размер тела, поэтому для логирования нужна обёртка, перехватывающая WriteHeader/Write. Главная опасность — потеря опциональных интерфейсов (Flusher, Hijacker, Pusher, io.ReaderFrom): встроенное поле типа http.ResponseWriter не промотит их методы, и type assertion в хендлере провалится, ломая SSE/WebSocket/стриминг. Решение — проксировать методы вручную или использовать httpsnoop.
В: Почему именно встраивание http.ResponseWriter теряет Flusher, ведь Go промотит методы встроенного поля?
О: Промоушн методов работает по статическому типу поля. Поле имеет тип интерфейса http.ResponseWriter, у которого в наборе методов нет Flush(). Поэтому компилятор не промотит Flush, даже если динамический тип за интерфейсом его реализует. Обёртка как тип не удовлетворяет http.Flusher, и obj.(http.Flusher) → false.
В: Где в цепочке должен стоять recovery middleware и почему?
О: Самым внешним. Тогда defer recover() перехватит панику из любого вложенного middleware и хендлера. Если recovery вложить глубже, паника во внешнем middleware его обойдёт и обрушит процесс. Оговорка: recover ловит только панику в той же горутине — паника в go func хендлера всё равно убьёт сервис.
В: Почему CORS preflight (OPTIONS) надо обрабатывать до auth?
О: Preflight-запрос браузер шлёт автоматически и без Authorization. Если auth стоит раньше, OPTIONS получит 401, браузер посчитает CORS-проверку проваленной и не отправит реальный запрос. CORS должен ответить на OPTIONS (обычно 204 с CORS-заголовками) и сделать short-circuit, не доходя до auth.
В: Как корректно реализовать per-IP rate limiting на golang.org/x/time/rate?
О: Держать map[ip]*rate.Limiter под мьютексом, создавать лимитер лениво на первый запрос с IP, вызывать Allow() (неблокирующий) и при false отдавать 429 с Retry-After. Обязательно фоновая очистка старых записей (по lastSeen), иначе утечка памяти. За прокси брать IP из доверенного X-Forwarded-For, не из RemoteAddr. Параметры: rate.Limit (rps) — скорость пополнения токенов, burst — ёмкость ведра (token bucket).
В: Как передавать данные (например, user) из auth middleware в хендлер?
О: Через context.WithValue(r.Context(), key, val) и r.WithContext(ctx), где key — приватный пользовательский тип (не string), чтобы избежать коллизий. Хендлер достаёт через типобезопасный геттер с проверкой ok. Context — для request-scoped данных; нельзя складывать туда обязательные параметры функций или мутабельное общее состояние.
В: Чем отличается порядок Logging снаружи Recovery от Recovery снаружи Logging? О: Если Recovery снаружи: при панике Logging уже мог отработать (или нет — зависит от того, где паника), а 500 отдаст Recovery; лог может зафиксировать неверный/нулевой статус. Если Logging снаружи: лог гарантированно выполнится для всех запросов, включая паникующие, но тогда Recovery вложен и не покроет панику самого Logging. На практике Recovery ставят внешним для гарантии 500, а статус для лога снимают через httpsnoop внутри Logging.
В: Поддерживает ли net/http 1.22 middleware из коробки?
О: Нет. 1.22 добавил в ServeMux методы (GET /path) и path-параметры (r.PathValue), но цепочек middleware в стандартном роутере нет. Глобальные middleware применяют, оборачивая весь mux (Chain(...)(mux)); для подмножеств — вручную оборачивают хендлеры или берут роутер вроде chi с r.Use/r.Group.
На что копают на senior+#
- Глубокое понимание потери интерфейсов: попросят объяснить через статический vs динамический тип, почему именно встраивание интерфейса не промотит Flush, и как httpsnoop динамически собирает правильную комбинацию интерфейсов (16 комбинаций Flusher/Hijacker/Pusher/ReaderFrom).
- Производительность цепочек: каждое звено — это
http.HandlerFunc(аллокация замыкания при сборке цепочки, но один раз на старте) и накладной вызов; обсуждение того, что цепочка собирается на старте, а не на каждый запрос, и чтоr.WithContextаллоцирует. - Контекст и отмена: связь middleware с
r.Context(), дедлайнами,context.WithTimeoutна уровне middleware, корректная отмена downstream-вызовов. - Recovery и горутины: ограничение recover одной горутиной, паттерны безопасного запуска фоновых горутин (отдельный recover внутри
go func),http.ErrAbortHandler. - Корректность Logging: захват статуса при стриминге, проблема двойного
WriteHeader, учёт размера тела, не ломатьio.ReaderFrom(sendfile) обёрткой. - Безопасность rate limiting: подделка
X-Forwarded-For, выбор distributed rate limit (Redis/token bucket в сторадже) vs локальный, fairness, тонкости token bucket vs leaky bucket vs sliding window. - CORS-нюансы: credentials + wildcard,
Vary: Origin, кеширование preflight,Access-Control-Expose-Headers, обработкаPrivate Network Accessзаголовков. - Идемпотентность и иммутабельность: почему
*http.Requestнужно обновлять черезWithContext(shallow copy), а не мутировать поля; потокобезопасность shared-состояния в middleware. - Тестирование: как тестировать middleware изолированно через
httptest.NewRecorderи фейковыйnext, проверка short-circuit, проверка проброса context. - Альтернативные сигнатуры: где оправдан
func(http.HandlerFunc) http.HandlerFunc, паттерн с явнымnextи почему стандарт остаётсяfunc(http.Handler) http.Handler.