Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 14 из 25 ~20 мин +50 XP

Дедлайны и отмены

grpc deadline — это не настройка «таймаут 2 секунды» где-то в конфиге, а часть контракта вызова, которая едет по сети вместе с запросом и работает на обоих концах провода. Шестой урок показал основы: context.WithTimeout перед вызовом, заголовок grpc-timeout, проверка ctx.Done() в хендлере — и обещал глубину позже. Здесь достаём её целиком: таймауты grpc-вызовов против дедлайнов-точек — WithTimeout возвращает WithDeadline от «сейчас», а дедлайн в контексте — всегда точка во времени; остаток времени на проводе и его пересчёт на сервере; stream.Context() и хендлер, который умеет кончаться; коды DeadlineExceeded и Canceled из каталога тринадцатого урока в роли рабочих инструментов; отмена клиентом через WithCancel и главное — каскад, когда RPC вызывает RPC и дедлайн сжимается от звена к звену. Плюс честная карта того, какие ошибки увидит клиент при обрыве, и одна оговорка про context.Value: метаданные из десятого урока — отдельный механизм, к срокам отношения не имеющий.

Содержание урока · 15 разделов

grpc deadline: дедлайн как часть контракта вызова#

В уроке про unary вы уже ставили таймаут на вызов: context.WithTimeout, defer cancel, Invoke вернул DeadlineExceeded — и шестнадцать строк кода казались всей темой. Но этот фрагмент прятал три вопроса, без которых дедлайны в проде не живут. Первый: что именно едет по сети, когда клиент ставит срок? Не «две секунды» как константа, а остаток времени до дедлайна, пересчитанный в момент отправки, — и на сервере этот остаток снова превращается в дедлайн от чужого «сейчас». Второй: кто и когда принудительно останавливает работу? Оказывается, минимум трое: клиентский Invoke, серверный транспорт со своим таймером и сам хендлер — и только последний из них опционален, остальные гарантированы библиотекой. Третий: что происходит в цепочке сервисов, когда один вызов порождает другой? Дедлайн не сбрасывается и не копится — он сжимается, и в этом сжатии участвует сам язык: правила контекстного дерева Go делают правильное поведение дефолтным. Четырнадцатый урок разбирает всё это по винтикам. Каталог тринадцатого урока дал нам два кода — DeadlineExceeded и Canceled; сейчас они станут не строчками в таблице, а результатами конкретных таймеров и вызовов cancel(). Метод-пример прежний — GetOrder и CreateOrder сервиса заказов; для каскада появится соседний сервис платежей, которому хендлер заказов звонит внутри своей работы. И оговорка заранее: дедлайн — единственная часть контекста вызова, которая распространяется по сети сама; метаданные распространяются иначе, и в середине урока мы аккуратно разведём эти два механизма.

Таймауты grpc-вызовов: длительность против точки во времени#

Начнём с терминологии, потому что слова «таймаут» и «дедлайн» часто используют как синонимы, а в контекстном пакете Go за ними стоит строгое различие. Таймаут — это длительность: «дай три секунды с текущего момента». Дедлайн — это точка во времени: «успей до 17:32:15.250». В док-комментарии пакета context связь между ними сформулирована дословно: «WithTimeout returns WithDeadline(parent, time.Now().Add(timeout))» — таймаут это всего лишь удобная форма дедлайна, где точка вычисляется от «сейчас». А сам WithDeadline описан так: «WithDeadline returns a derived context that points to the parent context but has the deadline adjusted to be no later than d. If the parent's deadline is already earlier than d, WithDeadline(parent, d) is semantically equivalent to parent» — дедлайн производного контекста «не позже d», и если родитель уже живёт с более ранним сроком, функция семантически эквивалентна WithCancel: новый таймер не заводится вовсе. В исходниках это видно буквально: «if cur, ok := parent.Deadline(); ok && cur.Before(d) { // The current deadline is already sooner than the new one. return WithCancel(parent) }». Это правило минимума — фундамент каскадов из второй половины урока: продлить дедлайн через производный контекст невозможно, можно только сжать. Третий игрок — WithCancel: «The returned context's Done channel is closed when the returned cancel function is called or when the parent context's Done channel is closed, whichever happens first» — без срока, чистая отмена, и тоже подчиняющаяся родителю. У любого контекста есть метод Deadline() (time.Time, bool): пара «точка во времени, есть ли она вообще» — ок-флаг false означает, что срока нет и таймер не заведён. У Background() срока нет никогда; у контекста из grpc-вызова — есть, если клиент его поставил. Поэтому таймауты grpc-вызовов на практике означают одну операцию: посадить в ctx точку во времени — через WithTimeout, через WithDeadline или унаследовав её от родителя, — а дальше дерево контекста и библиотека сделают остальное.

Проверка: что делает WithTimeout

Вы вызываете context.WithTimeout(parent, 3*time.Second), где parent — контекст с дедлайном через десять секунд. Какой срок получит производный контекст?

Клиент: срок на вызов — три ракурса

GO
// cmd/client/main.go — три способа посадить срок в вызов.
// 1. Таймаут: длительность от текущего момента — самый ходовой вариант.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // освободить таймер, если вызов успеет раньше срока

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

// 2. Дедлайн: точка во времени — когда срок назначает не «отсчёт от сейчас»,
//    а внешнее событие, например общий бюджет пользовательского запроса.
dctx, dcancel := context.WithDeadline(
        context.Background(), deadlineFromRequest) // пришёл с фронта
defer dcancel()

// 3. Уже истёкший срок: запрос не уйдёт на провод вообще.
past := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer past()
_, perr := client.GetOrder(past, &pb.GetOrderRequest{Id: 42})
fmt.Println(status.Code(perr)) // DeadlineExceeded — мгновенно, без сети

Три ракурса одного механизма. Вариант 1 — форма из шестого урока; defer cancel обязателен не для «отмены вызова», а для освобождения ресурсов: док-комментарий WithTimeout прямо приводит шаблон slowOperationWithTimeout с комментарием «releases resources if slowOperation completes before timeout elapses» — таймер и связанная с ним горутина должны быть сняты, как только операция кончилась. Вариант 2 нужен там, где срок назначается извне: фронт передал абсолютное «успеть до», или бюджет запроса вычислен выше по цепочке. Вариант 3 — самый поучительный: если в момент отправки дедлайн уже в прошлом, grpc-go отказывает без всякой сети. В http2_client.go, где собираются заголовки нового потока, стоит проверка: «if timeout <= 0 { return nil, status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error()) }» — вычислять остаток библиотека умеет до всякого соединения. Обратите внимание и на честный комментарий исходников рядом: «Send out timeout regardless its value. The server can detect timeout context by itself» — заголовок с остатком уезжает при любом его значении, хоть на часы вперёд, разбираться с истечением сервер будет сам.

Остаток на проводе: как grpc-timeout едет и пересчитывается#

Заголовок grpc-timeout вы уже видели в трассировке шестого урока: две секунды уезжают как «2S», двести пятьдесят миллисекунд как «250m». Но в том уроке нас интересовала кодировка; теперь интересует смысл, и он тоньше, чем «клиент переслал свой таймаут». Клиент не пересылает длительность, которую вы написали в WithTimeout. Он вызывает ctx.Deadline() у того контекста, который вы передали в вызов, вычисляет time.Until(deadline) — остаток до точки во времени, — и вот этот остаток кодируется в заголовок функцией EncodeDuration, которую шестой урок разбирал по исходникам grpcutil. Разница видна в двух ситуациях. Первая: контекст вызова производный, с чужим дедлайном — скажем, вы получили запрос с остатком в одну секунду и позвонили соседу с этим ctx как есть; на провод уедет не «ваш» таймаут, которого вы не ставили, а остаток родительского срока. Каскадное сжатие начинается прямо здесь, ещё до всякого сервера. Вторая: между установкой дедлайна и отправкой запроса прошло время — остаток уже меньше исходных двух секунд, и в заголовке будет именно он. Серверная половина — зеркальный пересчёт. http2_server.go разбирает заголовки вызова, находит grpc-timeout и декодирует его функцией decodeTimeout: восемь цифр плюс буква единицы — всё, что длиннее или невалидно, — ошибка, и вызов встречает Internal с сообщением «malformed grpc-timeout» ещё до хендлера. Декодированная длительность интерпретируется как срок от серверного «сейчас»: библиотека создаёт контекст потока вызовом WithTimeout с этим значением — и хендлер получает в ctx свой собственный дедлайн, равный по смыслу клиентскому, но уже привязанный к чужим часам. Отсюда два практических вывода. Первый: дедлайн по сети едет как остаток, а не как абсолютная метка — расхождение часов клиента и сервера съедается из бюджета вызова, и для больших значений это надо помнить при диагностике «куда делось сто миллисекунд». Второй: сервер не может «увидеть исходный таймаут» — только то, что осталось; продлить срок изнутри нельзя в принципе, и это не недоработка, а смысл механизма.

Сервер: stream.Context() и контекст потока с дедлайном#

Для unary-вызова дедлайн прилетает первым аргументом хендлера — тем самым ctx, который вы передаёте дальше в репозиторий и соседние вызовы. Для стриминговых моделей контекст тот же по смыслу, но добывается методом: stream.Context(), и док-комментарий к нему лаконичен: «Context returns the context for this stream» — девятый урок уже слушал этот канал в bidi-чате. Теперь важно понять, откуда он берётся и что в нём лежит. На приёмной стороне grpc-go заводит контекст потока так: если заголовок grpc-timeout присутствовал — context.WithTimeout(ctx, timeout) с декодированным остатком; если нет — context.WithCancel(ctx), контекст без срока, который кончится только вместе с соединением или явной отменой. Отсюда прямое следствие, которое часто удивляет: вызов от клиента без таймаута приходит хендлеру с контекстом, у которого ctx.Deadline() возвращает ok=false. Срока нет — не «огромный», а именно никакого; таймер не заведён, и проверка «дедлайн уже прошёл» в вашем коде просто не сработает. Второй сюрприз — серверный таймер транспорта. Создавая поток с дедлайном, транспорт запускает собственный таймер на остаток: при истечении он вызывает closeStream со статусом CANCEL — кодом протокола HTTP/2, — а та ветка в обязательном порядке дёргает cancel() контекста потока. То есть у дедлайна два независимо сработавших конца: клиентский Invoke вернёт DeadlineExceeded ровно в свой срок, а серверный транспорт в свой срок принудительно закроет поток, даже если хендлер игнорирует ctx.Done() полностью. Хендлер при этом не убивается: горутина продолжит крутиться, пока сама не заглянет в Done() или не закончит работу, — но её ответ уже некуда доставить: поток закрыт. Клиентская симметрия для полноты: у стрима тоже есть Context(), но его док-комментарий предупреждает «It should not be called until after Header or RecvMsg has returned. Once called, subsequent client-side retries are disabled» — вызов до первого Recv отключает клиентские ретраи, и это мост к пятнадцатому уроку: библиотека умеет повторять вызовы сама, если ей не мешать.

Проверка: вызов без дедлайна

Клиент вызвал GetOrder с контекстом context.Background() — без WithTimeout и без дедлайна. Что вернёт ctx.Deadline() в хендлере сервера?

Сервер: хендлер, который умеет кончаться

GO
// internal/server/orders.go — долгая работа дробится и слушает Done().
func (s *OrderServer) GetOrder(
        ctx context.Context, req *pb.GetOrderRequest,
) (*pb.Order, error) {
        // Дедлайн уже в ctx: остаток из grpc-timeout пересчитан сервером.
        if dl, ok := ctx.Deadline(); ok {
                log.Printf("заказ %d: остаток на обработку %v",
                        req.Id, time.Until(dl))
        }
        // Порции работы: между ними — единственный шанс уйти честно.
        for i := 0; i < 10; i++ {
                select {
                case <-ctx.Done():
                        // Срок истёк или клиент отменил вызов: статус из
                        // ошибки контекста — DeadlineExceeded или Canceled.
                        return nil, status.FromContextError(ctx.Err()).Err()
                case <-time.After(100 * time.Millisecond):
                        // Порция: сканируем очередной сегмент индекса.
                }
        }
        order, err := s.repo.Find(ctx, req.Id)
        if err != nil {
                return nil, status.Errorf(codes.NotFound, "заказ %d не найден", req.Id)
        }
        return order, nil
}

Каркас «хендлера, который умеет кончаться», — развитие правила шестого урока про порции. Три детали, которые здесь новые. Первая: статус из ctx.Err() собирает status.FromContextError, и её док-контракт стоит процитировать: «FromContextError converts a context error or wrapped context error into a Status. It returns a Status with codes.OK if err is nil, or a Status with codes.Unknown if err is non-nil and not a context error» — контекстная ошибка превращается в DeadlineExceeded или Canceled, всё прочее — в Unknown. Вторая: возврат статуса при Done() нужен не клиенту — клиент с горящим дедлайном уже ушёл, — а вашему логу и метрикам: вместо «хендлер умер молча» в логах появится аккуратный статус, и по нему видно, сколько вызовов не переживает свой бюджет. Третья: ветка Done() — не «по возможности», а каждые сто миллисекунд работы; если порция сама блокируется надолго (тяжёлый SQL, чужой HTTP), толку от select'а нет — долгие операции внутри порции должны тоже принимать ctx, иначе дедлайн доедет только до их порога, а не до их середины. Симметрия со стримингами полная: там вместо цикла порций — select между Recv и Done(), как в девятом уроке с каналом waitc.

Клиент 2 секунды, сервер 5 секунд

Прочитайте код и выберите, что он выведет:

// КЛИЕНТ:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42})
if err != nil {
        st := status.Convert(err)
        fmt.Println(st.Code(), "|", st.Message())
}

// СЕРВЕР (хендлер):
func (s *OrderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
        time.Sleep(5 * time.Second) // "тяжёлая" обработка
        return &pb.Order{Id: req.Id}, nil
}

DeadlineExceeded изнутри: сообщение, которое не надо парсить#

В каталоге тринадцатого урока DeadlineExceeded стоял четвёртым номером среди кодов, которые фреймворк генерирует сам. Теперь видно, где именно генерирует: минимум в трёх местах, и у каждого своя роль. Первое — клиентский http2_client.go перед отправкой заголовков: остаток не положительный — отказ немедленно, запрос не покидает процесс. Второе — toRPCErr в rpc_util.go: любая контекстная ошибка context.DeadlineExceeded, поднявшаяся из ожидания ответа, переписывается в готовый статус errContextDeadline = status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error()). Третье — серверная сторона: транспортный таймер закрывает поток, а хендлер, если он дисциплинирован, сам возвращает FromContextError(ctx.Err()) с тем же кодом. Важно, что во всех трёх случаях сообщение — «context deadline exceeded», текст из пакета context, а не человеческая легенда: док-контракт кода из codes.go стоит прочесть целиком, потому что в нём спрятано главное производственное следствие. «DeadlineExceeded means operation expired before completion. For operations that change the state of the system, this error may be returned even if the operation has completed successfully. For example, a successful response from a server could have been delayed long enough for the deadline to expire» — вызов, меняющий состояние, к моменту DeadlineExceeded мог УСПЕТЬ выполниться: списание прошло, заказ создан, письмо ушло — а ответ не доехал в срок. Клиент видит «не успели», система — «успели, но вы не в курсе». Именно поэтому ретрай после DeadlineExceeded для неидемпотентной операции — это не ускорение, а задвоение: пятнадцатый урок соберёт вокруг этого правила политику повторов, а пока держите вывод — код DeadlineExceeded честно сообщает про ваш бюджет, но ничего не говорит о том, дошла ли работа до конца на той стороне. Второй вывод — про связку с сообщением: раз текст фиксированный, «улучшать» его нечем и незачем; вся контекстуализация — какой именно вызов, какой заказ, какой остаток — это забота ваших логов и метрик, а не grpc-message.

Отмена клиентом: WithCancel и код Canceled#

Дедлайн — это отмена по расписанию; вторая половина механизма — отмена по решению. context.WithCancel даёт контекст без срока: «The returned context's Done channel is closed when the returned cancel function is called or when the parent context's Done channel is closed, whichever happens first» — закрытие по вашему вызову cancel() или по родителю, что случится раньше. Подставьте такой контекст в grpc-вызов, а потом позовите cancel() посреди работы — и механика разовьётся по проводу сама. Клиентский транспорт закрывает HTTP/2-поток кадром RST_STREAM с кодом CANCEL: на серверной стороне handleRSTStream вызывает closeStream, а closeStream — уже знакомую по дедлайну цепочку — дёргает cancel() контекста потока. Хендлер видит ctx.Done() с context.Canceled; для стримов это те же Canceled из Recv и Send, что девятый урок разбирал на двух горутинах чата. Клиентский Invoke тем временем выходит с кодом Canceled, и сообщение здесь тоже фиксированное: errContextCanceled в rpc_util.go — это status.Error(codes.Canceled, context.Canceled.Error()), то есть дословно «context canceled». Док-комментарий кода из каталога подтверждает право собственности: «Canceled indicates the operation was canceled (typically by the caller)» — обычно отменил сам вызывающий, и библиотека честно превращает это в код №1. Когда выбирать cancel вместо deadline? Когда срок не измеряется часами: пользователь закрыл страницу — сессия bidi-чата из девятого урока кончается WithCancel по решению оператора; upstream-сервис прислал «этот запрос больше не нужен»; клиентский код понял, что ответ уже бесполезен. Дедлайн отвечает на вопрос «на сколько дать бюджет», cancel — «когда убить по сигналу». И последнее, что сбивает с толку новичков: defer cancel() после УСПЕШНОГО вызова не «отменяет результат» — поток уже завершён, closeStream на готовом потоке просто досыпает cleanup, и результат остаётся у вас. Отмена живёт только пока вызов жив.

Отмена посреди вызова

Прочитайте код и выберите, что он выведет:

// Клиент: серверный стрим WatchOrders, сообщения раз в секунду.
ctx, cancel := context.WithCancel(context.Background())
go func() {
        time.Sleep(100 * time.Millisecond)
        cancel() // пользователь закрыл страницу
}()
stream, err := client.WatchOrders(ctx, &pb.WatchOrdersRequest{})
if err == nil {
        for {
                note, err := stream.Recv()
                if err != nil {
                        fmt.Println(status.Code(err))
                        break
                }
                _ = note
        }
}

Каскад: RPC вызывает RPC, дедлайн сжимается#

Вот где дедлайны становятся архитектурой. Хендлер CreateOrder внутри своей работы звонит сервису платежей: один RPC порождает другой. Какой контекст дать дочернему вызову — и что окажется на проводе? Вариант первый — передать входящий ctx как есть. Дочерний вызов прочитает ctx.Deadline() — ту же самую точку во времени, — вычислит остаток в момент отправки и уедет в grpc-timeout с ним. Остаток меньше того, что клиент послал сервису заказов: часть бюджета съедена собственной работой хендлера. Каждое звено цепочки откусывает от общего бюджета — дедлайн сжимается автоматически, без единой строки специального кода: в этом весь смысл расчёта остатка вместо пересылки константы. Вариант второй — WithTimeout(ctx, 200time.Millisecond): дочерний вызов получает собственный, заведомо меньший срок. Это и есть дисциплина «каждый уровень выделяет часть бюджета»: платёжный звонок не должен съедать весь остаток, оставив запись в базу без времени. Правило минимума гарантирует, что ошибиться в большую сторону нельзя: если родительский остаток внезапно меньше ваших двухсот миллисекунд, WithDeadline вернёт WithCancel с комментарием исходников «The current deadline is already sooner than the new one» — таймер не заведётся, и срок останется родительским. Вариант третий — «щедрый» WithTimeout(ctx, 10time.Second) при родительском остатке в полторы секунды: новичок ожидает, что дочерний вызов получит десять секунд, — нет: ten больше остатка, правило минимума срабатывает, дедлайн остаётся родительским. Продлить срок дочернему вызову невозможно — только сузить. Что увидит дочерний вызов, когда родительский бюджет сгорел? Дерево контекста отменяет детей с ошибкой родителя: дочерний ctx получает context.DeadlineExceeded, и его Invoke выходит кодом DeadlineExceeded — причина пробегает вниз по цепочке, не меняясь по смыслу. Так «распространение отмены» работает в обе стороны: cancel() и дедлайн сверху убивают всё поддерево, а нижние звенья честно отчитываются тем же кодом.

Каскад: три варианта контекста в хендлере

GO
// internal/server/orders.go — CreateOrder звонит сервису платежей.
func (s *OrderServer) CreateOrder(
        ctx context.Context, req *pb.CreateOrderRequest,
) (*pb.Order, error) {
        pay := &pb.ChargeRequest{OrderId: req.Id, Amount: req.Amount}

        // Вариант 1: ctx как есть — на провод уедет ОСТАТОК дедлайна
        // клиента, пересчитанный в момент отправки. Дешёво и честно.
        if _, err := s.payments.Charge(ctx, pay); err != nil {
                return nil, err
        }

        // Вариант 2: свой, заведомо меньший срок — дисциплина
        // "часть бюджета на каждое звено". defer cancel обязателен.
        pctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
        defer cancel()
        if _, err := s.payments.Charge(pctx, pay); err != nil {
                return nil, err
        }

        // Вариант 3: "щедрый" срок больше остатка родителя. Правило
        // минимума: WithDeadline вернёт WithCancel, дедлайн останется
        // родительским. Продлить срок так НЕ получится.
        bctx, bcancel := context.WithTimeout(ctx, 10*time.Second)
        defer bcancel()
        return s.repo.Create(bctx, req) // живёт по родительскому остатку
}

Читаем по вариантам — что именно положит grpc-timeout на провод. Вариант 1: дочерний вызов читает ctx.Deadline() входящего контекста — ту же точку, что получил хендлер, — и посылает time.Until от неё: остаток, съеденный вашей работой, вычитается сам. Вариант 2: новая, более ранняя точка now+200ms; на проводе — двести миллисекунд независимо от остатка родителя, если тот больше; если меньше — сработает правило минимума и уедет остаток родителя. Вариант 3: «десять секунд» на провод не попадут никогда — родительский срок раньше, а значит WithDeadline семантически эквивалентен WithCancel, таймера нет, и Create живёт по родительскому остатку. Отсюда рабочий вывод для код-ревью: строка context.WithTimeout(ctx, N) в хендлере всегда означает «сознательно режу бюджет», и N обязано быть меньше оставшегося времени на внятную дочернюю операцию. Проверять это руками не надо — правило минимума сделает худший сценарий безопасным, — а вот забыть defer cancel() нельзя: каждый WithTimeout без снятия таймера — это ресурс, который держится до конца работы контекста-родителя, то есть до конца запроса.

Проверка: остаток на проводе в каскаде

Клиент поставил дедлайн через две секунды. Запрос дошёл до сервиса заказов, хендлер провёл собственную работу за 500 миллисекунд и теперь вызывает сервис платежей, передавая входящий ctx как есть. Что окажется в заголовке grpc-timeout дочернего вызова?

context.Value не при чём: два механизма в одном контексте#

Контекст вызова носит и срок, и данные, и важно не свалить их в одну кучу. Дедлайн и отмена — активная механика: таймеры, каналы Done, дерево, где отмена родителя мгновенно срабатывает у всех потомков, где значение остатка пересчитывается на каждом витке по сети. context.Value — пассивный словарь: ключ кладётся, ключ читается, никаких событий и таймеров. В gRPC-мире данные вызова распространяются собственным механизмом библиотеки, о котором говорил десятый урок: metadata.AppendToOutgoingContext поверх входящего контекста — и на провод заголовки уезжают отдельно от grpc-timeout, своей жизнью. Если нужно «прокинуть» вниз по цепочке trace-id или флаг ретрая — это метаданные, а не Value и уж точно не дедлайн. Внутри контекста потока библиотека держит и служебные значения: входящие метаданные достаются metadata.FromIncomingContext, данные TLS-пира из двенадцатого урока — peer.FromContext, — но всё это читается по ключам и ни на что не «реагирует». Дедлайн же нельзя ни положить в метаданные, ни передать строкой: это не данные, а состояние — таймер, который тикает на каждой машине заново, от чужих часов. Практическое правило разведения механизмов звучит так: то, что должно ВЫЖИТЬ истечение срока, едет в метаданных (они не зависят от таймеров); то, что должно УМЕРЕТЬ вместе с вызовом, держится на контексте с дедлайном. Попытка натянуть срок на Value — классическая ошибка проектирования: строку «deadline: 2s» прочитают, но никто не отменит работу, когда она истечёт. Контекст обязан управлять жизнью вызова — только через встроенные в него таймеры и Done-каналы, а не через договорённости «прочитай мою строчку и обещай».

Карта обрывов: что увидит клиент, когда всё пошло не так#

Соберём все исходы урока в одну карту — что именно возвращается клиенту при каждом виде беды. Дедлайн истёк — DeadlineExceeded с фиксированным «context deadline exceeded»: свой Invoke в свой срок, серверный транспорт в свой, хендлер в свой Done(). Кто-то позвал cancel() — Canceled с «context canceled»; право вызова принадлежит вашему коду, и отличить одну отмену от другой — уже ваша забота: по коду, а не по сообщению. Обрыв соединения — Unavailable: в toRPCErr транспортная ConnectionError переписывается именно в этот код, и тринадцатый урок напоминал, что это единственный код с обещанием «повтор с бэкоффом имеет смысл» — при условии идемпотентности. Обрыв посреди сообщения — Internal: io.ErrUnexpectedEOF, когда тело сообщения оборвалось на середине, библиотека квалифицирует как «something is very broken» — и это правильно, потому что полуразобранное сообщение означает нарушение протокола, а не чистую сеть. Отдельная честность про гонки: если соединение умирает одновременно с горящим дедлайном, клиент увидит тот код, чей источник первым заметит беду, — здесь нет ни детерминизма, ни гарантий порядка, и клиентский код обязан ветвиться равноправно, не строя логику «сперва проверяю дедлайн, потом сеть». Для стриминговых вызовов карта та же, но ошибки распределяются по горутинам: Send и Recv узнают о бедах по-разному, и девятый урок уже показывал, как читать правду в bidi. Общий принцип карты: код говорит категорию беды, сообщение — почти никогда; всё человеческое — в ваших логах, метриках и traceback'ах.

Карта ошибок клиента: код, причина, реакция#

КодЧто случилосьСообщение по умолчаниюРазумная реакция
codes.DeadlineExceededистёк дедлайн вызова: клиентский Invoke, серверный таймер транспорта или остаток родителя в каскаде«context deadline exceeded»честный ответ «не успели»; повтор только для идемпотентных операций — работа на сервере могла успеть выполниться
codes.Canceledкто-то вызвал cancel() на контексте вызова; или отменился родительский запрос«context canceled»не сбой сервиса: найти, кто и зачем отменил; по решению — перезапуск с новым сроком
codes.Unavailableобрыв соединения, резкая остановка сервера, отказ транспортного уровнятекст транспортной ошибки (Desc)транзиентно: ретрай с бэкоффом, если операция идемпотентна; иначе — наверх
codes.Internalобрыв посреди сообщения (io.ErrUnexpectedEOF), нарушен протокол; на сервере — повреждённый grpc-timeoutтекст исходной ошибки«something is very broken»: диагностика, а не повтор

Чек-лист: дедлайны и отмены#

  • Таймаут — длительность, дедлайн — точка во времени: WithTimeout это WithDeadline(parent, time.Now().Add(timeout)); в ctx живёт всегда точка, Deadline() возвращает пару (time.Time, ok).
  • Правило минимума: производный контекст не может иметь дедлайн позже родительского; «already sooner» — WithDeadline вырождается в WithCancel, таймера нет.
  • На провод едет остаток: time.Until(deadline) в момент отправки, восемь цифр и единица; сервер пересчитывает остаток от своего «сейчас» — расхождение часов вычитается из бюджета.
  • Истёкший к моменту отправки дедлайн — мгновенный DeadlineExceeded без отправки запроса; сервер при повреждённом grpc-timeout отвечает Internal «malformed grpc-timeout».
  • Нет заголовка grpc-timeout — контекст потока создаётся WithCancel: срока нет вовсе, ctx.Deadline() вернёт ok=false.
  • У дедлайна два принудительных конца: клиентский Invoke и серверный таймер транспорта (closeStream с ErrCodeCancel); хендлер убивать некому — только его собственная дисциплина ctx.Done().
  • Хендлер дробит работу и возвращает status.FromContextError(ctx.Err()).Err(): DeadlineExceeded или Canceled, всё прочее — Unknown.
  • Отмена по решению — WithCancel: RST_STREAM с CANCEL, сервер видит Canceled в Done()/Recv/Send; defer cancel() после успешного вызова безвреден.
  • Каскад: передать ctx как есть — уедет остаток; WithTimeout с меньшим сроком — часть бюджета; «щедрый» срок не работает — продлить нельзя, только сжать.
  • Метаданные и context.Value — отдельный пассивный механизм (AppendToOutgoingContext, FromIncomingContext, peer.FromContext); дедлайны и отмены — активные таймеры и Done-каналы того же контекста.

Путь дедлайна от клиента до сервера

Расставьте события в порядке жизни дедлайна — от установки срока на клиенте до финала на обеих сторонах.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Соберите клиентский каркас с дедлайном

Восстановите каркас: таймаут, снятие таймера, разбор итога и дедлайн-точка. Пять пропусков.

// Клиент: срок на вызов и разбор финала. ctx, cancel := context.(context.Background(), 2*time.Second) cancel() order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: 42}) if err != nil { switch .Code(err) { case codes.: log.Println("не успели: бюджет вызова исчерпан") case codes.Canceled: log.Println("вызов отменили по решению") } return } // Альтернатива таймауту: точка во времени вместо длительности. dctx, dcancel := context.(context.Background(), deadlineFromRequest) defer dcancel()

Карточки: дедлайны и отмены

Термин

WithTimeout / WithDeadline

нажмите, чтобы перевернуть
Определение

Док-контракт: «WithTimeout returns WithDeadline(parent, time.Now().Add(timeout))». Дедлайн — точка во времени; Deadline() → (time.Time, ok). Правило минимума: «If the parent's deadline is already earlier than d, WithDeadline(parent, d) is semantically equivalent to parent» — в исходниках «The current deadline is already sooner than the new one» → WithCancel. Продлить срок нельзя, только сжать. defer cancel обязателен: «releases resources if slowOperation completes before timeout elapses».

1 / 6

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

Отдельная честная оговорка про формат: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — поднять сервер и позвонить ему оттуда нельзя, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за десять минут на паре из четвёртого и пятого уроков. Три эксперимента на вечер. Первый: хендлер с time.Sleep(5s), клиент с WithTimeout(2s) — убедитесь, что клиент получает DeadlineExceeded в свой срок, а лог сервера без ветки Done() покажет работу впустую; добавьте порции с select из урока — и сравните. Второй: замените таймаут на WithCancel и вызовите cancel() через сто миллисекунд — клиент увидит Canceled, сервер — Canceled в ctx.Err(); затем добавьте defer cancel() после успешного вызова и проверьте, что результат не «отменился». Третий: поднимите второй сервис-сосед и позвоните ему из хендлера — сравните grpc-timeout на обоих участках, передавая ctx как есть и оборачивая в WithTimeout(200ms). Дальше по маршруту: пятнадцатый урок — повторы и ретраи: service config и retryPolicy превращают карту ошибок этого урока в автоматическое поведение клиента, а вопрос «повторять ли после DeadlineExceeded» получит строгий ответ через идемпотентность. До этого стоит перечитать урок про статус-коды — ветвление по DeadlineExceeded и Canceled из этого урока устроено ровно тем аппаратом, и урок про unary — базовая анатомия вызова с его таймаутами. Отмену в живом стриме с двумя горутинами мы уже слышали в уроке про bidi. Полный маршрут курса — на странице gRPC-Go.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Таймаут — длительность, дедлайн — точка во времени: WithTimeout это WithDeadline(parent, time.Now().Add(timeout)); Deadline() возвращает (time.Time, ok), ok=false означает отсутствие срока.
  • Правило минимума контекстного дерева: производный дедлайн не бывает позже родительского; если родительский срок раньше — WithDeadline вырождается в WithCancel без таймера. Продлить срок нельзя, только сжать.
  • На провод едет остаток: time.Until(deadline) в момент отправки, восемь цифр и единица; сервер декодирует и пересчитывает остаток от своего «сейчас» — расхождение часов вычитается из бюджета вызова.
  • Истёкший к отправке дедлайн — мгновенный DeadlineExceeded без сети; повреждённый grpc-timeout — Internal «malformed grpc-timeout» до хендлера; вызов без заголовка приходит с контекстом без срока (WithCancel) — ctx.Deadline() вернёт ok=false.
  • Дедлайн имеет два принудительных конца: клиентский Invoke и серверный таймер транспорта, закрывающий поток ErrCodeCancel; хендлер обязан сам слушаться ctx.Done() — дробить работу и возвращать status.FromContextError(ctx.Err()).Err().
  • Canceled — отмена по решению: cancel() уносит RST_STREAM с CANCEL, клиент получает Canceled «context canceled», сервер — Canceled в Done()/Recv/Send; defer cancel() после успешного вызова безвреден.
  • Каскад: ctx как есть — на провод уходит остаток, дедлайн сжимается сам; WithTimeout с меньшим сроком — часть бюджета; «щедрый» срок игнорируется правилом минимума. Метаданные и context.Value — отдельный пассивный механизм, к срокам отношения не имеющий.

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

Соберите лабораторию дедлайнов на паре сервер/клиент из четвёртого и пятого уроков. Первое: хендлер GetOrder с пятью порциями по двести миллисекунд и select'ом по ctx.Done() — клиент с таймаутом в полсекунды; убедитесь, что клиент получает DeadlineExceeded с сообщением «context deadline exceeded», а лог сервера показывает, сколько порций успело. Второе: добавьте в хендлер печать ctx.Deadline() и сравните с клиентским сроком — увидите расхождение на время полёта; затем уберите WithTimeout у клиента и проверьте, что ctx.Deadline() вернёт ok=false. Третье: поднимите второй сервис и позвоните ему из хендлера трижды: с входящим ctx как есть, с WithTimeout(ctx, 200ms) и с WithTimeout(ctx, 10s) при родительском остатке в секунду — логируйте заголовок на принимающей стороне и убедитесь, что в первом случае уехал остаток, во втором двести миллисекунд, а в третьем — снова остаток, а не десять секунд. Четвёртое: замените таймаут на WithCancel, вызовите cancel() посреди вызова — клиент получит Canceled, сервер увидит Done() с context.Canceled; для сравнения сделайте то же после успешного завершения и объясните, почему ничего не сломалось. Пятое: обрежьте дедлайн «в прошлое» (WithDeadline от time.Now().Add(-time.Second)) и убедитесь, что Invoke выходит мгновенно и без сети — найдите это место в поведении http2_client по описанию из урока. Шестое: напишите перехватчик из одиннадцатого урока, который проверяет отсутствие дедлайна в ctx входящего вызова и логирует такие случаи как нарушения политики — посчитайте их за день работы стенда.

Личные заметкиПусто
Хранятся только в вашем браузере (gRPC · Дедлайны и отмены)

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

Чем таймаут отличается от дедлайна и что из них ставить?

Таймаут — длительность от текущего момента: context.WithTimeout(ctx, 2*time.Second). Дедлайн — абсолютная точка во времени: context.WithDeadline(ctx, someTime). Док-контракт связывает их одной строкой: «WithTimeout returns WithDeadline(parent, time.Now().Add(timeout))». В контексте живёт всегда точка; метод Deadline() возвращает пару (время, ok). Ставить можно любой конструктор — разница в выразительности: длительность удобна для локальных бюджетов («на платёж — двести миллисекунд»), точка — когда срок назначает внешний источник (общий бюджет пользовательского запроса, «успеть до 17:32»). Правило минимума контекстного дерева сделает безопасным оба варианта: дедлайн производного контекста «no later than d», а если родительский срок уже раньше — таймер не заводится вовсе.

Сервер видит, что клиент почти не успел: можно ли продлить дедлайн, чтобы доработать?

Нет. Дедлайн — не переговоры: серверный транспорт держит собственный таймер на остаток из grpc-timeout и по истечении закрывает поток ErrCodeCancel; продлевать срок в контексте контекстное дерево не позволяет — правило минимума гарантирует, что производный дедлайн не позже родительского, и «сервер почти успел» до клиента не доносится. Док-комментарий кода DeadlineExceeded предупреждает о жёсткой грани: «this error may be returned even if the operation has completed successfully» — вызов мог успеть, но ответ уже некуда доставить. Единственный легальный способ дать работе больше времени — новый бюджет от того, кто им распоряжается: клиент ставит больший таймаут заранее или сервис-владелец бюджета в цепочке пересматривает свои WithTimeout на звеньях. Серверу остаётся дисциплина: слушать ctx.Done(), отпускать работу честно и не делать полусделанных изменений, которые клиент сочтёт несостоявшимися.

Что увидит сервер, когда клиент отменил вызов cancel()?

Механика та же, что у дедлайна, только без таймера. Клиентский транспорт закрывает HTTP/2-поток кадром RST_STREAM с кодом CANCEL; на сервере handleRSTStream доходит до closeStream, а тот вызывает cancel() контекста потока. Хендлер видит ctx.Done() с ошибкой context.Canceled — для unary это единственный шанс уйти из работы, для стримов Recv и Send возвращают Canceled, как в bidi-чате девятого урока. Если хендлер дисциплинирован и возвращает status.FromContextError(ctx.Err()).Err(), в логах появится аккуратный Canceled; если игнорирует — горутина доработает впустую, а её ответ уйдёт в закрытый поток. Клиент в это время уже вышел из Invoke с кодом Canceled и фиксированным сообщением «context canceled» — текст из пакета context, его не настраивают. Практическое правило: Canceled в логах сервера — не сбой сервиса, а след решения клиента; высокая доля таких строк повод спросить, зачем клиенты платят за работу, результат которой им не нужен.

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

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Connect25 уроков~12 чпродвинутый25 уроков уже доступны

Connect: современный RPC от Buf

Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.

  • Строить сервисы на connectrpc.com/connect
  • Понимать три протокола и выбирать правильный
  • Настраивать buf: lint, breaking changes, generate
  • Писать interceptors и обрабатывать ошибки