Программирование GoОбработка ошибокРазработчик серверных приложений на Go

Объясните механизм: почему errors.Unwrap не извлекает причины из ошибки, созданной errors.Join, и какой спо...

Объясните механизм: почему errors.Unwrap не извлекает причины из ошибки, созданной errors.Join, и какой способ применяют для обхода всех причин?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

errors.Unwrap работает только с ошибками, реализующими метод Unwrap() error. Ошибка, созданная errors.Join, предоставляет метод Unwrap() []error, поэтому errors.Unwrap возвращает nil, не раскрывая объединённые причины.

Для обхода всех причин используют проверку интерфейса с методом Unwrap() []error и рекурсивно обходят дерево ошибок. Для проверки наличия конкретной причины обычно достаточно errors.Is, а полный список причин извлекают только при необходимости.

Исторический контекст

Изначально стандартный механизм wrapping в Go был рассчитан на одну исходную ошибку: обёртка могла добавить контекст и вернуть его через Unwrap() error. Функция errors.Unwrap отражает именно эту модель.

В Go 1.20 появился errors.Join, позволяющий представить несколько независимых ошибок одной ошибкой. Для сохранения совместимости новый механизм использует отдельную сигнатуру Unwrap() []error, не меняя поведение уже существующей errors.Unwrap.

Постановка проблемы

Несколько ошибок возникают, например, при пакетной валидации, закрытии нескольких ресурсов или параллельном выполнении независимых операций. Если объединить их через errors.Join, вызывающему коду может понадобиться не только узнать, присутствует ли определённая причина, но и обработать каждую причину отдельно.

Ошибочный вызов errors.Unwrap создаёт риск: программа решит, что у объединённой ошибки нет причины, хотя причины существуют. Это может привести к потере диагностических данных, неполному журналированию или неверному решению о повторе операции.

Подробное решение

errors.Unwrap ищет только метод с точной сигнатурой Unwrap() error. У результата errors.Join сигнатура другая — Unwrap() []error; это дерево причин, а не линейная цепочка. Поэтому errors.Unwrap намеренно не поддерживает такой результат и возвращает nil.

errors.Is и errors.As умеют обходить как обычные цепочки, так и деревья ошибок. Поэтому для вопроса «содержит ли ошибка причину определённого типа или значения» следует использовать их, а не самостоятельно извлекать причины.

Если нужен полный обход, применяют структурную проверку интерфейса:

package main import "errors" type multiUnwrapper interface { Unwrap() []error } func causes(err error) []error { if err == nil { return nil } if m, ok := err.(multiUnwrapper); ok { var result []error for _, child := range m.Unwrap() { result = append(result, causes(child)...) } return result } if child := errors.Unwrap(err); child != nil { return append([]error{err}, causes(child)...) } return []error{err} }

Такой обход учитывает и Unwrap() []error, и обычный Unwrap() error. В реальном коде нужно заранее определить семантику результата: включать ли в список составную ошибку, считать ли листьями только конечные причины и как обрабатывать повторяющиеся ошибки.

errors.Join игнорирует nil-элементы и возвращает nil, если все переданные ошибки равны nil. Пользовательские реализации Unwrap могут быть некорректными, например образовывать циклы, поэтому рекурсивный обход непроверенных ошибок стоит защищать от повторного посещения объектов или ограничивать глубину.

Ситуация из практики

Сервис обрабатывает пакет из ста записей и хочет вернуть вызывающему коду все ошибки валидации. Возврат только первой ошибки прост, но скрывает остальные проблемы и вынуждает запускать пакетную обработку повторно. Оборачивание каждой ошибки в одну линейную цепочку также не выражает независимость причин.

Вариант с одной текстовой ошибкой проще для журналирования, но структурированные причины теряются: невозможно надёжно применить errors.Is или errors.As к каждой из них. Вариант с errors.Join сохраняет причины, а errors.Is и errors.As позволяют выполнять стандартную классификацию.

Выбранное решение — вернуть errors.Join и отдельно договориться, что для машинной обработки используется errors.Is или errors.As, а полный рекурсивный обход применяется только в слое формирования отчёта. Это сохраняет совместимость со стандартным API и не связывает бизнес-логику с внутренним типом ошибки errors.Join.

Что кандидаты часто упускают

  1. Дополнительный вопрос: можно ли заменить errors.Unwrap на последовательный вызов errors.Is для получения всех причин?

    Нет. errors.Is отвечает на вопрос о наличии конкретной цели и не возвращает найденные узлы дерева. Его можно вызывать для заранее известных категорий, но для формирования полного списка причин нужен собственный обход или отдельная структура данных, которую предоставляет вызывающий код.

  2. Дополнительный вопрос: обязана ли функция, возвращающая errors.Join, сохранять порядок причин?

    Порядок переданных ненулевых ошибок сохраняется в структуре объединённой ошибки, но прикладной код не должен использовать его как бизнес-семантику без явного контракта. Порядок может быть случайным, если ошибки собраны из конкурентных операций, поэтому для стабильного вывода их следует сортировать по собственному признаку.

  3. Дополнительный вопрос: почему нельзя утверждать результат errors.Join к конкретному типу ошибки из стандартной библиотеки?

    Реализация объединённой ошибки является внутренней деталью пакета errors. Контрактом служит поведение методов Error и Unwrap() []error, а не конкретное имя или структура реализации. Поэтому следует проверять поведенческий интерфейс либо использовать errors.Is и errors.As, не завязываясь на неэкспортируемый тип.