---
title: "Типы вызовов: unary"
description: "grpc unary в Go: модель запрос-ответ, четыре типа вызовов, grpc-status и grpc-timeout поверх HTTP/2, таймауты context.WithTimeout и простые ошибки."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/06-unary/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 18
---

# Типы вызовов: unary

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

CreateOrder и GetOrder уже прошли полный круг: сервер из четвёртого урока отвечает, клиент из пятого — получает, и оба этих метода — unary, самый ходовой тип вызовов gRPC. Здесь разбираем grpc unary системно: модель запрос-ответ и все четыре типа вызовов рядом, анатомия unary-вызова поверх HTTP/2 — от заголовка grpc-timeout до trailers с grpc-status, — таймауты через context.WithTimeout с двух сторон провода и модель простых ошибок: status.Error на сервере, status.Code на клиенте. На этом фундаменте дальше строятся три стриминговых урока, дедлайны, статус-коды и ретраи.

Пять уроков позади, и главный результат этого пути стоит сформулировать честно: вы уже вызывали unary-методы, просто не называли их так. CreateOrder и GetOrder из схемы заказов — по одному сообщению на входе и на выходе, и обе пары «хендлер + вызов» вы написали своими руками: сервер — в [уроке про первый gRPC-сервер](/courses/grpc-go/lessons/04-server/), клиент — в [уроке про gRPC-клиент](/courses/grpc-go/lessons/05-klient/). Оставалось небрежное чувство, что вызов — это какой-то магический чёрный ящик: байты уходят, структура возвращается, а что происходит между Invoke и возвратом из хендлера — неясно. Этот урок закрывает пробел: соберём все четыре типа вызовов в одну таблицу, разберём unary-вызов покадрово — что именно едет по HTTP/2, — вытащим на свет заголовки grpc-status, grpc-message и grpc-timeout, поставим таймауты с обеих сторон и договоримся, какие ошибки наружу можно, а какие нельзя.

### grpc unary: модель запрос-ответ

Unary — это RPC в чистом виде: клиент отправляет одно сообщение-запрос, сервер отвечает ровно одним сообщением-ответом, и на этом вызов закончен. В коде это выглядит как обычная функция: аргументы на входе, результат на выходе, — и документация Invoke формулирует контракт буквально: «sends the RPC request on the wire and returns after response is received» — отправляет запрос на провод и возвращается после получения ответа. За простотой стоит строгая дисциплина кардинальности, которую фреймворк проверяет за вас: сгенерированный unary-метод — это Invoke над дескриптором StreamDesc с ServerStreams: false и ClientStreams: false, и внутри invoke клиент после первого RecvMsg зовёт его второй раз — за trailers. Если сервер по недосмотру выслал два сообщения, второй RecvMsg вернёт не io.EOF, а очередное сообщение, и клиент получит codes.Internal с сообщением «cardinality violation: expected <EOF> for non server-streaming RPCs, but received another message» — нарушение кардинальности карается на месте. После отправки единственного запроса клиент вызывает CloseSend — сигнал «других сообщений не будет». Тот самый unary call, о котором пишут англоязычные туториалы, — это и есть эта модель: один Request, один Response, ничего посередине. И важная поправка на масштаб: unary — свойство метода, а не соединения. В одном HTTP/2-соединении мультиплексируются десятки параллельных unary-вызовов, так что «запрос-ответ» не значит «медленно и по очереди» — мультиплексирование потоков из первого урока здесь и работает.

### Типы вызовов: четыре модели обмена

| Вид вызова | В нашей схеме | Клиент → сервер | Сервер → клиент | Сгенерированный метод клиента |
| --- | --- | --- | --- | --- |
| unary | CreateOrder, GetOrder | одно сообщение | одно сообщение | (*pb.Order, error) — Invoke |
| server streaming | WatchOrders | одно сообщение | поток OrderEvent | grpc.ServerStreamingClient[OrderEvent] |
| client streaming | нет в схеме; пример: UploadReceipt(stream ReceiptChunk) returns (ReceiptSummary) | поток чанков | одна сводка | grpc.ClientStreamingClient[ReceiptChunk, ReceiptSummary] |
| bidirectional | нет в схеме; пример: чат поддержки ChatTickets(stream TicketMsg) returns (stream TicketMsg) | поток | поток | grpc.BidiStreamingClient[TicketMsg, TicketMsg] |

### Типы вызовов: как выбирать

Таблицу стоит читать не как справочник, а как чек-лист проектирования. Дефолт — unary: большинство операций сервиса заказов — это «спросил — ответил», и стриминг там не нужен ровно до тех пор, пока не появится требование, которое обычным запрос-ответом не выражается. Таких требований три классических. Первое: список неограниченного размера — отдавать каталог из миллиона позиций одним unary-сообщением нельзя, упрётесь в потолок сообщения (по умолчанию 4 мегабайта, помните ResourceExhausted?). Второе: события в реальном времени — курс валют, логи, прогресс долгой операции; клиенту нужно получать их по мере появления, а не одним пакетом в конце. Третье: большая передача по частям — файл заливается чанками. Распознаются типы вызовов в .proto по слову stream: нет слова — unary, справа — серверный стрим, слева — клиентский, с обеих сторон — двунаправленный; это правило из урока про proto3, и теперь к нему прилагаются конкретные Go-типы из таблицы выше. Каждому стриминговому виду в курсе посвящён отдельный урок: седьмой — server streaming, где WatchOrders наконец перестанет быть заглушкой, восьмой — client streaming, девятый — bidi. А этот урок добивает unary до конца: провод, таймауты, ошибки.

### Так unary-вызов GetOrder выглядит на проводе

```go
// Путь вызова GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
// по HTTP/2 — реконструкция кадров (упрощённо, без HPACK-сжатия).

// 1. Клиент открывает поток и отправляет заголовки запроса:
//    :path         /shop.v1.OrderService/GetOrder
//    :method       POST
//    content-type  application/grpc
//    grpc-timeout  2S
//    te            trailers

// 2. Клиент шлёт тело запроса: 5 байт префикса — 1 байт флага
//    сжатия + 4 байта длины сообщения в big-endian — и затем
//    байты protobuf-сообщения GetOrderRequest.
//    Флагом END_STREAM клиент закрывает своё направление:
//    сообщений от него больше не будет.

// 3. Сервер выполняет хендлер и отвечает. Сначала заголовки ответа:
//    :status       200
//    content-type  application/grpc
//    затем РОВНО ОДНО сообщение Order — тот же префикс и protobuf,
//    затем trailers с итогом вызова:
//    grpc-status   0
//    grpc-message  (пусто: ошибки нет)
```

Три кадра слева направо — и три вещи, которые стоит заметить. Первая: сообщение не «просто байты» — перед каждым лежит пятёрка байтов префикса, где объявлены сжатие и длина, поэтому получатель знает, где сообщение кончается. Вторая: :path — тот самый FullMethodName «/shop.v1.OrderService/GetOrder» из клиентского урока, а content-type — application/grpc (с подтипом кодека: application/grpc+proto). Третья: итоговый статус едет не в заголовках ответа, а в trailers — кадре заголовков, закрывающем поток.

### grpc-status, grpc-message, grpc-timeout: заголовки протокола

Заголовки из трассировки выше — не произвольные строки, а элементы спецификации gRPC поверх HTTP/2, и в grpc-go за каждым стоит конкретный код. Начнём с конца — со статуса. В http2_server.go метод WriteStatus собирает trailers: поле «grpc-status» получает десятичное представление кода (strconv.Itoa от номера из перечисления codes — успешный вызов это grpc-status: 0), поле «grpc-message» — текст сообщения, пропущенный через encodeGrpcMessage: печатные ASCII-байты проезжают как есть, всё остальное кодируется процентами, а невалидный UTF-8 заменяется символом-заменой — сообщение никогда не сломает кадр заголовков. Если сервер приложил к статусу детали, они едут отдельным заголовком grpc-status-details-bin в base64 — это поле станет главным в уроке про статус-коды и details. Теперь начало — таймаут. Клиент отправляет дедлайн вызова в заголовке «grpc-timeout» как число с суффиксом единицы: n, u, m, S, M, H — от наносекунд до часов. Функция EncodeDuration в internal/grpcutil выбирает наименьшую единицу, в которой значение умещается в восемь цифр: две секунды уезжают как «2S», 250 миллисекунд — как «250m». И примечательная деталь из исходников http2_client.go: клиент шлёт grpc-timeout всегда, даже когда дедлайна нет — комментарий гласит «Send out timeout regardless its value. The server can detect timeout context by itself»: сервер сам разберётся со своим контекстом. Trailers нужны потому, что итог вызова известен не всегда в момент отправки тела: для unary они идут следом почти вплотную, а для стримингов — после последнего сообщения потока; механика одна и та же.

### Какое значение уедет в grpc-timeout?

```go
// Клиент ставит срок вызова в 250 миллисекунд.
// Что сервер увидит в заголовке grpc-timeout?

ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()

_, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
```

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

- 250ms — единицы пишутся как в Go
- ✅ 250m — миллисекунды кодируются суффиксом m
- 0.25S — секунды с дробной частью
- Заголовка не будет: таймаут меньше секунды не отправляется

EncodeDuration выбирает наименьшую единицу, в которой значение умещается в восемь цифр: div(250ms, миллисекунда) даёт 250 и суффикс m — на проводе заголовок grpc-timeout: 250m. Дробные секунды протокол не пишет, а заголовок клиент шлёт всегда, даже без дедлайна. Единицы по возрастанию: n, u, m, S, M, H.

### Проверка: где едет итоговый статус

**Вопрос:** В каком HTTP/2-кадре клиент получает grpc-status успешного unary-ответа?

- В заголовках ответа, до тела сообщения
- ✅ В trailers — кадре заголовков после тела
- Внутри самого protobuf-сообщения Order
- В служебном кадре GOAWAY при закрытии соединения

gRPC всегда кладёт итоговый статус в trailers — заголовки после тела; WriteStatus в http2_server.go собирает grpc-status и grpc-message именно там. Для unary тело и trailers идут подряд, для стримингов — в самом конце потока. Тело ответа — это только protobuf-сообщение, без статуса; именно поэтому обратные прокси должны поддерживать trailers.

### Таймауты unary: дедлайн едет вместе с запросом

Как ставить срок вызову, вы уже знаете из клиентского урока: context.WithTimeout + defer cancel. Здесь важнее другое — что дедлайн делает дальше. Первый факт: он уезжает на сервер. Заголовок grpc-timeout заполняется из контекста вызова, и сервер, принимая вызов, восстанавливает дедлайн в своём ctx: ctx.Deadline() хендлера показывает тот же срок, что поставил клиент. Второй факт: клиент не обязан ждать сервера. Как только срок истёк, Invoke завершается ошибкой DeadlineExceeded — конвертация context.DeadlineExceeded в одноимённый код зашита в toRPCErr rpc_util.go, — даже если сервер через секунду пришлёт прекрасный ответ: приниматься он уже не будет. Третий факт: сервер узнаёт об уходе клиента. HTTP/2-поток закрывается, и ctx.Done() в хендлере срабатывает — работа после дедлайна становится чистым сжиганием ресурсов: сервер считает, кодирует и отправляет ответ в закрытый поток. Отсюда правило уважения дедлайна: любую долгую работу дробить на порции и проверять ctx.Done(). Четвёртое — различать два «вызов умер»: DeadlineExceeded означает «истёк срок», а Canceled — «кто-то явно отменил контекст»: пользователь ушёл со страницы, родительский вызов закрылся, отработал таймаут выше по цепочке. Клиент различает их через status.Code и реагирует по-разному: первый — честный ответ «не успели», второй — повод узнать, кто и зачем отменил. И последний штрих: дедлайн наследуется. Производный контекст передаётся дальше, поэтому unary-вызов вашего сервиса к соседнему унаследует срок родительского запроса — а как этот механизм устроен внутри и как задавать дедлайны через WithDeadline, разберёт урок про дедлайны и отмены; grpc.WithTimeout как DialOption, помните, устарел и игнорируется.

### Дедлайн с двух сторон: клиент задаёт — сервер уважает

```go
// Клиент (из прошлого урока): срок конкретного вызова — 2 секунды.
// ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// defer cancel()
// order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})

func (s *OrderServiceServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
        // Дедлайн приехал с запросом: на проводе это grpc-timeout,
        // в коде хендлера — обычный ctx.Deadline().
        if dl, ok := ctx.Deadline(); ok {
                log.Printf("GetOrder(%d): у вызова осталось %v", req.Id, time.Until(dl))
        }

        // Долгую работу дробим на порции и после каждой проверяем ctx.Done().
        for !s.ready(req.Id) {
                select {
                case <-ctx.Done():
                        // Клиент ушёл: истёк срок или вызов отменён.
                        // FromContextError: DeadlineExceeded или Canceled по ctx.Err().
                        return nil, status.FromContextError(ctx.Err()).Err()
                default:
                }
                // ... порция работы: собрать позиции, посчитать сумму ...
        }

        return s.orders[req.Id], nil
}
```

status.FromContextError — функция из пакета status, которая превращает ошибку контекста в статусный код: context.DeadlineExceeded становится DeadlineExceeded, context.Canceled — Canceled, остальное — Unknown. Хендлер мог бы просто вернуть ctx.Err() как есть — клиент всё равно получит корректные коды, библиотека конвертирует не-статусные ошибки контекста сама, — но явный FromContextError делает намерение читаемым. Важно понимать симметрию: если хендлер игнорирует ctx.Done(), он доработает до конца, но его WriteStatus уйдёт в закрытый поток — потрачены процессор, память и соединение.

### Хендлер не уложился. Что увидит клиент?

```go
// Хендлер GetOrder делает time.Sleep(5 * time.Second)
// и затем возвращает заказ.
//
// Клиент вызвал с таймаутом в две секунды:

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

_, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
fmt.Println("код:", status.Code(err))
```

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

- код: OK — сервер через пять секунд всё-таки вернёт ответ
- ✅ код: DeadlineExceeded — клиент уйдёт по дедлайну, не дожидаясь
- код: Canceled — отмену вызову пошлёт сервер
- Программа заблокируется, пока хендлер не проснётся

Клиент не ждёт дольше дедлайна: на третьей секунде Invoke вернёт статусную ошибку DeadlineExceeded — ответ сервера, который придёт через две секунды после этого, принимать уже некому. Хендлер тем временем продолжит спать и отправит WriteStatus в закрытый поток: без проверки ctx.Done() сервер не узнает, что работал впустую. Это же предсказание объясняет, почему таймаут ставят каждый вызову, а не «один на программу».

### Простые ошибки: полный путь status.Error

Клиентская половина разбора ошибок у вас уже есть — status.Code из прошлого урока. Теперь соберём серверную половину и весь путь целиком. Хендлер возвращает error — дальше слово за библиотекой. В processRPC grpc-go звонит status.FromError(appErr): если ошибка статусная — она же и едет в WriteStatus, то есть в trailers grpc-status/grpc-message. Если не статусная — включается status.FromContextError: context.DeadlineExceeded превратится в код DeadlineExceeded, context.Canceled — в Canceled, а всё прочее — в codes.Unknown с текстом исходной ошибки в grpc-message. Из этой цепочки следуют три практических правила. Правило первое: вернули status.Error(codes.NotFound, ...) — клиент получит NotFound; вернули errors.New или fmt.Errorf — клиент получит Unknown и не сможет ветвиться. Правило второе, тоньше: обёртка fmt.Errorf с %w поверх статусной ошибки код сохраняет — FromError ищет статус в цепочке через errors.As по интерфейсу GRPCStatus, — однако текст сообщения в trailers заменится на текст внешней обёртки: клиент увидит и код, и ваш контекст. Правило третье: достать читаемое сообщение на клиенте — status.Convert(err).Message(), конвертация возвращает *status.Status с методами Code и Message. Полная анатомия кодов — какие существуют, что означает каждый, как прикладывать детали через WithDetails и RetryInfo — это тема отдельного урока про статус-коды; здесь важно, что даже «простые ошибки» — это структурированные статусы на проводе, а не строки в логах.

### Три возврата из хендлера — три судьбы на клиенте

```go
// Вариант 1: честный статусный код.
// Клиент видит codes.InvalidArgument и может отреагировать.
if len(req.Items) == 0 {
        return nil, status.Error(codes.InvalidArgument, "заказ без позиций не принимаем")
}

// Вариант 2: обычная ошибка. Клиент получит codes.Unknown,
// а в grpc-message — текст этой ошибки: ветвиться по коду нельзя.
if err := s.payments.Hold(ctx, amount); err != nil {
        return nil, fmt.Errorf("платёжный холт не прошёл: %v", err)
}

// Вариант 3: обёртка %w поверх статуса. Код сохранится (NotFound),
// но grpc-message заменится на текст обёртки:
// «GetOrder(42): заказ не найден».
order, ok := s.lookup(req.Id)
if !ok {
        return nil, fmt.Errorf("GetOrder(%d): %w", req.Id,
                status.Error(codes.NotFound, "заказ не найден"))
}
```

Разница между вариантами 2 и 3 — в одном символе: %v рвёт связь с статусом, %w сохраняет её через errors.As, и status.Code на клиенте различит NotFound от Unknown. Но контекст ошибки всё равно полезнее кода: лучше сразу вернуть status.Errorf с осмысленным сообщением, чем надеяться на обёртки. Обратите внимание: вариант 1 — ровно та строка, которую вы писали в серверном уроке; теперь вы знаете её полный путь до trailers клиента.

### Проверка: обычная ошибка из хендлера

**Вопрос:** Хендлер вернул errors.New("склад недоступен") без статуса. Что получит клиент?

- codes.Internal — библиотека классифицирует серверную ошибку
- ✅ codes.Unknown с текстом «склад недоступен» в grpc-message
- codes.Unavailable — соединение со складом потеряно
- Ошибки не будет: текст уедет в поле ответа Order

status.FromError не найдёт в ошибке статус, FromContextError не найдёт ошибку контекста — сработает запасной путь: codes.Unknown с err.Error() в качестве сообщения. Unavailable требует, чтобы его вернул кто-то явно; Internal для этого случая не предусмотрен. Отсюда правило: всё, что клиент должен различать, покидает хендлер только в статусной упаковке.

> **Unknown — код-мусорка, не мусорьте в нём.** Unknown собирает всё, что не статус и не ошибка контекста: и забытые errors.New на сервере, и не-статусные ошибки на клиенте — status.Code вернёт Unknown для любой обычной Go-ошибки. По нему невозможно принять решение: ни повторить, ни показать «нет такого», ни спрятать. Держите границу: наружу из хендлера — только status.Error/status.Errorf, обычные ошибки — внутрь кода, с конвертацией на границе.

### Ретраи unary: что повторять и что нет

Сеть сбоит мимолётно: сервер перезапускается, соединение дрейфует между машинами, балансировщик меняет адрес — и unary-вызов, который вчера прошёл, сегодня падает с codes.Unavailable. Повторить его руками вы уже умеете — ветка Unavailable в клиентском switch из прошлого урока прямо просится стать повтором. Но у grpc-go есть собственный механизм: клиент умеет повторять неудачные попытки сам, если для метода задана retryPolicy в service config — конфигурации канала в JSON или через API. Поля политики: maxAttempts — потолок попыток, включая первую (не меньше двух); initialBackoff, maxBackoff и backoffMultiplier — экспоненциальная задержка с жеребьёвкой; retryableStatusCodes — набор кодов, достойных повтора, в строковом виде вроде "unavailable". Потолок попыток на весь канал — пять по умолчанию (константа defaultMaxCallAttempts, настраивается опцией WithMaxCallAttempts), исчерпание возвращает ошибку с текстом «max retries exhausted». И здесь упираемся в главное слово unary-повторов — идемпотентность. GetOrder повторять безопасно: чтение, хоть сто раз, ответ один. CreateOrder — нельзя: каждая попытка создаёт заказ, и ретрай превратит «временный сбой сети» в «двойной заказ у клиента». Ссылки на идемпотентность в политике повтора, фильтры необратимых операций и service config целиком — материал отдельного урока про ретраи; сегодня достаточно усвоить правило: повтор включают не для всех методов подряд, а для тех, у которых повтор безопасен.

### Проверка: код, достойный ретрая

**Вопрос:** Какой код чаще всего включают в retryableStatusCodes?

- codes.OK
- codes.NotFound
- ✅ codes.Unavailable
- codes.InvalidArgument

Unavailable означает «в этот раз не достучались» — рестарт, обрыв, перегрузка; повтор позже имеет смысл, и это классический код ретрая. NotFound — бизнес-ответ «такого заказа нет»: повтор с тем же запросом вернёт то же самое. InvalidArgument — ошибка самого запроса: пока клиент не изменит данные, повтор бесполезен.

### Чек-лист unary-вызова

- Ровно одно сообщение в каждую сторону: лишний ответ клиент засчитает как cardinality violation и вернёт codes.Internal.
- Дедлайн на каждый вызов: context.WithTimeout + defer cancel(); тот же срок уезжает в заголовок grpc-timeout.
- Сервер уважает дедлайн: долгая работа — порциями, между ними проверка ctx.Done(), иначе ресурсы горят впустую.
- Наружу из хендлера — только status.Error/status.Errorf: обычная ошибка приедет клиентом как codes.Unknown.
- Клиент ветвится по status.Code: NotFound, InvalidArgument, Unavailable и DeadlineExceeded — это разные реакции.
- Повтор — только для идемпотентных методов: GetOrder можно, CreateOrder нельзя.

### Соберите статусный возврат и разбор кода

Восстановите фрагмент: статусный возврат на сервере, таймаут и ветвление по кодам на клиенте.

```go
// Сервер: отсутствующий заказ — нормальная ситуация, не сбой.
if _, ok := s.orders[req.Id]; !ok {
        return nil, ___.Errorf(codes.___, "заказ %d не найден", req.Id)
}

// Клиент: срок на вызов и разбор кода.
ctx, cancel := context.___(context.Background(), 2*time.Second)
defer cancel()

order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
if err != nil {
        switch ___.Code(err) {
        case codes.___:
                fmt.Println("не успели за дедлайн")
        case codes.___:
                fmt.Println("сервис недоступен, повторите позже")
        default:
                log.Fatalf("вызов не удался: %v", err)
        }
        return
}
```

Подсказка: Статусы создаёт status.Errorf, код извлекает status.Code; срок ставит context.WithTimeout; просроченный вызов — codes.DeadlineExceeded, недоступный сервис — codes.Unavailable.

Правильные ответы в пропусках: `status`, `NotFound`, `WithTimeout`, `status`, `DeadlineExceeded`, `Unavailable`

### Путь unary-вызова на уровне транспорта

Расставьте по порядку события вызова GetOrder — от установки дедлайна до возврата из Invoke, включая кадры и trailers.

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

1. `context.WithTimeout — клиент ставит дедлайн вызова`
2. `client.GetOrder(ctx, req) — заглушка зовёт Invoke и CloseSend`
3. `HEADERS: :path /shop.v1.OrderService/GetOrder, content-type application/grpc, grpc-timeout 2S`
4. `DATA: префикс (флаг сжатия + длина) и байты GetOrderRequest, END_STREAM`
5. `Сервер: дедлайн уже в ctx хендлера — библиотека вызывает ваш метод`
6. `DATA: сервер отправляет ровно одно сообщение-ответ`
7. `trailers: grpc-status (0 или код ошибки) и grpc-message`
8. `Клиент: второй RecvMsg забирает trailers — Invoke возвращает (*pb.Order, error)`

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

Одно честное замечание про формат: у самоучителя есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять сервер и постучаться в него оттуда невозможно, поэтому runner-ов в этом уроке и во всём gRPC-курсе нет. Всё, что здесь разобрано, проверяется локально за минуты: сервер из [урока про первый gRPC-сервер](/courses/grpc-go/lessons/04-server/), клиент из [урока про gRPC-клиент](/courses/grpc-go/lessons/05-klient/), команда go run — и unary-вызов вживую: попробуйте послать grpc-timeout на две секунды в хендлер, который спит пять. Интерактивы (quiz, predict, fill, order, flashcards) — для закрепления теории. Дальше по маршруту: седьмой урок — server streaming, где WatchOrders из нашей схемы перестанет быть заглушкой Unimplemented; следом — client streaming и bidi, и таблица из этого урока оживёт строка за строкой. Дедлайны в глубину — распространение по цепочке сервисов и WithDeadline — в уроке про дедлайны и отмены; статус-коды с деталями и WithDetails — в уроке про обработку ошибок; ретраи с service config — в уроке про повторы. Полный маршрут курса — на [странице gRPC-Go](/courses/grpc-go/).

### Карточки: unary под микроскопом

- **Unary (запрос-ответ)** — Один Request → один Response, строго 1:1: лишнее сообщение клиент встречает кодом Internal («cardinality violation»). Внутри Invoke: SendMsg → CloseSend → RecvMsg → второй RecvMsg за trailers.
- **grpc-timeout** — Заголовок запроса с дедлайном: число и единица n/u/m/S/M/H. Две секунды — «2S», 250 миллисекунд — «250m». Шлётся всегда; сервер видит тот же срок в ctx.Deadline().
- **grpc-status / grpc-message** — Trailers после тела ответа: код — десятичная строка (0 = OK), сообщение — в percent-кодировке, невалидный UTF-8 заменяется. Детали статуса едут в grpc-status-details-bin (base64).
- **codes.Unknown** — Код-мусорка: хендлер вернул не-статусную ошибку. Обёртка fmt.Errorf с %w код сохраняет, но заменяет сообщение; наружу из хендлера — только status.Error/status.Errorf.
- **DeadlineExceeded vs Canceled** — DeadlineExceeded — истёк срок вызова (клиент уходит, не дожидаясь сервера); Canceled — явная отмена контекста: пользователь ушёл или закрылся родительский вызов. Различаются через status.Code.
- **retryPolicy** — Service config на метод: maxAttempts (от 2), initialBackoff/maxBackoff/backoffMultiplier, retryableStatusCodes (обычно Unavailable). Потолок попыток — 5 (WithMaxCallAttempts). Неидемпотентные методы не повторяют.

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

- Unary — модель запрос-ответ: ровно одно сообщение в каждую сторону; в схеме заказов это CreateOrder и GetOrder, а WatchOrders — server streaming из седьмого урока.
- Типы вызовов различаются словом stream в .proto: без него — unary; сгенерированный Go-метод возвращает значение (Invoke) или стриминговый клиент: ServerStreamingClient, ClientStreamingClient, BidiStreamingClient.
- Unary-вызов на HTTP/2: HEADERS с :path и grpc-timeout, DATA с 5-байтовым префиксом и protobuf, END_STREAM; ответ — заголовки, одно сообщение и trailers с grpc-status/grpc-message.
- grpc-timeout шлётся всегда: две секунды — «2S», 250 миллисекунд — «250m»; сервер видит дедлайн в ctx хендлера и обязан проверять ctx.Done() в долгой работе.
- Клиент не ждёт дольше дедлайна: Invoke вернёт DeadlineExceeded, даже если сервер ответит позже; Canceled — явная отмена, а не просрочка.
- Хендлер возвращает статусные ошибки через status.Error/status.Errorf: обычная ошибка приедет клиентом как codes.Unknown; обёртка %w сохраняет код, но заменяет текст.
- Ретраи настраиваются service config'ом: retryableStatusCodes (обычно Unavailable), экспоненциальный бэкофф, потолок 5 попыток; повторять безопасно только идемпотентные методы.

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

Поднимите сервер из урока 04 и клиент из 05, затем три эксперимента. Первый: добавьте в GetOrder time.Sleep(5 * time.Second) и убедитесь, что клиент с таймаутом в 2 секунды получит DeadlineExceeded, а лог сервера покажет, что хендлер доработал впустую; затем добавьте проверку ctx.Done() и сравните. Второй: замените status.Errorf в ветке NotFound на fmt.Errorf и прочитайте на клиенте codes.Unknown; верните код обёрткой %w и посмотрите, что изменилось. Третий: напишите service config с retryPolicy для GetOrder (retryableStatusCodes: "unavailable"), остановите сервер и проверьте, как клиент дожидается его перезапуска в пределах пяти попыток.

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

### Чем unary отличается от стриминга на практике?

Unary — один запрос и один ответ: вызов выглядит как обычная функция. Стриминговые виды шлют поток сообщений в одном или обоих направлениях. Unary — дефолт; стриминг берут для неограниченных списков, событий в реальном времени и передачи больших данных по частям.

### Что получит клиент, если хендлер вернул обычную ошибку?

codes.Unknown с текстом ошибки в grpc-message. Чтобы клиент мог ветвиться по коду, возвращайте из хендлера статусные ошибки — status.Error или status.Errorf; обёртка fmt.Errorf с %w поверх статуса сохранит код, но подменит сообщение.

### Куда едет итоговый статус вызова?

В HTTP/2-trailers после тела ответа: grpc-status — номер кода десятичной строкой, grpc-message — сообщение в percent-кодировке; успешный вызов — grpc-status 0. Поэтому обратные прокси на пути gRPC-трафика обязаны поддерживать trailers.
