Содержание урока · 12 разделов
- grpc unary: модель запрос-ответ
- Типы вызовов: четыре модели обмена
- Типы вызовов: как выбирать
- grpc-status, grpc-message, grpc-timeout: заголовки протокола
- Таймауты unary: дедлайн едет вместе с запросом
- Простые ошибки: полный путь status.Error
- Ретраи unary: что повторять и что нет
- Чек-лист unary-вызова
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пять уроков позади, и главный результат этого пути стоит сформулировать честно: вы уже вызывали unary-методы, просто не называли их так. CreateOrder и GetOrder из схемы заказов — по одному сообщению на входе и на выходе, и обе пары «хендлер + вызов» вы написали своими руками: сервер — в уроке про первый gRPC-сервер, клиент — в уроке про gRPC-клиент. Оставалось небрежное чувство, что вызов — это какой-то магический чёрный ящик: байты уходят, структура возвращается, а что происходит между 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?
Прочитайте код и выберите, что он выведет:
// Клиент ставит срок вызова в 250 миллисекунд.
// Что сервер увидит в заголовке grpc-timeout?
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
_, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})Проверка: где едет итоговый статус
В каком HTTP/2-кадре клиент получает grpc-status успешного unary-ответа?
Таймауты 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 уйдёт в закрытый поток — потрачены процессор, память и соединение.
Хендлер не уложился. Что увидит клиент?
Прочитайте код и выберите, что он выведет:
// Хендлер 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))Простые ошибки: полный путь 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("склад недоступен") без статуса. Что получит клиент?
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?
Чек-лист 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 нельзя.
Соберите статусный возврат и разбор кода
Восстановите фрагмент: статусный возврат на сервере, таймаут и ветвление по кодам на клиенте.
Путь unary-вызова на уровне транспорта
Расставьте по порядку события вызова GetOrder — от установки дедлайна до возврата из Invoke, включая кадры и trailers.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше по курсу#
Одно честное замечание про формат: у самоучителя есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять сервер и постучаться в него оттуда невозможно, поэтому runner-ов в этом уроке и во всём gRPC-курсе нет. Всё, что здесь разобрано, проверяется локально за минуты: сервер из урока про первый gRPC-сервер, клиент из урока про gRPC-клиент, команда go run — и unary-вызов вживую: попробуйте послать grpc-timeout на две секунды в хендлер, который спит пять. Интерактивы (quiz, predict, fill, order, flashcards) — для закрепления теории. Дальше по маршруту: седьмой урок — server streaming, где WatchOrders из нашей схемы перестанет быть заглушкой Unimplemented; следом — client streaming и bidi, и таблица из этого урока оживёт строка за строкой. Дедлайны в глубину — распространение по цепочке сервисов и WithDeadline — в уроке про дедлайны и отмены; статус-коды с деталями и WithDetails — в уроке про обработку ошибок; ретраи с service config — в уроке про повторы. Полный маршрут курса — на странице gRPC-Go.
Карточки: unary под микроскопом
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки