---
title: "Server streaming"
description: "grpc server streaming в Go: один запрос, поток OrderEvent, Send на сервере, Recv до io.EOF на клиенте, stream.Context() и статусные ошибки из потока."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/07-server-streaming/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 22
---

# Server streaming

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

WatchOrders из схемы заказов всё это время отвечал клиенту вежливым отказом Unimplemented — сегодня заглушка оживёт. grpc server streaming — первый стриминговый тип вызовов после unary: клиент отправляет один запрос WatchRequest, а сервер вместо единственного ответа шлёт поток сообщений OrderEvent, пока не решит, что хватит. Разберём обе стороны провода: серверную — как реализовать метод с grpc.ServerStreamingServer[OrderEvent], чем правильно завершать поток и почему контекст стрима живёт в stream.Context() — и клиентскую — цикл Recv до io.EOF, разбор статусных ошибок, приезжающих из потока, и аккуратное отсоединение по контексту. Механика wire-уровня из шестого урока никуда не исчезает: grpc-timeout едет как прежде, итоговый статус по-прежнему в trailers — просто теперь они выходят после последнего сообщения.

Ровно такую строку вы видели в сгенерированном файле с первых уроков курса: rpc WatchOrders (WatchRequest) returns (stream OrderEvent) — и всё это время сервер отвечал на неё кодом Unimplemented, потому что реализованы были только CreateOrder и GetOrder. В [шестом уроке про unary](/courses/grpc-go/lessons/06-unary/) мы обещали, что WatchOrders перестанет быть заглушкой, — сегодня сдержим слово. Клиентская половина у вас почти готова: в [уроке про gRPC-клиент](/courses/grpc-go/lessons/05-klient/) уже мелькала сигнатура WatchOrders, возвращающая grpc.ServerStreamingClient[OrderEvent], — не хватало лишь цикла чтения. Серверную половину спроектируем с нуля: как отправлять события по одному, когда останавливаться, что вернёт Send, когда клиент ушёл, и почему у этого хендлера, в отличие от unary, вообще нет параметра ctx. Заодно закроем пару заблуждений, которые тянутся из туториалов: про io.EOF «на сервере» и про SendAndClose, который тут ровно ни при чём.

### grpc server streaming: один запрос, поток ответов

Server streaming — вторая из четырёх моделей обмена gRPC и первая стриминговая в курсе: клиент отправляет ровно одно сообщение-запрос, а сервер в ответ шлёт последовательность сообщений — ноль, одно или сколько пожелает, — после чего закрывает поток итоговым статусом. В .proto это слово stream справа от returns: rpc WatchOrders (WatchRequest) returns (stream OrderEvent) читается как «один WatchRequest на входе, поток OrderEvent на выходе». Русскоязычный запрос «стрим сервер go» описывает ровно эту конструкцию: поток ведёт сервер, клиент только слушает и в любой момент волен уйти. Кардинальность здесь не жёсткая, как в unary, а свободная, и это стоит проговорить через контраст: unary требовал ровно один ответ — лишнее сообщение каралось codes.Internal, а ноль ответов тоже считался нарушением; серверному стриму и ноль, и много сообщений — легальные сценарии, никаких проверок кардинальности на клиенте не включается. Подписка на события, за которую отвечает WatchOrders, потому и стриминг: заказ 42 сегодня проходит путь создан → оплачен → отгружен за минуту и порождает три события, а может зависнуть в статусе «оплачен» на сутки — одним сообщением такой ответ заранее не спланируешь. Server streaming — правильный инструмент для всех трёх поводов из таблицы шестого урока: события в реальном времени, ответы неограниченного размера и ответы, длина которых неизвестна в момент запроса. Два оставшихся повода — поток от клиента и полноценный дуплекс — достанутся восьмому и девятому урокам.

### Контракт WatchOrders: proto и сгенерированные подписи

```go
// api/shop/v1/order.proto — контракт из второго урока, без изменений.
service OrderService {
  // stream справа от returns — серверный стрим:
  // на входе один WatchRequest, на выходе поток OrderEvent.
  rpc WatchOrders (WatchRequest) returns (stream OrderEvent);
}

message WatchRequest {
  OrderStatus status = 1; // фильтр подписки: UNSPECIFIED — все события
}

message OrderEvent {
  Order order = 1; // заказ на момент события
  string kind = 2; // тип события: "created", "paid", "shipped"
}

// Сгенерированный order_grpc.pb.go — обе стороны одного метода.

// Клиент: контекст по-прежнему в вызове, вместо ответа — интерфейс стрима.
WatchOrders(ctx context.Context, in *WatchRequest, opts ...grpc.CallOption) (
    grpc.ServerStreamingClient[OrderEvent], error)

// Сервер: параметра ctx НЕТ — контекст вызова достаётся из стрима.
WatchOrders(*WatchRequest, grpc.ServerStreamingServer[OrderEvent]) error
```

Два отличия от unary видны сразу. Первое: у клиента контекст остался на месте — дедлайн ставится тем же context.WithTimeout, а вместо готового ответа метод возвращает интерфейс grpc.ServerStreamingClient[OrderEvent], через который ответы читаются по одному. Второе, и на этом спотыкается почти каждый: у серверного хендлера нет параметра ctx. Контекст вызова — с дедлайном клиента, отменой и метаданными — доступен только через stream.Context(). Это следствие генерации: интерфейс фиксирует сигнатуру, а сигнатура server-streaming метода — два аргумента, запрос и стрим. Псевдоним OrderService_WatchOrdersServer = grpc.ServerStreamingServer[OrderEvent] — просто старое имя нового дженерика, Generator оставил его ради обратной совместимости кода.

### Серверная сторона: Send, nil и никакого io.EOF

Серверный интерфейс grpc.ServerStreamingServer[OrderEvent] в дженериках живёт с grpc-go 1.64: раньше файл stream.go описывал лишь не-generic ServerStream с SendMsg(m any), а protoc-gen-go-grpc создавал под каждый метод собственный интерфейс вида OrderService_WatchOrdersServer с Send(*OrderEvent) error и встроенным grpc.ServerStream. Современный генератор опирается на обобщённые интерфейсы из stream_interfaces.go, и под старым именем теперь прячется псевдоним нового типа — код, написанный в старом стиле с Send(*OrderEvent), продолжает собираться без изменений. В интерфейсе два слоя: Send(*OrderEvent) error — «отправь очередное сообщение в поток» — и встроенный ServerStream с методами Context, SetHeader, SendHeader и SetTrailer. Документация формулирует контракт завершения дословно: «To terminate the response stream, return from the handler method and return an error from the status package, or use nil to indicate an OK status code» — вернул nil, поток закрылся со статусом OK; вернул ошибку из пакета status, клиент получит её код и сообщение. Из этого следует самое живучее заблуждение темы: io.EOF на сервере не нужен. io.EOF — это клиентский сигнал «поток кончился успешно», его возвращает Recv; хендлер не посылает никакого явного «конца» — конец это возврат из функции. По той же причине здесь не место SendAndClose: этот метод живёт в ClientStreamingServer и закрывает unary-ответ после потока запросов — его разбор честно ждёт восьмой урок про client streaming.

### Хендлер WatchOrders: подписка на события заказа

```go
// internal/server/order.go — WatchOrders больше не заглушка Unimplemented.
func (s *OrderServiceServer) WatchOrders(
        req *pb.WatchRequest, stream pb.OrderService_WatchOrdersServer) error {
        // Учебный источник событий: в реальном сервисе это шина или очередь,
        // куда CreateOrder и переходы статуса заказа 42 уже пишут.
        ch := make(chan *pb.OrderEvent, 16)
        s.subscribe(ch)
        defer s.unsubscribe(ch)

        for {
                select {
                case <-stream.Context().Done():
                        // Клиент ушёл: отменил контекст или истёк его дедлайн.
                        // Библиотека переведёт ошибку контекста в статус вызова.
                        return stream.Context().Err()

                case ev := <-ch:
                        // Фильтр подписки из запроса: UNSPECIFIED — все события.
                        if req.Status != pb.OrderStatus_ORDER_STATUS_UNSPECIFIED &&
                                ev.Order.GetStatus() != req.Status {
                                continue
                        }
                        // Каждое событие — отдельное сообщение в потоке.
                        if err := stream.Send(ev); err != nil {
                                // Стрим сломан (клиент отключился, сеть оборвалась):
                                // слать дальше некуда, возвращаем ошибку как есть.
                                return err
                        }
                        // Заказ дошёл до финального статуса — подписка исполнена.
                        if ev.Order.GetStatus() == pb.OrderStatus_ORDER_STATUS_SHIPPED {
                                return nil // успешный конец: клиент увидит io.EOF
                        }
                }
        }
}
```

Читаем по строкам: select с двумя ветками — сердце любого живого хендлера. Ветка Done срабатывает, когда контекст стрима закрыт, — без неё хендлер будет вечно читать канал, который никто не слушает, и GracefulStop из серверного урока никогда не дождётся. Ошибку Send мы не глотаем: после сбоя стрим непригоден, и документация прямо советует возвращаться. Финальный nil — не формальность, а сам контракт: именно он превращается в grpc-status 0 в trailers. Замените учебный канал на шину событий — каркас select + Send + nil останется тем же.

### stream.Context(): где живёт контекст стрима

Unary-хендлер получал контекст вторым аргументом, и дедлайн из заголовка grpc-timeout лежал в ctx.Deadline(). В server streaming параметра нет — это не потеря, а перенос: stream.Context() возвращает контекст именно этого вызова, со всем содержимым. Внутри всё знакомое по шестому уроку: дедлайн клиента, который уехал заголовком grpc-timeout, отмена, значения, peer. Отсюда главный вопрос проектирования подписок — отсоединение. Подписка — это обязательство сервера слать события, пока клиент слушает; клиент же может уйти в любой момент: закрыть приложение, вызвать cancel(), истечь дедлайном. Первое уведомление приходит в ветку <-stream.Context().Done(): хендлер обязан прекратить работу и вернуться. Если же он проспал момент и позвал Send после отсоединения — Send вернёт ошибку, и это будет статусная ошибка codes.Canceled с сообщением «context canceled»: транспорт видит закрытый HTTP/2-поток, streamContextErr разворачивает ошибку контекста в статус, а toRPCErr пропускает статусные ошибки как есть. Кода Internal здесь не будет — уход клиента для стрима не авария, а штатная старость: Internal зарезервирован для неожиданных поломок самой библиотеки. Любопытная деталь исходников: отложенный код в SendMsg при неудаче сам записывает этот статус итогом вызова — хендлеру даже возвращаться не обязательно, чтобы клиент узнал правду. И напомним предохранитель из серверного урока: вечный стрим без дедлайна не даст GracefulStop вернуться — подписки обязаны уметь кончаться.

### Клиент оборвал соединение. Что вернёт Send?

```go
// Хендлер читает события из канала и шлёт их клиенту.
// Клиент в этот момент уже вызвал cancel() и вышел из программы.

select {
case <-stream.Context().Done():
        fmt.Println("контекст закрыт")
case ev := <-ch:
        err := stream.Send(ev)
        fmt.Println(status.Code(err))
}
```

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

- OK — сообщение ушло в буфер, ошибки нет
- ✅ Canceled — Send вернул статус codes.Canceled («context canceled»)
- Internal — стрим закрыт, Send фиксирует внутреннюю ошибку библиотеки
- Панику: после закрытия контекста Send звать нельзя

Транспорт видит закрытый HTTP/2-поток и возвращает ошибку контекста; streamContextErr конвертирует context.Canceled в статус codes.Canceled, toRPCErr оставляет статусную ошибку как есть — её и получает хендлер. Паники нет: Send можно звать сколько угодно, но после первой ошибки стрим мёртв и каждое следующее чтение канала — трата процессора. Internal здесь не появится: категория «внутреннее» — для поломок библиотеки, а уход клиента — нормальная судьба подписки. Заметьте: даже если хендлер проигнорирует ошибку и вернёт nil, клиент ничего не увидит — вызов уже закрыт, статус Canceled уехало итогом.

### Проверка: чем завершить поток

**Вопрос:** Сервер отправил все события. Как правильно закрыть server-streaming вызов?

- stream.SendAndClose(nil) — закрыть стрим и подтвердить успех
- ✅ return nil — обычный возврат из хендлера
- return io.EOF — сообщить клиенту, что данных больше нет
- Вызвать stream.CloseSend() — закрыть отправляющую сторону сервера

Возврат из хендлера и есть закрытие потока: nil даст grpc-status 0, клиентский Recv вернёт io.EOF, и цикл завершится штатно. SendAndClose — метод ClientStreamingServer из client streaming: он закрывает unary-ответ после потока ЗАПРОСОВ, в server streaming его просто нет в интерфейсе. CloseSend — клиентский метод; у стрима клиента в server streaming отправляющей стороны по сути нет. io.EOF из хендлера — антипаттерн: библиотека не распознает его как статус и конвертирует в codes.Unknown с текстом «EOF».

### Клиентская сторона: Recv в цикле до io.EOF

Клиентский интерфейс зеркально лаконичен: grpc.ServerStreamingClient[OrderEvent] — один метод Recv() (*OrderEvent, error) плюс унаследованные Header, Trailer и Context. Каждый вызов Recv блокируется до следующего сообщения и возвращает его; документация интерфейса сводит все исходы к двум. Первый: «If io.EOF is returned, the stream has terminated with an OK status» — сервер вышел с nil, все сообщения доставлены, подписка закрыта успешно. Второй: «Any other error is compatible with the status package and indicates the RPC's status code and message» — любая другая ошибка статусная, в ней код и сообщение вызова, и ветвиться по ним нужно через status.Code, как вы уже делаете с unary-вызовами. Идиома чтения идёт из официального примера route_guide в репозитории grpc-go: бесконечный for, внутри две проверки — err == io.EOF означает чистый выход из цикла, прочая ошибка означает разбор и завершение. После первой не-EOF ошибки звать Recv снова бессмысленно: вызов завершён, ошибка будет та же. Под капотом GenericClientStream.Recv — тонкая обёртка над ClientStream.RecvMsg: вся транспортная машинерия из шестого урока — пятибайтовые префиксы длины, окна, trailers — крутится тем же кодом, что и в unary; разница в том, что RecvMsg здесь зовут добровольно и много раз, а не два раза под капотом Invoke.

### Цикл чтения: io.EOF — не ошибка

```go
// cmd/client/main.go: подписка на события заказа.
// ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer cancel()
//
// stream, err := client.WatchOrders(ctx, &pb.WatchRequest{
//         Status: pb.OrderStatus_ORDER_STATUS_UNSPECIFIED, // все события
// })
// if err != nil {
//         log.Fatalf("WatchOrders: %v", err) // вызов даже не открылся
// }

for {
        ev, err := stream.Recv()
        switch {
        case err == io.EOF:
                // Сервер вернул nil: поток завершён со статусом OK.
                fmt.Println("сервер закрыл поток: событий больше не будет")
                return
        case err != nil:
                // Статусная ошибка из потока: NotFound, Canceled,
                // DeadlineExceeded — ветвимся по коду, как в unary.
                log.Fatalf("подписка прервана: %v (код %v)", err, status.Code(err))
        }
        fmt.Printf("событие %s: заказ %d в статусе %s
",
                ev.GetKind(), ev.GetOrder().GetId(), ev.GetOrder().GetStatus())
}
```

Каркас совпадает с printFeatures официального примера route_guide — в репозитории grpc-go этот цикл служит эталоном чтения стрима уже много лет. Порядок проверок важен: io.EOF — не ошибка, а штатный конец, поэтому проверяется первым; status.Code(err) достанет код из статусной ошибки для журнала и решений. Геттеры GetKind и GetOrder — сгенерированные nil-безопасные помощники protobuf из урока про синтаксис: без них каждое чтение поля пришлось бы защищать проверкой на nil.

### Как код ошибки доезжает из середины потока

Разбор ошибок Recv стоит один раз увидеть изнутри, потому что статус в стриминге доезжает не сразу. Пока сервер шлёт сообщения, клиенту едет чистый protobuf; итоговый статус — grpc-status и grpc-message в trailers, как в шестом уроке, — записывается в самом конце, после последнего сообщения потока. Функция recv в stream.go разворачивает это в три ветки. Первая: транспорт сообщил io.EOF — данные кончились; библиотека читает Status() стрима, и если там не OK, возвращает именно эту статусную ошибку — это случай, когда сервер вернул status.Error до первого сообщения или посреди потока. Вторая: статус оказался OK — Recv возвращает io.EOF, клиент спокойно выходит из цикла; для server-streaming методов пустой поток легален, поэтому проверки кардинальности не включаются вовсе — знаменитое «cardinality violation: received no response message from non-server-streaming RPC» срабатывает только когда ServerStreams: false, то есть в unary. Третья ветка: сбой транспорта посреди потока — его тянет через toRPCErr, и клиент получает статусные Unavailable или Unknown вместо тихого зависания. Практическое следствие одно, но важное: статусная ошибка может вылететь на любом Recv — и на первом, если сервер отказал сразу, и на десятом, если посреди подписки истёк дедлайн или упала сеть. Клиентский цикл обязан уметь завершиться в любой момент, а коды обработки ошибок не должны предполагать, что «до первой ошибки всё точно дошло».

### Сервер отказал до первого события

```go
// Хендлер: доступ запрещён — стрим открыт, сообщений ещё не было.
func (s *OrderServiceServer) WatchOrders(
        req *pb.WatchRequest, stream pb.OrderService_WatchOrdersServer) error {
        if !s.canWatch(req) {
                return status.Error(codes.PermissionDenied,
                        "нет доступа к событиям заказов")
        }
        // ...
}

// Клиент: первое же чтение из свежего стрима.
ev, err := stream.Recv()
```

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

- io.EOF — поток закрыт без ошибок
- nil-событие и nil-ошибку: сообщение потерялось по дороге
- ✅ Статусную ошибку codes.PermissionDenied с текстом с сервера
- Обычную ошибку без кода — разбирать придётся строку

recv видит конец данных, читает итоговый статус из trailers — там PermissionDenied с сообщением сервера — и возвращает его самым первым вызовом Recv: до того как поток успел доставить хоть одно сообщение. io.EOF зарезервирован для статуса OK — смешивать «конец» и «отказ» библиотека не станет. Отсюда правило клиентского цикла: проверка err != nil обязана стоять ДО обработки события, потому что отказ может прийти раньше первого сообщения.

### Проверка: пустой поток

**Вопрос:** Сервер немедленно вышел с return nil, не послав ни одного OrderEvent. Что увидит клиент?

- codes.Internal: cardinality violation — ответов было ноль
- ✅ Первый Recv вернёт io.EOF: пустой поток — легальный ответ серверного стрима
- codes.NotFound: пустой ответ считается потерей данных
- Вызов зависнет: без сообщений итоговый статус не записывается

Проверка «received no response message from non-server-streaming RPC» срабатывает только для методов с ServerStreams: false — то есть для unary из шестого урока. Server-streaming хендлеру ноль сообщений разрешён: подписка, отфильтровавшая все события, — нормальный результат, а не поломка. Клиент получит io.EOF на первом же Recv и выйдет из цикла; статус в trailers при этом честно записывается — grpc-status 0.

> **Вернёте io.EOF из хендлера — клиент получит Unknown.** Соблазн вернуть io.EOF «чтобы клиент вышел из цикла» понятен и вреден. Библиотека вызывает status.FromError: io.EOF — не статусная ошибка; дальше status.FromContextError: это не DeadlineExceeded и не Canceled — итог codes.Unknown с текстом «EOF». Клиент, который честно ждёт io.EOF как знак успеха, уйдёт в ветку ошибки и, возможно, начнёт ретраи там, где никакой ошибки не было. На сервере сигнал конца потока ровно один — return nil (или статусная ошибка для неуспеха); io.EOF живёт на другой стороне провода, в клиентском Recv.

### Unary и server streaming: шесть различий

| Вопрос | Unary (шестой урок) | Server streaming (этот урок) |
| --- | --- | --- |
| Схема | rpc GetOrder (…) returns (Order) | rpc WatchOrders (…) returns (stream OrderEvent) |
| Хендлер | GetOrder(ctx, req) (*pb.Order, error) | WatchOrders(req, stream) error — контекст в stream.Context() |
| Сколько ответов | ровно один: ноль или два — cardinality violation | ноль и больше: пустой поток легален |
| Отправка | ответ — возврат из хендлера | stream.Send(событие) на каждое сообщение |
| Завершение | return order, nil | return nil — OK; return status.Error(…) — отказ |
| Сигнал конца у клиента | ответ как результат Invoke | io.EOF из stream.Recv() |

### Send под капотом: flow control и потерянные хвосты

Send выглядит как fire-and-forget, но у него есть характер, и документация проговаривает его в три предложения. Первое: SendMsg блокируется, пока не появится достаточный flow control — место в HTTP/2-окне, чтобы поставить сообщение в транспорт; медленный клиент и объёмные сообщения делают хендлер медленным самим по себе, без всяких искусственных задержек: если получатель не читает, отправка упрётся в окно и встанет. Второе: «SendMsg does not wait until the message is received by the client» — «ушло в транспорт» не значит «получено»; подтверждений доставки на уровне библиотеки нет. Третье, важнейшее для дизайна: «An untimely stream closure may result in lost messages» — несвоевременное закрытие потока может потерять сообщения: клиент ушёл, а хендлер уже отдал последние три события в буфер. Отсюда архитектурное правило: стрим годится для событий, прогресса и логов, где каждое сообщение самодостаточно, и не годится для сценариев вида «последнее сообщение — самое важное»: итог вызова живёт в статусе trailers, а не в последнем сообщении потока. Технические границы из того же исходника: сообщение больше maxSendMessageSize вернёт codes.ResourceExhausted с текстом «trying to send message larger than max», а звать Send из двух горутин на одном стриме запрещено — конкурентная отправка это территория девятого урока про bidi.

### Проверка: что гарантирует Send

**Вопрос:** stream.Send(ev) вернул nil. Что это означает?

- Клиент получил сообщение и успешно обработал его
- ✅ Сообщение закодировано и записано в транспорт: место в окне нашлось; доставка не гарантирована
- Сообщение поставлено в очередь и может быть отброшено даже при живом стриме
- Стрим закрыт, хендлер может возвращаться

Документация SendMsg: блокируется до достаточного flow control, не ждёт получения клиентом, а несвоевременное закрытие потока может потерять сообщения. nil означает лишь, что сообщение закодировано и вписано в транспорт. Целостность итога вызова гарантирует статус в trailers, а не счётчик доставленных сообщений — поэтому важные результаты кладут в статус или отдельный unary-вызов, а не в хвост стрима.

### Сквозной клиент: подписка с дедлайном

```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(), 10*time.Second)
        defer cancel()

        stream, err := client.WatchOrders(ctx, &pb.WatchRequest{})
        if err != nil {
                log.Fatalf("WatchOrders: %v", err)
        }

        for {
                ev, err := stream.Recv()
                switch {
                case err == io.EOF:
                        // Сервер вернул nil — все события доставлены.
                        fmt.Println("сервер закрыл поток со статусом OK")
                        return
                case err != nil:
                        // Свой же дедлайн тоже приезжает статусной ошибкой:
                        // DeadlineExceeded — истёк срок, Canceled — отменили.
                        fmt.Printf("подписка прервана: код %v
", status.Code(err))
                        return
                }
                fmt.Printf("[%s] заказ %d → %s
",
                        ev.GetKind(), ev.GetOrder().GetId(), ev.GetOrder().GetStatus())
        }
}
```

Симметрия, ради которой всё затевалось. Клиент ставит дедлайн — сервер видит его в stream.Context(); сервер возвращает nil — клиент выходит по io.EOF; клиент уходит по cancel() или дедлайну — сервер ловит ctx.Done(), а его следующий Send приносит Canceled. Одна и та же ошибка контекста честно видна обеим сторонам — это и есть отсоединение по ctx в полном цикле. Для вечной подписки вместо WithTimeout берут context.WithCancel и отменяют по пользовательскому решению или сигналу процесса — механика чтения не меняется ни на символ.

### Чек-лист server streaming

- Схема: stream справа от returns — один запрос, поток ответов; ноль сообщений легален.
- Сервер шлёт через stream.Send(*ev) и завершает вызов возвратом: nil — OK, статусная ошибка — отказ.
- io.EOF — клиентский сигнал конца; вернёте его из хендлера — клиент получит codes.Unknown с текстом «EOF».
- Контекст вызова — stream.Context(): дедлайн и отмена клиента; проверяйте Done() между Send.
- Send после отсоединения вернёт статус (обычно codes.Canceled) — дальше слать бессмысленно, возвращайтесь.
- Клиент: Recv в цикле до io.EOF; прочая ошибка — статусная, ветвьтесь по status.Code.
- Send не ждёт доставки: обрыв может потерять хвост — важный итог кладите в статус вызова, а не в последнее сообщение.

### Соберите клиентский цикл подписки

Восстановите клиентскую половину WatchOrders: дедлайн, вызов и чтение потока до конца.

```go
ctx, cancel := context.___(context.Background(), 10*time.Second)
defer cancel()

stream, err := client.___(ctx, &pb.WatchRequest{})
if err != nil {
        log.Fatalf("WatchOrders: %v", err)
}

for {
        ev, err := stream.___()
        if err == ___.EOF {
                break // успешный конец потока
        }
        if err != nil {
                log.Fatalf("код: %v", ___.Code(err))
        }
        fmt.Println(ev.GetKind(), ev.GetOrder().GetId())
}
```

Подсказка: Срок ставит context.WithTimeout; метод клиента — WatchOrders; читает поток Recv; конец со статусом OK приносит io.EOF; код извлекает status.Code.

Правильные ответы в пропусках: `WithTimeout`, `WatchOrders`, `Recv`, `io`, `status`

### Жизненный цикл server-streaming вызова

Расставьте события подписки WatchOrders — от вызова клиента до чистого завершения обеих сторон.

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

1. `Клиент: client.WatchOrders(ctx, req) — grpc-timeout уезжает в заголовках, как у unary`
2. `Сервер: библиотека зовёт WatchOrders(req, stream); контекст доступен через stream.Context()`
3. `Сервер: stream.Send(событие) — с первым сообщением уезжают заголовки ответа`
4. `Клиент: stream.Recv() возвращает (*pb.OrderEvent, nil) — очередное событие`
5. `Сервер: события кончились — хендлер возвращает nil`
6. `Сервер: библиотека пишет trailers grpc-status 0 — после последнего сообщения`
7. `Клиент: очередной Recv возвращает io.EOF — успешный конец потока`
8. `Клиент: цикл завершён, подписка закрыта — ресурсы обеих сторон освобождены`

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

Одно честное замечание про формат: у самоучителя есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять сервер и читать из него стрим оттуда невозможно, поэтому runner-ов в этом уроке и во всём gRPC-курсе нет. Всё, что здесь разобрано, проверяется локально за минуты: сервер из урока 04, клиент из [урока про gRPC-клиент](/courses/grpc-go/lessons/05-klient/) — и WatchOrders оживёт: попробуйте отменить контекст после второго события и посмотрите, что вернёт Send на сервере. Дальше по маршруту: восьмой урок — client streaming, поток разворачивается в другую сторону, клиент шлёт последовательность сообщений, сервер отвечает одним итогом через SendAndClose — и его вы уже не спутаете с завершением серверного стрима; следом девятый урок — bidi, где обе стороны говорят одновременно. Метаданные и токены авторизации — в уроке про metadata, глубокие дедлайны и распространение отмены — в уроке про дедлайны. А [механику unary — grpc-timeout, trailers и статусные ошибки](/courses/grpc-go/lessons/06-unary/) полезно перечитать перед восьмым уроком: стриминг построен ровно на ней, только сообщений стало много. Полный маршрут курса — на [странице gRPC-Go](/courses/grpc-go/).

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

- **Server streaming** — Один запрос → поток ответов: rpc WatchOrders (WatchRequest) returns (stream OrderEvent). Ноль и больше сообщений легальны; итог вызова — статус в trailers после последнего сообщения.
- **grpc.ServerStreamingServer[OrderEvent]** — Серверный интерфейс (дженерики с grpc-go 1.64): Send(*OrderEvent) error плюс встроенный ServerStream. Завершение — return nil (OK) или статусная ошибка; SendAndClose тут не нужен — это client streaming.
- **Recv до io.EOF** — Клиент: stream.Recv() в цикле. io.EOF — поток завершён со статусом OK; любая другая ошибка — статусная, с кодом и сообщением вызова. После не-EOF ошибки Recv не повторяют.
- **stream.Context()** — Контекст server-streaming вызова: дедлайн клиента, отмена, метаданные. У хендлера нет параметра ctx — только стрим. Done() сигнализирует, что клиент ушёл.
- **Send после отсоединения** — Вернёт статус codes.Canceled («context canceled»): транспорт видит закрытый поток. Библиотека сама запишет этот статус итогом вызова; продолжать слать бессмысленно — возвращайтесь.
- **Send не ждёт доставки** — Блокируется до места в flow control, но не ждёт получения клиентом; обрыв может потерять последние сообщения. Сообщение больше maxSendMessageSize — codes.ResourceExhausted.

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

- Server streaming — один запрос, поток ответов: в .proto слово stream справа от returns; WatchOrders наконец реализован вместо заглушки Unimplemented.
- Сервер: stream.Send(*OrderEvent) на каждое событие; завершение — return nil (клиент получит io.EOF) или статусная ошибка; SendAndClose не нужен — это client streaming.
- io.EOF — клиентский сигнал конца потока; на сервере он превращается в codes.Unknown: конец обозначают nil, а не io.EOF.
- Контекст вызова живёт в stream.Context(): дедлайн и отмена клиента видны серверу; Send после отсоединения вернёт codes.Canceled.
- Пустой поток легален: проверка cardinality violation включается только для не-стримовых методов; подписка без событий — нормальный ответ.
- Клиент: Recv в цикле, io.EOF — чистый выход, прочая ошибка — статусная: код приходит и до первого сообщения, и посреди потока.
- Send блокируется на flow control и не гарантирует доставки: обрыв может съесть хвост — важный итог кладите в статус вызова.

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

Оживите WatchOrders по раскладке серверного урока. Первый шаг: реализуйте хендлер с каналом событий и select по stream.Context().Done(), отправьте три события заказа 42 — created, paid, shipped — и убедитесь, что клиент читает их циклом Recv и выходит по io.EOF. Второй: поставьте клиенту таймаут в одну секунду, а серверу — паузу в три секунды между событиями: клиент получит DeadlineExceeded, а сервер увидит ctx.Done() и вернётся из хендлера; затем замените таймаут на context.WithCancel, отмените подписку вручную и прочитайте codes.Canceled в Send на сервере. Третий: верните из хендлера io.EOF вместо nil и посмотрите на клиенте codes.Unknown с текстом «EOF»; верните nil и сравните. Наконец, добавьте фильтр по req.Status: подпишитесь только на ORDER_STATUS_PAID и проверьте пустой поток — клиент должен выйти по io.EOF, не получив ни одного события.

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

### Чем server streaming отличается от repeated-поля в ответе?

Repeated собирает весь список в одно сообщение: упрётесь в потолок размера сообщения и будете ждать конца всего списка. Server streaming шлёт каждое событие отдельным сообщением сразу, как только оно готово, количество сообщений заранее не ограничено, а клиент может прервать подписку в любой момент.

### Почему у хендлера нет параметра ctx?

Сгенерированная сигнатура server-streaming метода — запрос и стрим, без контекста. Контекст вызова доступен через stream.Context(): там дедлайн клиента из grpc-timeout, отмена и метаданные. Это отличие от unary, где ctx приходит вторым аргументом.

### Нужно ли клиенту звать CloseSend после серверного стрима?

Нет. В server streaming клиент не отправляет поток, и документация интерфейса прямо просит не трогать прочие методы ClientStream. Подписку закрывает сервер возвратом nil, а клиент — отменой контекста или выходом из цикла Recv; CloseSend нужен в client streaming, чтобы пометить конец последовательности запросов.
