Модуль: 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/http 1.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.PusherHTTP/2 Server PushHTTP/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.