---
title: "HTTP-транспорт"
description: "HTTP-слой go-kit: kithttp.NewServer, декодеры и энкодеры, ErrorEncoder и DefaultErrorEncoder, статусы через Failer и связка endpoint'ов с обычным роутером mux."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/04-http-transport/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# HTTP-транспорт

*Курс «go-kit: микросервисный тулкит», урок 4 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/04-http-transport/*

Собрано ядро и домен — осталось выпустить stringsvc в сеть. Транспортный слой — единственное место курса, где легален *http.Request, и в go-kit http он сведён к трём функциям: конструктор kithttp NewServer оборачивает endpoint в http.Handler, декодер читает запрос, энкодер пишет ответ. Ты смонтируешь оба метода на стандартный mux, научишься отдавать честные статусы через Failer и поймёшь, почему сломанный JSON по умолчанию уезжает с 500 — и как это чинить. После урока сервис наконец отвечает curl'у.

### Транспортный слой: три функции вместо фреймворка

Вспомни луковицу из первого урока: транспорт — внешний слой, привязанный к протоколу. В пакете transport/http (в коде его обычно импортируют с алиасом kithttp) транспорт go kit сводится к трем сигнатурам. NewServer(endpoint, decode, encode, options...) принимает endpoint и две функции, возвращая *Server — обычный http.Handler. DecodeRequestFunc — func(context.Context, *http.Request) (interface{}, error): декодер превращает HTTP-запрос в request-структуру, которую ждёт адаптер. EncodeResponseFunc — func(context.Context, http.ResponseWriter, interface{}) error: энкодер сериализует response-структуру в ответ. Всё. Ни роутера, ни миддлварь-цепочки, ни магии привязок: транспорт — это три функции, и ровно они единственные в проекте знают про тело запроса и заголовки. Такой размен — многословность за явность — и есть фирменный стиль go kit http: замени gRPC — поменяются три функции, сервис и endpoint'ы останутся нетронутыми.

### Монтируем stringsvc: NewServer на стандартном mux

```go
import (
        "log"
        "net/http"
        "os"

        "github.com/go-kit/log"      // kitlog
        "github.com/go-kit/kit/endpoint"
        kithttp "github.com/go-kit/kit/transport/http"
)

func main() {
        logger := kitlog.NewJSONLogger(os.Stdout)

        var svc StringService = stringService{}
        upper := makeUppercaseEndpoint(svc)
        count := makeCountEndpoint(svc)

        mux := http.NewServeMux()
        mux.Handle("/uppercase", kithttp.NewServer(
                upper,
                decodeUppercaseRequest, // *http.Request -> UppercaseRequest
                encodeResponse,         // response -> JSON
        ))
        mux.Handle("/count", kithttp.NewServer(
                count,
                decodeCountRequest,
                encodeResponse,
        ))

        logger.Log("msg", "listening", "addr", ":8080")
        log.Fatal(http.ListenAndServe(":8080", mux))
}
```

Каждый метод сервиса — свой NewServer со своим декодером, энкодер общий. kithttp.NewServer возвращает http.Handler, поэтому роутер остаётся обычным: стандартный mux, gorilla, chi — что нравится. Метод по умолчанию — POST, это учтём в curl-проверке; ограничение методов — забота роутера, не go-kit.

### Как работает NewServer изнутри

Заглянем в server.go пакета transport/http — там меньше сотни строк, и полезно понимать путь запроса. ServeHTTP берёт r.Context(), прогоняет через ServerBefore-функции (о них ниже), затем вызывает декодер: получил request — передал его в endpoint вместе с контекстом; ошибка декодера — сразу в error encoder, endpoint не зовётся. Дальше ответ попадает в энкодер, а его ошибка уходит в ServerErrorHandler — обычно в логгер, потому что заголовки уже уехали и клиенту сказать нечего. Четыре серверных опции дополняют картину: ServerBefore добавляет в контекст значения из запроса — путь, заголовки, auth; ServerAfter срабатывает после ответа — туда лягут метрики и трейсинг в уроках 07–08; ServerErrorEncoder подменяет то, как ошибка превращается в HTTP-ответ; ServerErrorHandler ловит ошибки энкодера. Правило чтения простое: NewServer — это конвейер декодер → endpoint → энкодер с четырьмя хуками по краям, и все хуки транспортные, к домену отношения не имеют.

### Декодер: единственный, кто трогает тело

Декодер получает *http.Request и возвращает request-структуру — вот и весь контракт. Что здесь легально: json.NewDecoder(r.Body).Decode(&req) для POST-тел, r.URL.Query() для GET-параметров, переменные маршрута, если роутер их умеет, заголовки — например, Authorization, который потом уйдёт в контекст. Три практики, которые сэкономят нервы. Первая: тело читается один раз — декодер обязан, а не «может» прочитать r.Body до конца, иначе keep-alive-соединения начнут гнить; json.NewDecoder делает это сама. Вторая: возвращай примитивные структуры ровно того типа, что ждёт адаптер из урока 02 — любой mismatch выстрелит паникой assertion, и это будет твоя вина, не go-kit. Третья: ошибки декодера — это транспортный сбой, они идут по error-каналу и по умолчанию превращаются в 500; кастомизируем это на следующем уроке кодеков. Для GET-эндпоинтов тело пустое — декодер просто собирает структуру из query, и это нормально: декодер — не ритуал JSON, а переводчик HTTP в домен.

### Декодеры для Uppercase и Count

```go
import (
        "context"
        "encoding/json"
        "net/http"
)

// POST /uppercase {"s":"hello"} — читаем тело.
func decodeUppercaseRequest(_ context.Context, r *http.Request) (interface{}, error) {
        var req UppercaseRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
                return nil, err // транспортная ошибка: endpoint не вызовется
        }
        return req, nil
}

// GET /count?s=hello — тело пустое, берём query.
func decodeCountRequest(_ context.Context, r *http.Request) (interface{}, error) {
        s := r.URL.Query().Get("s")
        return CountRequest{S: s}, nil
}
```

Две типовые формы декодера. Uppercase читает JSON-тело; ошибка Decode возвращается наружу — она инфраструктурная. Count собирает запрос из query: декодер не обязан трогать тело. Контекст не используется и назван _ — значения из заголовков сюда добавят ServerBefore-хуки в уроках трейсинга и аутентификации.

### Энкодер: сериализация и Content-Type

Энкодер симметричен: получает response-структуру и пишет её в http.ResponseWriter. Базовая форма — три строки: выставить Content-Type: application/json; charset=utf-8 и вернуть json.NewEncoder(w).Encode(response). Заученная ловушка HTTP здесь прячется в порядке операций: первый же вызов, который пишет в тело, триггерит неявный WriteHeader(200) — после этого менять заголовки бессмысленно, они уже отправлены. Поэтому Set — всегда до Encode, а явный WriteHeader — только если хочешь статус отличный от 200. Ошибка, которую возвращает энкодер, — отдельная история: когда Encode падает (клиент отвалился, срез по конвейеру), заголовки уже ушли, сообщить клиенту нечего — ошибка попадает в ServerErrorHandler и логируется. Статус по умолчанию 200 устраивает счастливый путь; несчастливый — следующий раздел, и там появится таблица codeFrom.

### Энкодер и честные статусы: Failer в действии

```go
import (
        "context"
        "encoding/json"
        "net/http"

        "github.com/go-kit/kit/endpoint"
)

// Счастливый путь: JSON со статусом 200.
func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
        if f, ok := response.(endpoint.Failer); ok && f.Failed() != nil {
                // Бизнес-ошибка: не 500, а честный статус.
                encodeError(ctx, f.Failed(), w)
                return nil
        }
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        return json.NewEncoder(w).Encode(response)
}

func encodeError(_ context.Context, err error, w http.ResponseWriter) {
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.WriteHeader(codeFrom(err))
        json.NewEncoder(w).Encode(map[string]interface{}{"error": err.Error()})
}

func codeFrom(err error) int {
        switch err {
        case ErrEmpty:
                return http.StatusBadRequest // 400
        default:
                return http.StatusInternalServerError // 500
        }
}
```

Канон stringsvc в редакции Failer из урока 02: response проверяется на Failed(), и бизнес-ошибка кодируется отдельно — со статусом из codeFrom, а не пачкой 500. ErrEmpty стал честным 400. Энкодер — последний пункт, где допустимы заголовки и статусы; сервис про HTTP не узнает никогда.

### Ошибки транспорта: три пути к клиенту

Сведём всё, что знает транспорт go kit об ошибках, — тут три принципиально разных маршрута. Первый: ошибка декодера — сломанный JSON, недостающий параметр; она возвращается из DecodeRequestFunc, endpoint не вызывается, а ответ формирует error encoder. По умолчанию работает DefaultErrorEncoder: статус 500 и текст ошибки в text/plain — нечестно для плохого запроса, поэтому следующий урок посвятим собственному ErrorEncoder через опцию ServerErrorEncoder. Второй: бизнес-ошибка из response-структуры — Failed() != nil; её ловит наш encodeResponse и кодирует через encodeError со статусом из codeFrom. Третий: err endpoint'а — инфраструктурный сбой; он тоже уходит в error encoder, и это правильно: база недоступна — клиенту честный 500, а breaker'у на клиентской стороне — сигнал. Сравни с gin, где c.JSON и c.Abort размазаны по хендлерам: здесь кодирование ошибки — функция с таблицей статусов, единая для всех эндпоинтов. Такая дисциплина делает поведение API предсказуемым: одинаковые ошибки — одинаковые ответы на любом транспорте.

### Куда уходит ошибка: путь к статусу

| Ошибка | Пример | Кто ловит | Что получит клиент |
| --- | --- | --- | --- |
| Бизнес, известная | ErrEmpty из сервиса | encodeResponse через Failer | 400 и JSON с полем error |
| Бизнес, не смапленная | новый sentinel без ветки в codeFrom | encodeError → default | 500 — добавь ветку в codeFrom |
| Сломанный запрос | EOF от json.Decoder | DefaultErrorEncoder | 500 и text/plain — лечится ErrorEncoder (урок 05) |
| Инфраструктурный сбой | база недоступна: err endpoint'а | DefaultErrorEncoder | 500 — честный сигнал breaker'у |
| Ошибка энкодера | Encode упал на отвалившемся клиенте | ServerErrorHandler → лог | обрубленный ответ — клиенту нечего сказать |
| Паника | программистская ошибка | процесс падает громко | соединение рвётся — канон урока 01 |

### Роутер остаётся твоим

NewServer возвращает http.Handler — значит, go-kit не отбирает маршрутизацию, и это решение FAQ, а не случайность. Стандартный http.NewServeMux тянет простые проекты; gorilla/mux и chi добавляют переменные пути и middleware уровня роутера; gin можно смонтировать обёрткой его хендлера, если команда уже на нём живёт. Два практических замечания. Методы: mux.Handle не ограничивает метод — POST/GET различаются на уровне роутера (gorilla: r.Methods("POST")), иначе декодер JSON встретит GET с пустым телом и честно вернёт ошибку декодера. Переменные пути: если нужен /profiles/{id}, бери роутер с параметрами и передавай их в request-структуру из декодера — домен и endpoint по-прежнему не видят URL. Пара NewServer + чужой роутер — типовой гибрид больших проектов: go-kit отвечает за слои и обёртки, роутер — за маршруты. В уроке 13 тот же endpoint посадим на gRPC, и станет видно, зачем всё это разделение.

### ServerBefore и ServerAfter: хуки контекста

Осталось два хука, на которые в этом уроке только смотрим. ServerBefore принимает RequestFunc — функцию func(ctx context.Context, r *http.Request) context.Context — и вызывается до декодера: здесь берут из запроса то, что относится к условиям вызова, а не к данным — заголовок Authorization, X-Request-ID, путь. Значение кладётся в контекст с ключом и едет вниз через endpoint в сервис. ServerAfter — зеркальный хук после ответа: func(ctx, response) — туда ложатся метрики и трейсы. Зачем это знать сейчас: чтобы не соблазниться положить данные запроса в декодере в ctx — данные едут в request-структуре, условия вызова — в контексте через ServerBefore. Практика появится по делу: идентификатор запроса — в уроке 06 про логи, спаны трейсинга — в 08, JWT — в 23. Сегодня достаточно прочитать опции в сигнатуре NewServer и не бояться их.

### Чеклист транспортного слоя

1. На каждый endpoint — свой kithttp.NewServer; декодер уникален, энкодер общий.
2. Декодер — единственный читатель r.Body, query и заголовков; ошибки декодера — транспортные.
3. Content-Type и статусы выставляются только в энкодере и encodeError, до первого Write.
4. Бизнес-ошибки кодируются через Failer и таблицу codeFrom, а не все подряд 500.
5. Роутер внешний: методы и переменные пути — его забота; NewServer отдаёт http.Handler.
6. Ни одна транспортная сущность не проходит дальше декодера: *http.Request в endpoint не везут.

> ***http.Request не проходит дальше декодера.** Если увидел *http.Request в endpoint-адаптере или тем более в сервисе — архитектура треснула. Урок 01 строил транспорт-агностичное ядро не для красоты: gRPC-версия из урока 13 переиспользует endpoint'ы и сервис только потому, что ни один слой ниже декодера не знает про net/http. Контрольный вопрос при ревью: «смогу ли я посадить этот endpoint на второй транспорт, не меняя ни строки ниже?»

> **Честно о запуске кода.** Урок требует установленного Go и модуля github.com/go-kit/kit — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении кода, а не на запуске. Локально: go mod init && go get github.com/go-kit/kit@v0.13.0, потом curl -X POST localhost:8080/uppercase -d '{"s":"hi"}' — и сервис впервые ответит.

**Вопрос:** Что возвращает kithttp.NewServer и куда его монтировать?

- Функцию func(w, r) — повесить на http.HandleFunc
- ✅ *Server, реализующий http.Handler — подходит для mux.Handle и любого роутера
- Собственный роутер go-kit — им и придётся пользоваться
- Горутину — NewServer сам запускает слушатель

NewServer возвращает *Server с методом ServeHTTP — обычный http.Handler. Поэтому он монтируется на стандартный mux, gorilla, chi; роутинг go-kit не отбирает. Никакого слушателя он не запускает — жизненный цикл остаётся за http.ListenAndServe.

**Вопрос:** Сервис вернул ErrEmpty, response-структура реализует Failer, encodeResponse написан по канону. Какой статус увидит клиент?

- 500 — любые ошибки кодируются одинаково
- 200 — Err-поле с omitempty просто не попадёт в JSON
- ✅ 400 — encodeError возьмёт статус из codeFrom
- 204 — тело пустое, когда есть бизнес-ошибка

encodeResponse проверяет Failed() != nil и вызывает encodeError, тот пишет WriteHeader(codeFrom(err)) — для ErrEmpty ветка case даёт http.StatusBadRequest. Клиент получает 400 и JSON {"error":"empty string"}. Без таблицы codeFrom бизнес-ошибки скатывались бы в 500 — и алерты врали бы.

**Вопрос:** Где выставляется Content-Type: application/json в go-kit сервисе?

- В сервисе — он знает формат ответа
- В endpoint-адаптере — он строит response-структуру
- ✅ В энкодере и encodeError — перед первым записывающим вызовом в ResponseWriter
- Нигде — go-kit выставляет его автоматически за все ответы

Заголовки — транспортная забота: их пишет энкодер до json.NewEncoder(w).Encode. После первой записи в тело неявный WriteHeader уже отправил заголовки, и Set бесполезен. Сервис и endpoint про Content-Type не знают — иначе второй транспорт был бы невозможен.

### Сломанный JSON: какой статус по умолчанию?

```go
// POST /uppercase телом "not json"
// Декодер: json.NewDecoder(r.Body).Decode(&req)
// ServerErrorEncoder НЕ настроен
```

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

- 400 — bad request, декодер честно виноват
- 200 с пустым телом — ошибка проглотится
- ✅ 500 и text/plain — сработает DefaultErrorEncoder
- Паника процесса — декодер не умеет в невалидный JSON

Ошибка декодера — транспортная: она уходит в error encoder, а по умолчанию там DefaultErrorEncoder — статус 500 и текст ошибки в text/plain. Нечестно для плохого запроса, поэтому следующим уроком мы напишем свой ErrorEncoder и сведём ошибки декодирования к 400 — в этом уроке важно запомнить дефолт.

### Переставили Set и Encode: дойдёт ли Content-Type?

```go
func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
        json.NewEncoder(w).Encode(response) // сначала тело
        w.Header().Set("Content-Type", "application/json; charset=utf-8") // потом заголовок
        return nil
}
```

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

- Заголовок дойдёт: Set работает в любой момент ответа
- ✅ Заголовок потерян: Encode триггернул неявный WriteHeader(200) раньше Set
- Паника: нельзя писать тело до заголовков
- Encode вернёт ошибку — строку выбросит ServerErrorHandler

Первый записывающий вызов в ResponseWriter триггерит неявный WriteHeader(200) — заголовки уходят в сеть сразу. Set после этого ничего не меняет: Content-Type потерян, клиент угадывает формат. Отсюда канонический порядок: Header().Set, затем WriteHeader при необходимости, затем Encode.

### Восстанови монтаж эндпоинта и энкодер

Собери транспортный слой. Пропуски — по порядку: конструктор из kithttp, метод заголовка, метод энкодера.

```go
mux.Handle("/uppercase", kithttp.___(
        upper,
        decodeUppercaseRequest,
        encodeResponse,
))

func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
        w.Header().___("Content-Type", "application/json; charset=utf-8")
        return json.NewEncoder(w).___(response)
}
```

Подсказка: Первый пропуск — фабрика http.Handler над endpoint'ом; второй — установка заголовка; третий — сериализация структуры в поток.

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

### Путь запроса через транспорт

Расставь этапы пути POST /uppercase в правильном порядке: от ListenAndServe до отправленного тела.

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

1. `http.ListenAndServe принял соединение и передал mux`
2. `mux нашёл хендлер — Server из kithttp.NewServer`
3. `ServerBefore-хуки пополнили контекст из запроса`
4. `декодер превратил тело в UppercaseRequest`
5. `endpoint-обёртки встретили запрос снаружи внутрь`
6. `адаптер достал тип и вызвал svc.Uppercase`
7. `UppercaseResponse поднялся обратно через цепочку обёрток`
8. `encodeResponse выставил Content-Type и статус`
9. `json.NewEncoder(w).Encode отправил тело клиенту`

### Термины урока

- **kithttp.NewServer** — Конструктор transport/http: endpoint + декодер + энкодер + опции → *Server, обычный http.Handler для любого роутера.
- **DecodeRequestFunc** — func(ctx, *http.Request) (interface{}, error): единственное место, где легальны r.Body, query и заголовки; ошибка — транспортная.
- **EncodeResponseFunc** — func(ctx, http.ResponseWriter, interface{}) error: Content-Type до Encode, статусы через WriteHeader, ошибки уйдут в ServerErrorHandler.
- **DefaultErrorEncoder** — Дефолтный error encoder: всегда 500 и text/plain. Подменяется опцией ServerErrorEncoder — домашняя территория урока 05.
- **Failer на HTTP** — encodeResponse проверяет Failed() у response и кодирует бизнес-ошибку через encodeError со статусом из таблицы codeFrom.
- **ServerBefore / ServerAfter** — Хуки вокруг конвейера: Before — условия вызова в контекст до декодера (auth, request id), After — работа после ответа (метрики, трейсинг).

### Что дальше

stringsvc отвечает по сети: POST /uppercase и GET /count работают, статусы честные, слои не слиплись. Но дефолты транспорта видны: плохой JSON уезжает с 500, а кодирование ошибок умещается в один case. Следующий шаг — [урок 05-codeki](/courses/go-kit/lessons/05-codeki/): свой ErrorEncoder, аккуратные JSON-ошибки и правила кодеков. Освежить Basis можно в [уроке 02-endpoint](/courses/go-kit/lessons/02-endpoint/) — теперь ты знаешь, кто вызывает адаптеры, — а карта курса лежит в [уроке 01-filosofiya](/courses/go-kit/lessons/01-filosofiya/) и на [странице курса](/courses/go-kit/). Транспорт был последним недостающим слоем — дальше курс надевает на них наблюдаемость.

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

- Транспорт — три функции: NewServer(endpoint, decode, encode, options...) возвращает http.Handler.
- Декодер единственный трогает тело, query и заголовки; его ошибка — транспортная, endpoint не зовётся.
- Энкодер пишет Content-Type до первого Write: неявный WriteHeader(200) срабатывает на первой записи тела.
- Бизнес-ошибки кодируются через Failer и encodeError с таблицей codeFrom: ErrEmpty — 400, не 500.
- Дефолтный error encoder отдаёт 500 text/plain на всё — включая сломанный JSON; свой ErrorEncoder — урок 05.
- Роутер остаётся твоим: mux, gorilla, chi, gin; методы и переменные пути — его забота.
- ServerBefore кладёт условия вызова в ctx до декодера; ServerAfter работает после ответа — территория метрик и трейсинга.

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

Доведи stringsvc до работающего сервиса: смонтируй оба endpoint'а через kithttp.NewServer на стандартном mux, подключи декодеры из урока и общий encodeResponse с encodeError и codeFrom. Проверь curl'ом три сценария и запиши наблюдения: валидный POST /uppercase с телом {"s":"hi"}, GET /count?s=hello через query и POST со сломанным JSON — на последнем поймай дефолтный 500 от DefaultErrorEncoder и объясни себе, почему это нечестно. Затем добавь в codeFrom вторую sentinel-ошибку своего сервиса из домашки урока 03 и убедись, что статус поменялся без правки сервиса. В конце попробуй смонтировать один endpoint на gin или chi — NewServer должен вписаться без изменений в декодерах и энкодерах.

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

### Как смонтировать go-kit endpoint на gin или chi?

NewServer возвращает http.Handler, поэтому chi монтирует напрямую: r.Post("/uppercase", handler). Для gin нужна обёртка его хендлера вокруг handler.ServeHTTP — стандартный приём. Роутер отвечает за методы и параметры пути, go-kit — за слои и декодеры.

### GET с query или POST с JSON — как правильно?

Оба легальны, решает декодер: для GET он собирает request-структуру из r.URL.Query(), для POST — из тела. Разница только в форме декодера; endpoint, сервис и энкодер не меняются. Метод ограничивает роутер, не go-kit.

### Почему сломанный JSON даёт 500, а не 400?

Потому что ошибка декодера попадает в DefaultErrorEncoder, который отвечает 500 и text/plain на любую ошибку. Это дефолт, а не приговор: настроенный ServerErrorEncoder с таблицей статусов сведёт ошибки декодирования к 400 — именно этим займётся урок 05 про кодеки.
