---
title: "Метаданные и контекст"
description: "grpc metadata в Go: metadata.MD и md.Pairs, AppendToOutgoingContext на клиенте, FromIncomingContext на сервере, Bearer-токен, заголовки и trailers стримов."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/10-metadata/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 18
---

# Метаданные и контекст

*Курс «gRPC-Go: высокопроизводительный RPC», урок 10 из 25. ~18 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/10-metadata/*

grpc metadata — это пары «ключ — значение», которые едут с вызовом в заголовках HTTP/2 рядом с protobuf-сообщениями: идентификатор трассировки, язык клиента, лимиты и, конечно, токен авторизации. В этом уроке разбираем grpc metadata системно: metadata.MD как map[string][]string и конструкторы New и md.Pairs, клиентские AppendToOutgoingContext и NewOutgoingContext, серверную FromIncomingContext с ok-формой, строчные ключи, суффикс -bin с base64 для не-ASCII значений — и собираем рабочий сценарий «Bearer-токен в заголовке, проверка в хендлере» на канонных OrderService и ChatService. Заодно — география заголовков и трейлеров: что уезжает при открытии вызова, что при первом сообщении, а что только вместе со статусом.

Девять уроков мы качали данные внутри вызова: protobuf-сообщения летели в обе стороны, и нам хватало их с головой. Но настоящий сервис не живёт одними сообщениями: прежде чем разбирать тело запроса, инфраструктура хочет знать, кто зовёт, на каком языке отвечать, куда писать трассировку и не истёк ли токен доступа. Всё это — метаданные: именованные пары, которые gRPC везёт вместе с вызовом, не попадая ни в одно сообщение. Русскоязычный запрос «метаданные grpc» — ровно про эту механику. В мире HTTP вы называли их заголовками запроса и ответа — здесь они на месте, но с двумя поправками: у ответа порций две (заголовки до сообщений и трейлеры вместе со статусом), а время отправки строго регламентировано — заголовки уезжают при открытии вызова, и позже их не дописать. Сегодня мы разберём metadata.MD как map[string][]string, конструкторы New и Pairs, клиентские AppendToOutgoingContext и NewOutgoingContext, серверную FromIncomingContext, правила ключей — нижний регистр, ASCII, суффикс -bin с base64 — и соберём честную авторизацию: Bearer-токен на клиенте, проверка в хендлере на сервере. Интерцепторы, которые выносят эту проверку из хендлеров, — материал следующего урока, там и продолжим.

### grpc metadata: данные вне сообщений

Метаданные — это сквозные данные вызова: не часть предметной задачи («сделай заказ 42»), а обвязка вокруг неё (кто спрашивает, с какого устройства, в какой локали отвечать, каким requestId логировать). На проводе они едут в том же HEADERS-кадре HTTP/2, что и хозяйство самой библиотеки: :path с FullMethodName из unary-урока, content-type, grpc-timeout, grpc-encoding. Ваши пары библиотека дописывает рядом — отдельного канала «для своих заголовков» не существует, есть общие заголовки вызова. Отсюда важнейшее свойство: grpc metadata — свойство вызова, а не модели вызова. Все четыре модели из таблицы шестого урока — unary, server streaming, client streaming и bidi — передают метаданные одинаково: клиент приклеивает их к контексту до вызова, сервер читает из контекста хендлера. Различий между «unary-метаданными» и «стриминговыми» нет: отличается только то, откуда взять контекст на сервере — из аргумента ctx у unary или из stream.Context() у стримов. Зачем это нужно на практике: трассировка и корреляция (requestId, trace-контекст,tenant), авторизация (Bearer-токен, JWT), локализация (accept-language), канареечная маршрутизация (версия клиента, регион), лимиты и feature-флаги. Правило больших пальцев простое: предметные данные — в сообщениях, сквозные требования инфраструктуры — в метаданных. Положите локаль в protobuf-поле каждого сообщения — и она начнёт дублироваться в логах, кэшах и схемах; положите её в заголовок — и она останется транспортной заботой, единая для всех методов.

### metadata.MD — это map[string][]string

```go
// Метаданные — это map[string][]string: у одного ключа может быть
// несколько значений, поэтому тип — не map[string]string.
md := metadata.Pairs(
        "authorization", "Bearer dev-42",
        "trace", "a", // тот же ключ во второй раз — значение добавится в срез
        "trace", "b", // md["trace"] == []string{"a", "b"}
)
fmt.Println(md.Len()) // 2 — число ключей в map, а не значений

// New строит MD из map[string]string; регистр ключей понижает сам.
md2 := metadata.New(map[string]string{"Authorization": "Bearer dev-42"})
fmt.Println(md2.Get("authorization")) // [Bearer dev-42]

// Get сам приводит ключ к нижнему регистру и возвращает срез значений.
fmt.Println(md.Get("TRACE"))      // [a b]
fmt.Println(md.Get("нет-такого")) // [] — пустой срез, ошибки нет

// Set заменяет список значений, Append дописывает к существующему.
md.Set("trace", "c")    // md["trace"] == []string{"c"}
md.Append("trace", "d") // md["trace"] == []string{"c", "d"}

// Join склеивает несколько MD в один, порядок значений — по порядку слагаемых.
md3 := metadata.Join(md, md2)
```

Сверка с metadata/metadata.go: «type MD map[string][]string» с рекомендацией «Users should use the following two convenience functions New and Pairs to generate MD». Pairs — вариадик «ключ, значение, ключ, значение...» и паникует на нечётном числе аргументов: «metadata: Pairs got the odd number of input pairs for metadata: %d» — ловится ещё на вашей машине, до сети. Повторный ключ не перезаписывает, а удлиняет срез, сохраняя порядок пар. Len() возвращает len(md) — число уникальных ключей, а не значений: в примере их два, хотя значений четыре. Get понижает регистр перед поиском и возвращает срез (пустой, если ключа нет), Set заменяет список целиком, Append дописывает «not overwriting what was already stored at that key», Delete удаляет ключ, Copy делает независимую копию, Join склеивает несколько MD. И принципиальная деталь: ни New, ни Pairs НЕ проверяют значения — вообще никак: кириллица, эмодзи, нулевые байты спокойно лягут в map. Проверка печатаемости случится позже, при отправке, — об этом отдельный блок ниже. Ещё одно правило из доки NewOutgoingContext: «md must not be modified after calling this function» — MD, приклеенный к контексту, становится read-only: соберите его до вызова и больше не трогайте.

### Ключи: всегда строчные и по строгим правилам

Первая дисциплина метаданных — регистр. И New, и Pairs понижают ключи к нижнему регистру автоматически, и дока пакета metadata формулирует это прямо: «all the keys will be automatically converted to lowercase, so «key1» and «kEy1» will be the same key and their values will be merged into the same list». Это не прихоть Go-пакета: HTTP/2 требует строчных имён заголовков на проводе, и gRPC просто приводит мир к этому правилу заранее. Вторая дисциплина — алфавит. Дока New и Pairs перечисляет допустимые символы ключей: цифры 0-9, буквы (заглавные нормализуются), и три специальных символа -_. Всё остальное при отправке будет отвергнуто с текстом «header key %q contains illegal characters not in [0-9a-z-_.]» — подчёркивание, дефис и точка, никаких пробелов, двоеточий и кириллицы в ключах. Третья — резерв: ключи с префиксом grpc- зарезервированы за библиотекой (дока New и Pairs: «Keys beginning with "grpc-" are reserved for grpc-internal use only and may result in errors if set in metadata»), потому что там уже живёт grpc-timeout, grpc-encoding, grpc-status и компания. Четвёртая — поиск. У MD есть метод Get, который сам понижает регистр, но прямое обращение md["Authorization"] ищет ключ ДОСЛОВНО в map — и не находит: map не знает про ваши договорённости. На сервере есть и второй инструмент — metadata.ValueFromIncomingContext(ctx, key): читает напрямую из контекста, сравнивая ключи без учёта регистра. И одна честная проверка по исходникам, чтобы уберечь от статей, написанных по памяти: функции metadata.GetRequestHeader в grpc-go не существует — ближайшие настоящие инструменты это md.Get, ValueFromIncomingContext и GetRequestMetadata у интерфейса PerRPCCredentials, о котором поговорим в блоке про токены. Сверяйте имена с pkg.go.dev, а не с чужих конспектов.

### Клиент: AppendToOutgoingContext поверх контекста

```go
// cmd/client/main.go — метаданные приклеиваются к контексту вызова.
func main() {
        conn, err := grpc.NewClient("localhost:50051",
                grpc.WithTransportCredentials(insecure.NewCredentials()))
        if err != nil {
                log.Fatalf("канал: %v", err)
        }
        defer conn.Close()
        client := pb.NewOrderServiceClient(conn)

        ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
        defer cancel()

        // Канонический способ: дописать пары прямо в контекст.
        // Нечётное число аргументов — паника ещё до сетевого вызова.
        ctx = metadata.AppendToOutgoingContext(ctx,
                "authorization", "Bearer dev-42",
                "x-request-id", "req-7",
        )

        // Дальше — обычный вызов: метаданные уедут с его заголовками.
        order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
        if err != nil {
                log.Fatalf("GetOrder: %v", err)
        }
        fmt.Println(order.GetId(), order.GetStatus())
}
```

Официальная дока по метаданным формулирует выбор прямо: «The recommended way is to append kv pairs to the context using AppendToOutgoingContext. This can be used with or without existing metadata on the context. When there is no prior metadata, metadata is added; when metadata already exists on the context, kv pairs are merged in» — поэтому вызывать её можно многократно, каждая дописывает к накопленному (интерцепторы в следующем уроке будут на этом строиться). Нечётное число аргументов — паника «metadata: AppendToOutgoingContext got an odd number of input pairs for metadata: %d», ключи тоже приводятся к нижнему регистру. Альтернатива — metadata.NewOutgoingContext(ctx, md): она ЗАМЕНЯЕТ метаданные в контексте, и дока предупреждает: «If used in conjunction with AppendToOutgoingContext, NewOutgoingContext will overwrite any previously-appended metadata» — плюс честно признаётся, что этот путь медленнее Append-варианта. Если MD собран из двух кусков, их склеивает metadata.Join: ctx = metadata.NewOutgoingContext(ctx, metadata.Join(старый, новый)) — это официальный рецепт из документации. И проверка по исходникам, которую стоит запомнить: функции metadata.JoinOutgoingContext в grpc-go нет ни в одной версии — если встретили её в статье, склейкой занимается Join. Момент приклейки важен: метаданные читаются при открытии вызова, поэтому всё должно быть в ctx ДО client.GetOrder — после открытия дописать в заголовки уже нельзя, у стримов это правило особенно строгое.

### Проверка: регистр ключей

**Вопрос:** Клиент отправил metadata.Pairs("Authorization", "Bearer dev-42"). Сервер сделал прямой доступ к map: md["Authorization"]. Что он получит?

- []string{"Bearer dev-42"} — регистр не важен, ключ найдётся
- ✅ nil — ключи в MD всегда хранятся в нижнем регистре, а прямой доступ к map регистр не нормализует; нужен md["authorization"] или md.Get
- Панику: обращения к несуществующему ключу map паникуют
- []string{} — ключ есть, но список значений пуст

Map в Go на отсутствующем ключе возвращает нулевое значение — nil, без паники. Вся соль в нормализации: Pairs записал ключ под именем authorization (нижний регистр), а дока New и Pairs обещает: «all the keys will be automatically converted to lowercase». Прямое индексирование md["Authorization"] ищет в map дословно и находит nil; md.Get("Authorization") сработал бы, потому что Get понижает регистр перед поиском. Правило: с MD либо работайте в нижнем регистре, либо через Get/ValueFromIncomingContext — привычка писать md["authorization"] экономит часы отладки.

### Сервер: FromIncomingContext и её ok-форма

На сервере входящие метаданные лежат в контексте вызова, и достаёт их metadata.FromIncomingContext(ctx) — функция возвращает пару (MD, bool): эта ok-форма из документации означает «метаданные есть, вот они» либо «метаданных в контексте нет вообще». Для unary-хендлера контекст — родной аргумент ctx; для стримов — stream.Context(): в девятом уроке мы разбирали, что у стримингового хендлера нет ни req, ни ctx, и контекст достаётся из стрима — FromIncomingContext(stream.Context()) работает для ChatTickets ровно так же, как для GetOrder. Дока функции обещает: «returns the incoming metadata in ctx if it exists. All keys in the returned MD are lowercase» — регистр нормализуется даже для MD, собранного вручную не через New/Pairs. Важный нюанс: вы получаете копию — функция копирует каждый срез значений, поэтому мутировать возвращённый map бессмысленно и опасно; собирайте собственные MD через Copy. Когда ok=false? На практике почти никогда: сетевой вызов всегда несёт хотя бы служебные заголовки; зато в юнит-тестах, где хендлер зовут с голым context.Background(), FromIncomingContext честно вернёт false — и это второй повод обрабатывать ok-ветку, а не игнорировать её. Для тестов же предназначена парная metadata.NewIncomingContext(ctx, md): приклейте MD к контексту и зовите хендлер без сети — приём для уроков про тестирование. Наконец, дисциплина: дока по метаданным прямо запрещает читательский трюк с контекстом не в ту сторону — «Do not use FromOutgoingContext on the server to write metadata to be sent to the client. FromOutgoingContext is for client-side use only»: Outgoing — клиентская пара (AppendTo/NewOutgoing + FromOutgoing), Incoming — серверная (FromIncoming). Перепутаете — на сервере будет пусто, а на клиенте чтение станет иллюзией.

### Сервер: Bearer-токен и проверка в хендлере

```go
// internal/server/order.go — проверка токена в хендлере GetOrder.
func (s *OrderServiceServer) GetOrder(
        ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {

        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
                // Метаданных нет вообще: тест с голым контекстом
                // или вызов мимо gRPC-стека. Не представился — отказ.
                return nil, status.Error(codes.Unauthenticated, "нет метаданных")
        }
        // Ключи всегда в нижнем регистре: ищем "authorization".
        toks := md.Get("authorization")
        if len(toks) == 0 || !strings.HasPrefix(toks[0], "Bearer ") {
                return nil, status.Error(codes.Unauthenticated, "нет Bearer-токена")
        }
        token := strings.TrimPrefix(toks[0], "Bearer ")
        if token != s.devToken { // в проде тут проверка подписи JWT
                return nil, status.Error(codes.PermissionDenied, "токен отклонён")
        }
        // Токен признан: работаем дальше.
        return s.repo.Order(ctx, req.GetId())
}
```

Полный серверный сценарий из официальной доки: «md, ok := metadata.FromIncomingContext(ctx)» в unary-хендлере — у стрима то же самое, но со stream.Context(). Обратите внимание на два разных отказа: Unauthenticated — клиент не представился или испортил форму токена (нет заголовка, нет префикса Bearer), PermissionDenied — представился, но прав нет (подпись не сошлась). Это не синонимы: первый говорит «повтори с валидным токеном», второй — «с этим токеном сюда нельзя». md.Get возвращает срез, поэтому берём первый элемент после проверки длины — заголовок Authorization в HTTP традиционно один, но MD готов хранить и несколько. Для стримов из девятого урока не забудьте: метаданные лежат в stream.Context(), и читать их нужно ДО цикла Recv — они не меняются весь вызов. И естественный план на вырост: такая проверка понадобится каждому хендлеру, а значит, место ей не в каждом методе, а в одном месте — в перехватчике, им и займётся одиннадцатый урок.

### Авторизация-токен: почему заголовок, а не поле

Почему токен живёт в metadata, а не в protobuf-поле запроса? Потому что авторизация — сквозная транспортная забота, одинаковая для всех методов и не имеющая никакого отношения к предметной схеме. Добавьте токен в GetOrderRequest — и он появится в схеме, в логах сообщений, в кэшах и в повторах, а для WatchOrders придётся изобретать его снова, хотя подписка сообщения не имеет. Заголовок Authorization с Bearer-схемой — канон HTTP, унаследованный gRPC целиком: строка «Bearer <токен>» означает «носитель этого токена вправе действовать от моего имени» (JWT, opaque-токен — что угодно под капотом). Формат стоит держать точно: сервер проверяет и префикс «Bearer » с пробелом, и сам токен. Второй канал передачи токена — декларативный: интерфейс credentials.PerRPCCredentials с методами GetRequestMetadata(ctx, uri...) (map[string]string, error) и RequireTransportSecurity() bool. Реализуйте его — и передавайте опцией grpc.WithPerRPCCredentials при создании канала: транспорт сам будет прикладывать эти пары к каждому вызову, не трогая ваши контексты; готовая реализация — credentials/oauth.TokenSource, которая достаёт токен из oauth2.TokenSource и кладёт его в authorization, требуя при этом PrivacyAndIntegrity, то есть настоящий TLS. Пригодится и правило RequireTransportSecurity: токен, переданный поверх insecure-канала, читает любой слушающий сеть — в проде метаданные с секретами едут только поверх TLS из двенадцатого урока. И не логируйте метаданные целиком: fmt.Println(md) в хендлере — это утечка токена в логи одной строкой; печатайте отдельные безопасные ключи, а секретные маскируйте.

### Pairs с повторяющимся ключом. Что напечатает код?

```go
md := metadata.Pairs("trace", "a", "trace", "b", "x", "y")

fmt.Println(md.Get("trace"))
fmt.Println(md.Len())
```

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

- ✅ [a b]
2
- [a]
3
- [a b]
3
- [b a]
2

Две механики в одном вопросе. Первая: Pairs не перезаписывает повторяющийся ключ, а дописывает значения в срез в порядке следования пар — дока официального примера иллюстрирует ровно этим: «key1» со значениями val1 и val1-2 даёт []string{"val1", "val1-2"}; поэтому Get("trace") вернёт [a b]. Вторая: Len() возвращает len(md) — число уникальных ключей, здесь trace и x, то есть 2, а не 3 (значений четыре, ключей два). Порядок значений внутри ключа — порядок вызова пар, ничего не переворачивается; а вот нечётное число аргументов в Pairs закончило бы пример паникой «metadata: Pairs got the odd number of input pairs» — у нас пары чётные, паники нет.

### Значения: печатаемый ASCII и суффикс -bin

Если ключи проверяются на алфавит, то значения — на печатаемость, и только при отправке. Правила проверки живут во внутреннем пакете и дословно таковы: «the characters in every value must be printable (in [%x20-%x7E])» для обычных ключей, и «if the key ends with a "-bin" suffix, no validation of the corresponding value is performed» — для бинарных проверка отключена. Что это значит на практике: обычный ключ может нести только печатаемые ASCII-символы — кириллица «Мария», эмодзи, нулевые байты туда не влезут. Попытка уедет с клиента кодом codes.Internal с текстом «header key %q contains value with non-printable ASCII characters» — и заметьте, ещё ДО сети: проверка пар происходит при открытии вызова, в момент сборки заголовков, так что сервер это сообщение никогда не увидит. Для всего непечатаемого существует суффикс -bin: ключ вида greeting-bin или grpc-trace-bin (так носит бинарный trace-контекст OpenTelemetry) освобождает значение от ASCII-проверки, а транспорт сам кодирует его в base64 на отправке — «encoded (base64) before sending and will be decoded after being transferred», — и сам же декодирует на приёме. В Go-коде вы работаете с сырыми байтами: string([]byte{0x60, 0x66}) кладёте как есть, на той стороне получаете те же байты. Два следствия. Первое: base64 — это не шифрование, а упаковка от запятых и непечатаемых байтов; читать base64 по дороге может кто угодно, защищает провод только TLS. Второе: суффикс — часть имени ключа: greeting и greeting-bin — два независимых ключа, и сервер должен читать именно greeting-bin, потому что greeting при этом пуст.

### Кириллица на обычном ключе. Что вернёт вызов?

```go
ctx := metadata.AppendToOutgoingContext(context.Background(),
        "greeting", "Привет", // значение с кириллицей
)
order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})

fmt.Println(status.Code(err))
```

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

- OK — gRPC сам перекодирует не-ASCII значения в base64
- InvalidArgument — сервер отверг некорректное значение
- ✅ Internal — проверка на клиенте, вызов не уходит в сеть
- Unavailable — сеть недоступна, соединение не установлено

Проверка метаданных случается на клиенте при открытии вызова, в момент сборки заголовков: значение обычного ключа обязано быть печатаемым ASCII — «the characters in every value must be printable (in [%x20-%x7E])». Кириллица в диапазон не попадает, и вызов завершается codes.Internal с текстом «header key "greeting" contains value with non-printable ASCII characters» — сервер ничего не получит, до сети дело даже не дойдёт. InvalidArgument отпадает: код вернул не сервер, а локальная проверка. Лечится суффиксом: ключ greeting-bin пропустит значение без проверки, транспорт закодирует его в base64 и декодирует у получателя — сервер прочтёт «Привет» из md.Get("greeting-bin").

### Метаданные в стримах: заголовки и трейлеры

До сих пор метаданные ехали только от клиента. Но сервер тоже может их слать — в двух отдельных порциях, и это ключевая география урока. Заголовки ответа (headers) едут ДО сообщений: трейлеры — ВМЕСТЕ со статусом, в финальном кадре вызова; тот самый кадр, где в unary-уроке мы нашли grpc-status. Серверные методы встроенного ServerStream описаны в stream.go дословно так. SetHeader(metadata.MD) error: «It may be called multiple times. When call multiple times, all the provided metadata will be merged. All the metadata will be sent out when one of the following happens: ServerStream.SendHeader() is called; The first response is sent out; An RPC status is sent out (error or success)» — то есть SetHeader только накапливает, а физическая отправка привязана к трём событиям. SendHeader(metadata.MD) error: «sends the header metadata. The provided md and headers set by SetHeader() will be sent. It fails if called multiple times» — это кнопка «отправить сейчас», и она одноразовая. SetTrailer(metadata.MD) — без ошибки вовсе: «sets the trailer metadata which will be sent with the RPC status. When called more than once, all the provided metadata will be merged». Для unary-хендлеров те же три действия доступны функциями пакета grpc: grpc.SetHeader(ctx, md), grpc.SendHeader(ctx, md), grpc.SetTrailer(ctx, md) — официальный пример features/metadata кладёт grpc.SetTrailer прямо в defer, чтобы записать время обработки в трейлер любого исхода. Клиентская сторона читает эти порции методами ClientStream: Header() (metadata.MD, error) «returns the header metadata received from the server if there is any. It blocks if the metadata is not ready to read» — блокируется до появления заголовков; а Trailer() metadata.MD «must only be called after stream.CloseAndRecv has returned, or stream.Recv has returned a non-nil error (including io.EOF)». Для unary и это есть — как CallOption: grpc.Header(&header) и grpc.Trailer(&trailer) в вызове GetOrder заполнят map после завершения RPC.

### Куда класть: заголовки или трейлеры

Две порции ответных метаданных — это не дублирование, а разные контракты по времени. В заголовки кладите то, что нужно клиенту ДО первого сообщения: локаль ответа, версию API сервера, лимиты потока, идентификатор обработчика — всё, чем клиент может руководствоваться, пока сообщения ещё не поехали. В трейлеры — то, что становится известно по ИТОГАМ вызова: время обработки, окончательный requestId, счётчики обработанных сообщений; не случайно grpc-status и grpc-message едут именно там — статус известен только в конце. Теперь ловушки, на которых ломаются первые реализации. Ловушка первая: сервер вызвал stream.SetHeader ПОСЛЕ первого Send. Заголовки уже уехали (дока перечисляла «The first response is sent out» как одно из событий отправки), и SetHeader вернёт ошибку — внутренний ErrIllegalHeaderWrite: писать заголовки «задним числом» нельзя, опоздавшие данные переезжают в SetTrailer. Ловушка вторая: клиент вызвал stream.Trailer() сразу после открытия стрима. Дока разрешает читать трейлеры только после конца (Recv вернул ошибку, включая io.EOF), а реализация возвращает снимок: transport-метод делает s.trailer.Copy() — копию текущего состояния. Позвали рано — получили пустую копию, и долетевшие позже трейлеры в неё не появятся: снимок, а не живой вид. Ловушка третья — время жизни клиентских заголовков: Header() блокируется до появления заголовков, и если стрим кончился без заголовков, дока обещает «If the metadata is nil and the error is also nil, then the stream was terminated without headers, and the status can be discovered by calling RecvMsg» — реализация превращает пару nil, nil в io.EOF, поэтому обрабатывайте ошибку из Header() как обычный исход вызова, а не как экзотику. И общее правило из девятого урока, теперь в терминах метаданных: авторизационный заголовок клиент прикладывает ДО client.ChatTickets(ctx) — при открытии стрима заголовки уезжают один раз, и потом их не дописать; на сервере FromIncomingContext(stream.Context()) стоит до цикла Recv — метаданные вызова не меняются от сообщения к сообщению.

### Сервер: SetHeader и SetTrailer в ChatTickets

```go
// internal/server/chat.go — метаданные в bidi-стриме из девятого урока.
func (s *ChatServiceServer) ChatTickets(
        stream pb.ChatService_ChatTicketsServer) error {

        // Заголовки: SetHeader можно звать многократно — вызовы сольются.
        // Физически уедут при первом ответе, по SendHeader или со статусом.
        if err := stream.SetHeader(metadata.Pairs("locale", "ru-RU")); err != nil {
                return err
        }
        // Трейлер: уедет вместе со статусом при возврате из хендлера.
        // SetTrailer ничего не возвращает — обрабатывать нечего.
        defer stream.SetTrailer(metadata.Pairs("operator-id", "op-7"))

        for {
                in, err := stream.Recv()
                if err == io.EOF {
                        // Клиент вызвал CloseSend: выходим со статусом OK,
                        // defer унесёт operator-id в трейлерах.
                        return nil
                }
                if err != nil {
                        return err
                }
                if err := stream.Send(&pb.TicketMsg{
                        TicketId: in.GetTicketId(),
                        Author:   "operator",
                        Text:     "принято: " + in.GetText(),
                }); err != nil {
                        return err
                }
        }
}
```

Канонический ChatTickets из девятого урока, дополненный метаданными по обеим порциям. SetHeader до цикла — все вызовы сольются (дока: «all the provided metadata will be merged»), и уедут они при первом же Send: отдельный SendHeader здесь не нужен. SetTrailer в defer — приём из официального примера features/metadata: трейлер запишется при ЛЮБОМ выходе из хендлера, и OK, и status.Error, потому что трейлеры едут со статусом всегда. Заметьте асимметрию сигнатур: SetHeader и SendHeader возвращают error, SetTrailer — нет; дока объясняет это тем, что трейлер неотменим, он часть статуса. Если переставить SetHeader после первого Send — вернётся ошибка (заголовки уже ушли), и это тот самый ErrIllegalHeaderWrite; правильная реакция — не паниковать, а перенести данные в трейлер. Для unary-хендлера GetOrder та же пара выглядит как grpc.SetTrailer(ctx, metadata.Pairs(...)) в defer — сигнатура с ctx вместо stream, механика та же.

### Клиент: Header() и Trailer() со стрима

```go
// cmd/client/main.go — клиент читает ответные метаданные стрима.
stream, err := client.ChatTickets(ctx)
if err != nil {
        log.Fatalf("ChatTickets: %v", err)
}

// Header() блокируется до появления заголовков от сервера.
header, err := stream.Header()
if err != nil {
        log.Printf("заголовки: %v", err)
} else {
        fmt.Println("локаль:", header.Get("locale"))
}

for {
        in, err := stream.Recv()
        if err == io.EOF {
                break // вызов завершён со статусом OK
        }
        if err != nil {
                log.Fatalf("чтение: %v", err)
        }
        fmt.Printf("[%s] %s\n", in.GetAuthor(), in.GetText())
}
// Трейлеры наполняются к концу вызова — читаем после последнего Recv.
fmt.Println("оператор:", stream.Trailer().Get("operator-id"))
```

Два метода ClientStream из девятого урока (там они значились в списке встраивания — «to provide Context, Header, Trailer, and CloseSend functionality»), теперь по делу. Header() блокируется до появления заголовков: сервер может отложить их до первого сообщения, поэтому вызов просто дождётся. Доку описывает особый исход: «If the metadata is nil and the error is also nil, then the stream was terminated without headers, and the status can be discovered by calling RecvMsg» — реализация превращает этот случай в io.EOF, так что ошибка из Header() — это обычный исход вызова. Trailer() легален только после конца: Recv вернул io.EOF или ошибку. Раньше можно — но бессмысленно: реализация возвращает снимок s.trailer.Copy(), пустой на момент вызова, и долетевшие позже трейлеры в него не появятся. Для unary те же данные берутся CallOption-ами из официального примера: var header, trailer metadata.MD, затем client.GetOrder(ctx, req, grpc.Header(&header), grpc.Trailer(&trailer)) — map-ы заполнятся после завершения RPC, и header.Get("location") из примера покажет серверную локацию.

### Проверка: SetHeader после первого сообщения

**Вопрос:** Хендлер bidi-стрима отправил клиенту два сообщения через stream.Send, а затем вызвал stream.SetHeader(md) с ключом locale. Что произойдёт?

- Метаданные молча присоединятся к трейлерам — клиент прочитает их из Trailer()
- ✅ SetHeader вернёт ошибку: заголовки уже уехали при первом сообщении
- Метаданные уедут с очередным сообщением — заголовки можно дописывать весь вызов
- Вызов аварийно завершится с codes.Internal на клиенте

Заголовки отправляются однократно, и дока SetHeader перечисляет три момента отправки: SendHeader, первое исходящее сообщение, статус. После первого Send заголовочный кадр уже в полёте, и транспортный метод вернёт ErrIllegalHeaderWrite — «писать заголовки после отправки нельзя». Метаданные не теряются навсегда: опоздавшие данные кладут в трейлер через SetTrailer — он легален до самого конца вызова и сольётся с уже записанными. Клиент от этого вызова не страдает: ошибка возвращается хендлеру, а не стриму — решение, что делать с данными, остаётся на сервере.

### Проверка: слишком ранний Trailer()

**Вопрос:** Клиент bidi-стрима сразу после client.ChatTickets(ctx) вызвал stream.Trailer() и сохранил результат в переменную, а трейлеры прочитал только в конце вызова. Что он увидит?

- Живой вид: переменная наполнится, когда трейлеры доедут
- Панику: Trailer() раньше конца вызова запрещён библиотекой
- ✅ Снимок пустого map: реализация возвращает копию текущих трейлеров, поздние данные в неё не появятся
- Ошибку компиляции: Trailer() возвращает (metadata.MD, error)

Дока ClientStream требует: «It must only be called after stream.CloseAndRecv has returned, or stream.Recv has returned a non-nil error (including io.EOF)». Библиотека на нарушение не паникует, компилятор не защищает — но реализация возвращает снимок: transport-метод делает s.trailer.Copy(), копию состояния на момент вызова. Позвали до конца — получили пустую копию, и долетевшие позже трейлеры в неё не появятся: снимок, а не живой вид. Сигнатура — Trailer() metadata.MD без ошибки, так что последний вариант исключён. Правило: сначала дочитайте Recv до io.EOF (или ошибки), потом Trailer() — тогда снимок будет полным.

> **Токен — секрет, а base64 — не шифрование.** Метаданные едут в заголовках HTTP/2 открытым текстом. Защиту даёт только TLS — двенадцатый урок курса; insecure-канал с токеном в заголовке допустим лишь в dev-окружении. base64 у ключей -bin — это упаковка от непечатаемых байтов, а не шифрование: читать её по дороге может любой. Не логируйте metadata целиком — одной строкой уедет и токен; печатайте отдельные безобидные ключи, секретные маскируйте. И не занимайте префикс grpc- — он за библиотекой (grpc-timeout, grpc-encoding, grpc-status), дока предупреждает, что такие ключи «may result in errors if set in metadata».

### География метаданных вызова

| Направление | Что едет | Когда | Как отправить | Как прочитать |
| --- | --- | --- | --- | --- |
| Клиент → сервер | ваши пары: authorization, x-request-id, accept-language | при открытии вызова — во всех четырёх моделях | metadata.AppendToOutgoingContext / NewOutgoingContext на ctx | metadata.FromIncomingContext(ctx или stream.Context()) |
| Библиотека → сервер | grpc-timeout, grpc-encoding, content-type, :path | те же заголовки открытия, автоматически | само собой — дедлайны и кодеки из прошлых уроков | md.Get("grpc-timeout") — можно, но обычно есть stream.Context() |
| Сервер → клиент | заголовки: locale, версия API, лимиты | до первого сообщения: SendHeader, первый Send или статус | stream.SetHeader / stream.SendHeader; unary: grpc.SetHeader(ctx, md) | stream.Header() — блокируется; unary: grpc.Header(&md) |
| Сервер → клиент | трейлеры: время обработки, requestId, grpc-status | вместе со статусом, в конце вызова | stream.SetTrailer; unary: grpc.SetTrailer(ctx, md) | stream.Trailer() после Recv с ошибкой (вкл. io.EOF); unary: grpc.Trailer(&md) |
| Обе стороны | бинарные значения: key-bin (grpc-trace-bin у OpenTelemetry) | в любое время по правилам своей порции | ключ с суффиксом -bin: base64 кодирует и декодирует транспорт | обычное чтение: md.Get("key-bin") вернёт сырые байты |

### Соберите связку «токен на клиенте — проверка на сервере»

Восстановите обе стороны: клиент строит MD и дописывает пары в контекст, сервер в ok-форме читает метаданные и проверяет заголовок авторизации.

```go
// Клиент: строим MD и приклеиваем пары к контексту.
md := metadata.___("authorization", "Bearer dev-42")
ctx = metadata.___(ctx, "x-request-id", "req-7") // дописать, не заменить

// Сервер (хендлер стрима): ok-форма чтения входящих метаданных.
___, ok := metadata.___(stream.Context())
if !ok {
        return status.Error(codes.Unauthenticated, "нет метаданных")
}
if len(___.Get("authorization")) == 0 {
        return status.Error(codes.Unauthenticated, "нет Bearer-токена")
}
return nil
```

Подсказка: Конструктор из пар — Pairs; дописать в контекст — AppendToOutgoingContext; переменная результата — md; чтение на сервере — FromIncomingContext; поиск значения — через md.Get.

Правильные ответы в пропусках: `Pairs`, `AppendToOutgoingContext`, `md`, `FromIncomingContext`, `md`

### Жизненный цикл метаданных bidi-вызова

Расставьте события чата ChatTickets — от сборки метаданных на клиенте до чтения трейлеров.

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

1. `Клиент: md := metadata.Pairs("authorization", "Bearer dev-42") — ключ уже в нижнем регистре`
2. `Клиент: ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer dev-42") — пары приклеены к контексту`
3. `Клиент: client.ChatTickets(ctx) — при открытии стрима метаданные уезжают в заголовках HTTP/2`
4. `Сервер: md, ok := metadata.FromIncomingContext(stream.Context()) — ok-форма до цикла Recv`
5. `Сервер: md.Get("authorization") вернул Bearer-токен — префикс и значение проверены`
6. `Сервер: stream.SetHeader(metadata.Pairs("locale", "ru-RU")) — сольётся и уедет при первом ответе`
7. `Сервер: хендлер вернул nil — defer stream.SetTrailer уехал вместе со статусом OK`
8. `Клиент: stream.Trailer().Get("operator-id") после io.EOF из Recv — трейлеры прочитаны`

### Чек-лист grpc metadata

- Тип — map[string][]string: у ключа может быть несколько значений; конструкторы New(map[string]string) и Pairs(kv...) сами понижают регистр ключей, Pairs паникует на нечётном числе аргументов.
- Клиент: metadata.AppendToOutgoingContext — канонический способ, дописывает к существующим парам; NewOutgoingContext заменяет и медленнее; склейка MD — metadata.Join; нечётное kv — паника, md после приклейки менять нельзя.
- Сервер: metadata.FromIncomingContext(ctx) в ok-форме, для стримов — stream.Context(); возвращает копию со строчными ключами; ValueFromIncomingContext ищет без учёта регистра; NewIncomingContext — помощник для тестов.
- Ключи: только [0-9a-z-_.], прямой доступ md["Authorization"] мимо Get вернёт nil; префикс grpc- зарезервирован за библиотекой.
- Значения обычных ключей — печатаемый ASCII [%x20-%x7E]: нарушение даёт codes.Internal ещё на клиенте, при открытии вызова; не-ASCII и байты — ключ с суффиксом -bin: base64 кодирует и декодирует транспорт автоматически.
- Bearer-токен кладут в authorization ДО вызова; сервер проверяет префикс и значение в хендлере: Unauthenticated — не представился, PermissionDenied — прав нет; логировать md целиком нельзя.
- Сервер → клиент: SetHeader накапливает и сливает вызовы (уезжают при SendHeader, первом сообщении или статусе), SendHeader одноразовый, SetTrailer едет со статусом и ничего не возвращает; после первого Send SetHeader вернёт ошибку.
- Клиент: stream.Header() блокируется (nil и nil — стрим кончился без заголовков, статус ищут в Recv); stream.Trailer() — только после конца вызова, иначе пустой снимок; для unary — grpc.Header(&md) и grpc.Trailer(&md) как CallOption.

### Что дальше по курсу

Отдельная честная оговорка про формат: в курсе есть песочница с интерпретатором Go, но сеть в браузере недоступна — gRPC-вызов оттуда не поднять, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за минуты: сервер из четвёртого урока, клиентский каркас из пятого — и метаданные оживут: приложите к вызову пары и напечатайте их в хендлере, уберите Bearer-префикс и посмотрите на Unauthenticated, пошлите кириллицу на обычном ключе и поймайте Internal ещё до сети, а тот же ключ с -bin донесёт значение целым. Соседние уроки по теме: метаданные работают одинаково во всех моделях — и в [client streaming восьмого урока](/courses/grpc-go/lessons/08-client-streaming/), и в [bidi-чате девятого](/courses/grpc-go/lessons/09-bidi/); в чате особенно наглядно, что заголовки уезжают один раз при открытии стрима. Дальше по маршруту: проверку токена из хендлера одиннадцатый урок про перехватчики перепишет в interceptor — одна функция на все методы, вызовы AppendToOutgoingContext и FromIncomingContext останутся теми же; двенадцатый урок о TLS даст проводу настоящую защиту; дедлайны и распространение отмены — в четырнадцатом; тесты хендлеров с NewIncomingContext и bufconn — в уроке про тестирование. Полный маршрут курса — на [странице gRPC-Go](/courses/grpc-go/).

### Карточки: метаданные и контекст

- **metadata.MD** — map[string][]string — у ключа срез значений. Конструкторы New(map[string]string) и Pairs(kv...): оба понижают регистр, Pairs паникует на нечётном kv и дописывает повторные ключи в срез. Len() — число ключей; Get/Set/Append/Delete/Copy/Join — операции с нормализацией регистра.
- **AppendToOutgoingContext vs NewOutgoingContext** — Append — рекомендованный путь: дописывает пары к существующим, можно звать многократно. New — заменяет всё (перезапишет и Append-пары) и медленнее; md после приклейки менять нельзя. Склейка MD — metadata.Join. Функции JoinOutgoingContext в grpc-go не существует.
- **FromIncomingContext** — Серверное чтение: md, ok := metadata.FromIncomingContext(ctx), для стримов — stream.Context(). Ok-форма: false — метаданных нет (тесты с голым контекстом). Возвращает копию со строчными ключами. ValueFromIncomingContext — регистронезависимый поиск без bool. NewIncomingContext — для юнит-тестов.
- **Ключи и значения на проводе** — Ключи: [0-9a-z-_.], всегда строчные, префикс grpc- зарезервирован. Значения обычных ключей — печатаемый ASCII [%x20-%x7E], иначе Internal ещё на клиенте. Ключ с суффиксом -bin пропускает проверку: транспорт кодирует значение в base64 и декодирует у получателя.
- **Bearer-токен в gRPC** — Клиент: metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer <token>") до вызова. Сервер: md.Get("authorization"), проверка префикса и значения; Unauthenticated — не представился, PermissionDenied — прав нет. Вынос проверки на все методы — перехватчики следующего урока. Декларативная альтернатива — PerRPCCredentials с GetRequestMetadata и WithPerRPCCredentials.
- **Заголовки и трейлеры ответа** — Заголовки — до сообщений: SetHeader (сливает, уезжают при SendHeader/первом сообщении/статусе) и одноразовый SendHeader; после первого Send SetHeader даёт ошибку. Трейлеры — со статусом: SetTrailer (сливает, ничего не возвращает). Клиент: Header() блокируется (nil+nil — статус в Recv), Trailer() — только после io.EOF/ошибки, иначе пустой снимок (Copy). Unary: grpc.SetHeader/SetTrailer(ctx, md) и CallOption-ы grpc.Header(&md), grpc.Trailer(&md).

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

- Метаданные — пары «ключ — значение» вне сообщений: едут в заголовках HTTP/2 при открытии вызова и работают одинаково во всех четырёх моделях — ctx на клиенте, stream.Context() на сервере.
- metadata.MD — это map[string][]string: New и Pairs строят его и сами понижают регистр ключей; Pairs паникует на нечётном числе аргументов, Len возвращает число ключей, а значения до отправки не проверяются.
- Клиент: metadata.AppendToOutgoingContext — канонический merge; NewOutgoingContext заменяет предыдущие пары и медленнее; склейка MD — metadata.Join; нечётное kv — паника.
- Сервер: metadata.FromIncomingContext(ctx) в ok-форме и всегда со строчными ключами; ValueFromIncomingContext — регистронезависимо; NewIncomingContext — помощник для тестов.
- Значения обычных ключей — печатаемый ASCII: иначе codes.Internal ещё на клиенте; не-ASCII и бинарные данные едут в ключах с суффиксом -bin — base64 кодирует и декодирует транспорт.
- Bearer-токен кладут в authorization до вызова; хендлер проверяет md.Get("authorization") и отвечает Unauthenticated или PermissionDenied; вынести проверку на все методы — работа перехватчиков следующего урока.
- Серверные заголовки: SetHeader сливает вызовы и уезжает при первом сообщении/SendHeader/статусе, после первого сообщения — ошибка; трейлеры едут со статусом; клиент: Header() блокируется, Trailer() — только после конца вызова.

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

Соберите сценарий авторизации по раскладке урока и прогоните четыре проверки. Первая: клиент GetOrder с AppendToOutgoingContext и парой authorization со значением «Bearer dev-42»; хендлер пускает вызов и возвращает заказ 42. Вторая: уберите метаданные вовсе — хендлер обязан ответить Unauthenticated, а клиент показать этот код через status.Code. Третья: пошлите metadata.Pairs("greeting", "Привет") на обычном ключе и убедитесь, что клиент получит Internal с текстом про non-printable ASCII ещё до отправки; перепишите ключ на greeting-bin и проверьте, что хендлер прочёл значение целым. Четвёртая: в ChatTickets из девятого урока поставьте stream.SetHeader с локалью до цикла Recv и stream.SetTrailer с operator-id в defer; на клиенте напечатайте header.Get("locale") сразу после Header() и trailer.Get("operator-id") после io.EOF. Финальный эксперимент: вызовите SetHeader после первого Send — убедитесь, что получили ошибку, и объясните себе, почему опоздавшие данные переезжают в трейлер, а не в заголовки.

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

### Чем метаданные отличаются от полей в protobuf-сообщении?

Сообщения — предметные данные конкретного метода; метаданные — сквозная транспортная обвязка, одинаковая для всех методов: авторизация, трассировка, локаль, requestId. Токен в поле сообщения попадёт в схему, логи сообщений и кэши и потребует дублирования в каждом методе; в заголовке он остаётся заботой транспорта и поедет во все вызовы одинаково. Проверяйте вхождение: «предметное — в сообщения, сквозное — в метаданные».

### Нужно ли класть токен в каждое сообщение стрима?

Нет: заголовки уезжают один раз при открытии вызова, и сервер читает их из stream.Context() до цикла Recv — метаданные не меняются от сообщения к сообщению. Дублирование токена в сообщениях вредно: оно попадает в логи сообщений и кардинально раздувает полезную нагрузку. Если сессия должна переживать смену токена — это делается протоколом приложения (служебное сообщение «переавторизация»), а не заголовками.

### Почему метаданные с ключом «Authorization» не находятся на сервере?

Потому что ключи нормализуются к нижнему регистру на обеих сторонах — и на клиенте (New/Pairs/AppendToOutgoingContext), и на сервере (FromIncomingContext). Прямой доступ md["Authorization"] ищет ключ дословно и возвращает nil; работают md["authorization"] или md.Get("Authorization") — Get понижает регистр сам. ValueFromIncomingContext ищет вообще без учёта регистра, если контекст собирали вручную.
