---
title: "Middleware: встроенные"
description: "Разбор gin middleware: gin.Logger и gin.Recovery изнутри, луковица цепочки вызовов, c.Next и c.Abort, передача данных между звеньями через c.Set и c.Get."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/06-middleware/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Middleware: встроенные

*Курс «Gin: самый популярный веб-фреймворк Go», урок 6 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/06-middleware/*

Цепочка звеньев перед хендлером — это и есть gin middleware: в ядре Gin их ровно два, gin.Logger и gin.Recovery, и оба заслуживают разбора по строкам. Пройдём сигнатуру HandlerFunc, луковицу выполнения вокруг c.Next(), прерывание c.Abort(), передачу пользователя через c.Set и c.Get — и научимся читать строку лога [GIN] так, чтобы она говорила всё.

### gin middleware: HandlerFunc и цепочка вызовов

Отдельного типа «middleware» в Gin нет: любой разбор gin middleware начинается с HandlerFunc — func(*gin.Context), того же типа, что и у обычного хендлера. Роль определяет позиция: звено стоит раньше финального обработчика и обычно зовёт c.Next(), чтобы пропустить ход дальше. Цепочка вызовов собирается при регистрации маршрута: r.GET("/todos", authGate, listTodos) — первый аргумент после пути, а дальше variadic, звеньев может быть сколько угодно. Глобальные звенья через r.Use(...) приклеиваются впереди: combineHandlers копирует накопленные звенья группы и звенья маршрута в один срез, и на каждый запрос этот срез исполняется слева направо по индексу внутри Context. У Use есть любопытное свойство: он глобален для движка — звено попадает и в 404, и в 405, и в отдачу статики, поэтому документация прямо называет Use правильным местом для Logger и Recovery. Любая ваша функция с сигнатурой func(*gin.Context) может встать в цепочку: middleware — не магия, а обычный код с удачной позицией.

### Цепочка из трёх звеньев на одном маршруте

```go
// tracer — простейшее middleware: печатает вход и выход звена.
func tracer(tag string) gin.HandlerFunc {
        return func(c *gin.Context) {
                fmt.Println(tag, "до c.Next()")
                c.Next() // всё, что после, сработает «на выходе»
                fmt.Println(tag, "после c.Next()")
        }
}

func main() {
        r := gin.Default() // Logger и Recovery уже в цепочке

        // Звенья исполняются слева направо: tracer, authGate, listTodos.
        r.GET("/todos", tracer("звено-1"), authGate, listTodos)

        r.Run(":8080")
}
```

Полная цепочка этого маршрута: Logger, Recovery, tracer, authGate, listTodos — первые два добавлены gin.Default() через Use, остальные перечислены в r.GET. tracer — замыкание-фабрика: оно принимает тег и возвращает HandlerFunc, готовый к постановке в цепочку. Тот же tracer можно прицепить ко всем маршрутам разом: r.Use(tracer("общий")). Тел authGate и listTodos пока нет — ниже разберём их целиком; сейчас важен порядок, а не содержимое.

### c.Next() и луковица: код до и после

Механика в исходниках на удивление скромная: Next() увеличивает внутренний индекс и в цикле вызывает оставшиеся звенья среза до конца. Вся «магия порядка» — следствие одного цикла. Код до c.Next() выполняется на входе, до всех последующих звеньев; код после — на выходе, когда хендлер уже отработал и, возможно, что-то написал в ответ. Отсюда форма луковицы: звенья входят по очереди, а выходят в обратном порядке — и gin.Logger именно так замеряет латентность: старт таймера до c.Next(), снятие показаний после. Тот же эффект достижим через defer: отложенная функция сработает на выходе звена даже при панике ниже по цепочке — Recovery построен ровно на defer и recover. Есть тонкость, о которой редко пишут: если middleware не вызовет c.Next() вовсе, цепочка не оборвётся — gin вызовет оставшиеся звенья после возврата из вашего. Но «выход» вы потеряете: код после c.Next() — единственное место, откуда видно, что хендлер уже завершился, поэтому замеры и итоговые логи живут именно там.

### Что напечатает цепочка из двух tracer'ов

```go
r := gin.Default()
r.GET("/ping", tracer("A"), tracer("B"), func(c *gin.Context) {
        fmt.Println("хендлер")
        c.String(http.StatusOK, "ok")
})
```

**Что выведет программа?**

- ✅ A до c.Next() / B до c.Next() / хендлер / B после c.Next() / A после c.Next()
- A до c.Next() / B до c.Next() / хендлер / A после c.Next() / B после c.Next()
- хендлер / A до c.Next() / B до c.Next() / A после c.Next() / B после c.Next()
- A до c.Next() / хендлер / A после c.Next() / B до c.Next() / B после c.Next()

Вход в луковицу слева направо: A печатает «до» и уходит в c.Next(), то же делает B, затем исполняется хендлер. Выход — строго в обратном порядке: первым из луковицы выходит последний вошедший, поэтому «после» печатает B раньше A. Тело хендлера всегда зажато между «до» первого звена и «после» того же звена.

### c.Abort(): прерывание цепочки

Abort() — единственный способ остановить цепочку вызовов: он присваивает внутреннему индексу константу abortIndex (63), и ни один последующий вызов уже не находит звеньев. Документация подчёркивает неудобную деталь: Abort не останавливает текущее звено — строки после него в той же функции продолжат исполняться, поэтому после Abort почти всегда нужен return. Проверить состояние можно c.IsAborted(). Вокруг Abort собрано семейство помощников: AbortWithStatus пишет статус с заголовками, AbortWithStatusJSON — это Abort плюс c.JSON из [прошлого урока](/courses/gin/lessons/05-json-otvety/), AbortWithError ещё и кладёт ошибку в c.Errors, откуда её потом подхватит Logger. У цепочки есть технический предел: суммарное число звеньев обязано быть меньше 63, иначе combineHandlers паникует с сообщением too many handlers — страховка от случайно зацикленного Use. И обратная ловушка: если в authGate просто вернуть return без Abort, цепочка продолжится — listTodos выполнится без авторизации, ведь gin вызовет его сразу после выхода из вашего звена. Отказ — это всегда Abort плюс return, а не только return.

### Проверка: Abort и текущая функция

**Вопрос:** В middleware вызвали c.Abort() и забыли return. Что произойдёт с оставшимися строками этого же звена?

- ✅ Исполнятся: Abort не прерывает текущее звено, нужен return
- Пропустятся: Abort выходит из функции, как return
- Паника: Abort нельзя вызывать дважды в одном запросе
- Исполнятся только вызовы c.Next(), остальное пропустится

Abort лишь присваивает индексу abortIndex: следующие звенья не вызовутся, но текущая функция работает до конца. Классическая ошибка — Abort, а за ним нечаянный c.JSON или запись в лог: ответ уже отдан, а код после него живёт своей жизнью. Отсюда идиома AbortWithStatusJSON плюс return одной строкой привычки.

### authGate: c.Set, c.Next и выход из луковицы

```go
// User — кто делает запрос; authGate кладёт его в контекст.
type User struct {
        Name string
}

// authGate — пускает дальше или отвечает 401 с JSON-телом.
func authGate(c *gin.Context) {
        token := c.GetHeader("Authorization")

        if token == "" {
                // Прерываем цепочку и сразу пишем ответ.
                c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                        "error": "требуется заголовок Authorization",
                })
                return // без return отработают и строки ниже
        }

        // Пишем пользователя в контекст для следующих звеньев.
        c.Set("user", User{Name: "Аня"})
        c.Next() // listTodos отработает внутри этого вызова

        // Выход из луковицы: хендлер уже завершился.
        fmt.Println("ответ ушёл, пользователь:", c.MustGet("user"))
}

func listTodos(c *gin.Context) {
        // Get возвращает (any, bool): приводим тип сами.
        v, ok := c.Get("user")
        if !ok {
                // authGate не стоял в цепочке — страхуемся.
                c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                        "error": "нет пользователя в контексте",
                })
                return
        }

        user := v.(User)
        c.JSON(http.StatusOK, gin.H{
                "user":  user.Name,
                "todos": []Todo{{ID: 1, Title: "разобрать c.Next", Done: false}},
        })
}
```

Маршрут тот же, что в тизере прошлого урока: r.GET("/todos", authGate, listTodos). С заголовком Authorization authGate пишет User в контекст, c.Next() запускает listTodos, тот собирает ответ из контекста; после возврата хендлера печатается строка на выходе звена — MustGet здесь оправдан, authGate гарантированно отработал раньше. Без заголовка клиент получает 401 и тело {"error":"требуется заголовок Authorization"} — до listTodos дело не доходит.

### c.Set и c.Get: обмен данными между звеньями

Под капотом — карта Keys с мьютексом: Set лениво создаёт её и записывает пару ключ-значение, Get возвращает (value, exists). Ключ и значение имеют тип any: Gin не заставляет регистрировать типы, но и не проверяет их — вытащенное значение приводится через type assertion, и форма v, ok := c.Get(...) безопаснее голой. Для уверенных мест есть MustGet, паникующий на отсутствующем ключе, и типизированные помощники GetString, GetBool и другие — они тихо вернут нулевое значение, если ключа нет. Время жизни — ровно один запрос: Keys обнуляется при переиспользовании Context из пула, поэтому контекст — не кеш сессий и не хранилище конфига. Для передачи контекста в горутину берите c.Copy(): копия защищена от дальнейших изменений и уже «прервана» — из неё нельзя запустить цепочку. Соглашения о наборе ключей, кто пишет и кто читает, — тема урока 07-kastomnye-middleware; здесь важен сам механизм.

### Проверка: c.Get на пустом контексте

**Вопрос:** Хендлер вызывает v, ok := c.Get("user") — ни одно звено не писало ключ "user". Что в переменных?

- ✅ v == nil, ok == false: ключа нет, паники не будет
- v == "", ok == false: строка обнуляется по умолчанию
- Паника: Get требует, чтобы ключ существовал
- Ошибка компиляции: Get возвращает только одно значение

Get возвращает (any, bool): отсутствующий ключ даёт (nil, false) — безопасно. Паникует соседний MustGet, аGetString и другие типизированные помощники на пропуске вернут нулевое значение типа. Поэтому важно различать «ключа нет» и «значение нулевое» — по-честному это различает только ok-форма.

### gin.Default(): New плюс Logger и Recovery

В исходниках gin.Default() — две строки: gin.New(), затем engine.Use(Logger(), Recovery()). Встроенных middleware в ядре ровно два: если где-то видели RequestID, CORS или gzip «из коробки», это gin-contrib и сторонние пакеты, а не gin.New. Чистый движок New не содержит ни одного звена, но при этом у него уже выставлены настройки по умолчанию — редиректы trailing slash, доверие к X-Forwarded-For, буфер для multipart. Default при старте печатает предупреждение [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached — то самое, что мелькало в логах ещё на [первом сервере](/courses/gin/lessons/02-pervyj-server/). Порядок аргументов Use(Logger(), Recovery()) не случайность: Logger ставится первым, Recovery вторым, и к концу урока станет ясно, что поменять их местами — значит потерять логи паник.

### gin.Logger: анатомия строки лога

Строка вида [GIN] 2025/06/01 - 15:04:05 | 200 | 341µs | 127.0.0.1 | GET "/todos" печатается звеном gin.Logger — это и есть тот самый gin logger, который виден в каждом туториале. Работает он через луковицу: запоминает time.Now() до c.Next(), а после собирает структуру LogFormatterParams и печатает её в gin.DefaultWriter — по умолчанию os.Stdout. StatusCode берётся из c.Writer.Status() — то есть уже финальный, каким его сделал любой ответ, включая AbortWithStatusJSON; Latency — разница таймеров; ClientIP — c.ClientIP() с учётом доверенных прокси; ErrorMessage — строка из c.Errors приватного типа; BodySize — сколько байт ушло клиенту. Если в URL была query-строка, она дописывается к пути — и токен из ?token= оседает в логах. Латентность перед печатью аккуратно обрезается: больше минуты — до десятков секунд, больше секунды — до десятков миллисекунд, больше миллисекунды — до десятков микросекунд. Цвета: 2xx зелёным, 4xx жёлтым, 5xx красным; GET синим, POST бирюзовым, DELETE красным — но только в терминале: при выводе в файл проверка isatty выключает ANSI-коды, и лог остаётся плоским текстом.

### Своя строка лога: LoggerWithFormatter и SkipPaths

```go
r := gin.New()

// Свой формат: LogFormatterParams несёт всё собранное о запросе.
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
        // Ошибки из c.Errors попадают в строку сами.
        return fmt.Sprintf("%s %s -> %d за %v\n",
                param.Method, param.Path, param.StatusCode, param.Latency)
}))

// /health в лог не нужен: путь должен совпасть точно.
r.Use(gin.LoggerWithWriter(os.Stdout, "/health"))

// Query-строка по умолчанию дописывается к пути; чтобы не светить
// токены: gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})

r.GET("/health", func(c *gin.Context) {
        c.String(http.StatusOK, "ok")
})
r.Run(":8080")
```

Форматтер — функция от LogFormatterParams: вернуть нужно готовую строку, и перевод строки в конце обязателен — fmt.Fprint его не добавляет. LoggerWithWriter(out, notlogged...) принимает пути, которые не логируются: сравнение точное, /health будет молчать, а /health/ — уже нет. Полный набор настроек живёт в LoggerConfig: Output, Formatter, SkipPaths, SkipQueryString и свежий Skip — функция-фильтр, решающая по контексту, логировать ли запрос.

### Что Logger напишет в поле пути

```go
r := gin.Default() // SkipQueryString не трогали

r.GET("/search", func(c *gin.Context) {
        c.String(http.StatusOK, "ok")
})

// curl "localhost:8080/search?q=gin&page=2"
```

**Что выведет программа?**

- ✅ [GIN] ... | GET "/search?q=gin&page=2" — query дописан к пути
- [GIN] ... | GET "/search" — Logger всегда логирует только путь
- Строки лога не будет: GET с query-строкой не логируется
- [GIN] ... | GET "/search" q=gin page=2 — query печатается отдельным полем

По умолчанию SkipQueryString равен false, и raw query приклеивается к пути: "/search?q=gin&page=2". Отдельного поля для query в LogFormatterParams нет — только Path, уже склеенный. Секретные параметры остаются в логах как есть; отключается это SkipQueryString: true в LoggerConfig, и тогда печатается голый путь.

### gin.Recovery: паника не убивает сервер

Документация Recovery звучит буднично: middleware, который восстанавливается после любых паник и пишет 500, если паника была. Устройство — ровно то, что мы разобрали на луковице: звено регистрирует defer с recover() и лишь затем зовёт c.Next(). Паника в любом более глубоком звене раскручивает стек ровно до этого defer. Дальше два пути. Обычный: в DefaultErrorWriter (по умолчанию os.Stderr) уходит строка [Recovery] <время> panic recovered: с дампом запроса, значением паники и стеком, где видны файл, строка и даже исходный код; после этого Recovery вызывает defaultHandleRecovery — ошибка через c.Error оседает в c.Errors, а клиент получает AbortWithStatus(http.StatusInternalServerError): только статус, тело пустое. Отдельная ветка — обрыв соединения (EPIPE, ECONNRESET): писать 500 в мёртвый сокет бессмысленно, Recovery лишь кладёт ошибку в c.Errors и делает Abort. Дамп запроса при этом аккуратен: заголовок Authorization маскируется звёздочкой, чтобы токен не уехал в логи.

### panic в хендлере: что видят клиент и консоль

```go
r := gin.Default() // Recovery уже в цепочке

// GET /boom падает: деление на ноль — классическая паника рантайма.
r.GET("/boom", func(c *gin.Context) {
        var zero int
        _ = 100 / zero // паника: integer divide by zero
        c.JSON(http.StatusOK, gin.H{"ok": true}) // сюда не дойдём
})

r.GET("/ping", func(c *gin.Context) {
        c.String(http.StatusOK, "pong")
})
```

curl -i localhost:8080/boom вернёт HTTP/1.1 500 с пустым телом: AbortWithStatus пишет только статус и заголовки. В консоли — [Recovery] panic recovered: значение паники (integer divide by zero), дамп запроса и стек до строки хендлера. Соседний /ping продолжает отвечать: recover съел панику на уровне звена, процесс не пострадал. Свой формат ошибки клиенту — CustomRecovery с функцией-обработчиком вместо дефолтной.

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

**Вопрос:** Хендлер вызвал panic("boom") при движке gin.Default(). Что получит клиент?

- ✅ Статус 500 с пустым телом, сервер продолжает работать
- Статус 500 и JSON {"error":"boom"} — Recovery сериализует панику
- Обрыв соединения без статуса: так gin отвечает на панику
- Процесс падает: паника в хендлере не перехватывается

Recovery ловит панику и вызывает defaultHandleRecovery: c.Error плюс AbortWithStatus(500) — тело не пишется. JSON-обёртку даёт только CustomRecovery с вашим обработчиком. Обрыв без статуса — это поведение без Recovery, когда панику ловит net/http; процесс и с Recovery, и без него жив.

> **Recovery — страховка, а не обработчик ошибок.** Без Recovery панику ловит уже net/http: он пишет в лог http: panic serving 127.0.0.1:xxxx, закрывает соединение и продолжает жить — клиент видит обрыв без всякого статуса. Recovery не защищает процесс (тот не падает и без него), он превращает панику в честный 500 со стеком. Отсюда моё правило: паника — не ветка бизнес-логики. Ошибки валидации и сервиса возвращайте значениями, как ShouldBindJSON из [урока 04](/courses/gin/lessons/04-binding/), который сам делает AbortWithError(400); Recovery держите как подстраховку от чужих багов и ошибок рантайма, а не как штатный error-handler.

### Порядок звеньев: Use до маршрутов и Logger перед Recovery

combineHandlers склеивает цепочку в момент регистрации маршрута: всё, что добавлено через Use до r.GET, войдёт в его срез звеньев; middleware, объявленное после, маршрут не увидит. Правило простое — сначала Use, потом маршруты; для групп то же самое: g.Use(...) до g.GET(...). Внутри цепочки порядок не декорация. Default ставит Logger перед Recovery не случайно: паникует хендлер — Recovery, звено ниже, ловит её defer'ом, ставит 500 — и когда исполнение возвращается в Logger, тот логирует уже финальный статус и сообщение ошибки из c.Errors. Переверните порядок: Recovery окажется выше, Logger ниже, паника пролетит сквозь кадр Logger'а, не дав выполниться коду после c.Next(), — строка лога на этот запрос не появится вовсе. Запрос восстановит всё тот же Recovery, но след в журнале потерян. Держите Logger первым: он обязан видеть всё, включая 500.

### Соберите луковицу запроса GET /todos

Цепочка: gin.Default() плюс r.GET("/todos", authGate, listTodos), заголовок Authorization передан. Разложите события в порядке исполнения.

Правильный порядок строк:

1. `Logger: start := time.Now()`
2. `Logger: вызов c.Next() — цепочка продолжается`
3. `Recovery: defer с recover() зарегистрирован`
4. `Recovery: вызов c.Next()`
5. `authGate: заголовок Authorization найден`
6. `authGate: c.Set("user", user)`
7. `authGate: вызов c.Next()`
8. `listTodos: c.JSON(200, todos) — ответ написан`
9. `authGate: код после c.Next() — выход из звена`
10. `Recovery: recover() вернул nil, defer отпущен`
11. `Logger: Latency снята, ErrorMessage пуст`
12. `Logger: строка [GIN] ... | 200 напечатана`

### Abort-семейство: что внутри и когда брать

| Метод | Что делает внутри | Когда брать |
| --- | --- | --- |
| c.Abort() | index = abortIndex: следующие звенья не вызываются, тело не пишется | ответ за вас напишет кто-то другой или он вообще не нужен |
| c.AbortWithStatus(code) | Status + WriteHeaderNow + Abort | отказ без тела: редкие 403/429 с пустым ответом |
| c.AbortWithStatusJSON(code, obj) | Abort, затем c.JSON | штатные ошибки middleware с телом: 401, 403, 429 |
| c.AbortWithStatusPureJSON(code, obj) | Abort, затем PureJSON без экранирования < > & | как выше, когда важны точные байты (урок 05) |
| c.AbortWithError(code, err) | AbortWithStatus плюс c.Error в c.Errors | когда отказ должен дойти до Logger и мониторинга |

### Допишите authGate

Восстановите запись в контекст, передачу хода и abort-ответ с JSON-телом.

```go
func authGate(c *gin.Context) {
        token := c.GetHeader("Authorization")
        if token == "" {
                c.___(http.StatusUnauthorized, gin.H{"error": "нет токена"})
                return
        }

        c.___("user", User{Name: "Аня"})
        c.___()
}
```

Подсказка: Отказ с JSON-телом — AbortWithStatusJSON; запись в контекст — c.Set; передача хода по цепочке — c.Next.

Правильные ответы в пропусках: `AbortWithStatusJSON`, `Set`, `Next`

### Пять правил встроенных звеньев

- middleware — это HandlerFunc: та же сигнатура func(*gin.Context), роль задаёт позиция в цепочке
- код до c.Next() — вход, код после — выход; замеры латентности живут только после
- Abort не прерывает текущее звено: за ним всегда return
- Use до регистрации маршрутов — иначе звено не попадёт в цепочку
- c.Set и c.Get живут ровно один запрос; для горутин — c.Copy()

### Карточки: встроенные middleware Gin

- **HandlerFunc** — func(*gin.Context): хендлер и middleware — один тип. Роль определяет позиция в цепочке вызовов, собираемой combineHandlers при регистрации маршрута.
- **c.Next()** — Вкладывает исполнение остатка цепочки внутрь звена: код до — на входе, после — на выходе, в обратном порядке (луковица). Без него звенья всё равно вызовутся, но «выхода» не будет.
- **c.Abort()** — index = abortIndex (63): следующие звенья не вызываются. Текущую функцию не прерывает — нужен return. Проверка — c.IsAborted().
- **gin.Default()** — New() + Use(Logger(), Recovery()). Встроенных middleware в ядре ровно два; CORS, gzip, RequestID — из gin-contrib и сторонних пакетов.
- **gin.Logger()** — Таймер до c.Next(), после — LogFormatterParams: StatusCode, Latency (с обрезкой), ClientIP, Method, Path с ?query, ErrorMessage из c.Errors, BodySize. Цвета только в терминале (isatty).
- **gin.Recovery()** — defer recover до c.Next(): в DefaultErrorWriter (stderr) уходит [Recovery] panic recovered со стеком; клиенту — 500 без тела. Обрыв соединения (EPIPE) — только c.Error и Abort. Своё поведение — CustomRecovery.

> **Проверьте строку лога глазами.** Соберите примеры урока в main.go и постучите curl -i localhost:8080/boom: увидите и 500 с пустым телом, и [Recovery]-стек в консоли, и отсутствие строки для /health при SkipPaths. В браузерной песочнице сайта сетевой код не исполняется — как и весь [курс gin](/courses/gin/), эти примеры живут в локальном go run; интерактивы здесь — quiz, predict, fill, order и флешкарты. Пара минут с curl даёт больше, чем третий перечитанный абзац.

## Коротко: что запомнить

- Middleware — это HandlerFunc: func(*gin.Context); цепочка собирается combineHandlers из звеньев Use и звеньев маршрута и исполняется слева направо.
- c.Next() вкладывает остаток цепочки внутрь звена: код до — вход, после — выход в обратном порядке; без Next звенья всё равно вызовутся, но «выхода» не будет.
- c.Abort() обнуляет дальнейшую цепочку (index = abortIndex), но не прерывает текущую функцию — за Abort нужен return; abort-семейство добавляет статус, JSON и ошибку в c.Errors.
- c.Set/c.Get — карта Keys на один запрос: Get возвращает (any, bool), MustGet паникует, типизированные помощники дают нулевое значение; для горутин — c.Copy().
- gin.Default() = New() + Use(Logger(), Recovery()); встроенных middleware в ядре два, остальное — gin-contrib.
- gin.Logger меряет латентность вокруг c.Next() и печатает статус, латентность, IP, метод и путь с ?query; свои форматы — LoggerWithFormatter, отключение путей — SkipPaths.
- gin.Recovery ловит панику defer'ом: [Recovery] panic recovered со стеком в stderr, клиенту 500 без тела; Logger ставят перед Recovery, чтобы логировать итоговый статус.

## Домашнее задание

Соберите движок на gin.New(): Logger с SkipPaths для /health, Recovery и authGate, который пишет c.Set("user", ...) и отвечает 401 без токена. Добавьте /boom с делением на ноль и убедитесь curl -i: статус 500 с пустым телом, [Recovery]-стек в консоли, /health в логах молчит. Затем переверните порядок на Use(Recovery(), Logger()) и найдите, какая строка пропала на паникующем запросе.

## Вопросы и ответы по уроку

### Обязательно ли вызывать c.Next() в middleware?

Нет: без него gin вызовет оставшиеся звенья сразу после возврата из вашего. Но тогда не будет «выхода» — код после c.Next(), где живут замеры латентности и итоговые логи. Все встроенные middleware вызывают c.Next() явно.

### Что вернёт gin.Recovery клиенту — JSON с ошибкой?

Нет: 500 с пустым телом через AbortWithStatus. Свой формат даёт CustomRecovery с функцией-обработчиком: положите ошибку в c.Errors и напишите тело сами — например, через c.AbortWithStatusJSON.

### Почему после c.Abort() код всё равно исполняется?

Abort меняет только индекс цепочки: следующие звенья не вызовутся, но текущая функция работает до конца. Выход — return сразу за Abort; проверить, была ли прервана цепочка, можно c.IsAborted().
