context canceled в Go ничего не объясняет: сохраняем причину отмены

Worker завершился с context canceled. Из журнала непонятно, что произошло: пользователь отменил экспорт, приложение выключается или один этап обнаружил повреждённый вход. Категория отмены полезна для управления потоком, но слишком общая для диагноза. Если причина известна коду, который останавливает операцию, её можно передать вместе с отменой.

Категория и причина остановки

context.WithCancelCause создаёт контекст и функцию отмены с error. После cancel(reason) метод Err по-прежнему возвращает context.Canceled, сохраняя стандартные проверки категории. Функция context.Cause возвращает заданную причину. Это позволяет одинаково завершать ожидание и отдельно объяснять, почему владелец принял решение остановиться.

В примере экспорт получает дочерний контекст. Владелец отменяет родителя из-за собственной причины errInputRejected, а затем повторно вызывает отмену с другой ошибкой. Первая причина сохраняется. Worker просыпается по Done и отправляет одновременно категорию и причину. Проверка использует errors.Is, чтобы не зависеть от текста сообщения.

Событие отмены распространяется вниз по дереву контекстов. Это не передача произвольного статуса через глобальную переменную: каждая операция имеет собственного родителя, а отмена одного дерева не должна останавливать чужие запросы. Храните контекст в параметрах вызовов и создавайте нужную границу там, где действительно известен владелец операции.

Передать первую причину потомку

package main

import (
	"context"
	"errors"
	"fmt"
)

func main() {
	rejected := errors.New("input checksum mismatch")
	parent, cancel := context.WithCancelCause(context.Background())
	defer cancel(nil)
	child, stopChild := context.WithCancel(parent)
	defer stopChild()
	result := make(chan [2]error, 1)
	go func() {
		<-child.Done()
		result <- [2]error{child.Err(), context.Cause(child)}
	}()
	cancel(rejected)
	cancel(errors.New("shutdown later"))
	observed := <-result
	if !errors.Is(observed[0], context.Canceled) {
		panic("cancellation category lost")
	}
	if !errors.Is(observed[1], rejected) {
		panic("first cause lost")
	}
	fmt.Println(observed[0])
	fmt.Println(observed[1])
}

WithCancelCause доступен начиная с Go 1.20. Вывод содержит context canceled и исходную причину проверки входа. Отдельный таймаут или отмена дочернего контекста могут стать причиной его завершения раньше родителя. Поэтому при сложном дереве важно, какая граница завершилась первой, а не какая ошибка появилась в системе в целом.

Граница результата и отмены

Причина не заменяет возвращаемую ошибку функции. Обработчик может завершиться обычным результатом без отмены контекста, а отдельный этап может вернуть собственный отказ. Используйте Cause при разборе остановки, а результат функции — для результата её выполнения. Если смешать эти каналы, любое завершение запроса начнёт выглядеть одинаковым.

Внешнему клиенту не следует автоматически выдавать текст Cause. Внутри него могут оказаться детали пути, сети или входного файла. На границе API выберите стабильный публичный код, а подробную причину запишите в разрешённый журнал с идентификатором операции. Это тот же принцип разделения машинного сигнала и диагностического текста, который полезен для обычных ошибок.

Функция cancel должна вызываться и на успешном пути для освобождения связанных ресурсов. defer cancel(nil) удобен как страховка; если осмысленная причина уже установлена, более поздний nil её не перезапишет. Не используйте завершённый контекст для новой операции: создайте новое дерево с соответствующим сроком жизни.

Добавьте тест, где дочерний контекст отменяется раньше родителя, и тест нормального завершения worker без отмены. Так вы увидите разницу между причиной остановки и итогом вычисления. Если журнал всё ещё содержит только Err, сохранение Cause внутри дерева не даст пользы: проверьте конкретное место формирования диагностической записи.

Продолжить практику

В курсе «Микросервисы на Go» есть дедлайны, ошибки зависимостей и поиск причин незавершённой работы. Откройте программу и продолжите от локального дерева контекстов к передаче времени и отмены между сервисами: клиентский таймаут должен доходить до места, где ещё выполняется работа.

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.