Как ошибка в реализации Unwrap может привести к зависанию errors.Is?
Если метод Unwrap возвращает саму ошибку напрямую или формирует цикл из нескольких ошибок, обход, выполняемый errors.Is, может не завершиться. Метод Unwrap должен строить ацикличную цепочку или дерево причин; циклическая структура является ошибкой реализации.
Механизм обёртки ошибок появился в стандартной библиотеке Go 1.13, чтобы функция могла добавлять контекст и одновременно сохранять исходную причину. Для анализа такой структуры были введены errors.Is, errors.As и соглашение о методе Unwrap.
Эти функции рассчитаны на последовательный или древовидный обход причин. Такой подход позволяет не разбирать текст ошибки, но требует, чтобы структура, возвращаемая Unwrap, оставалась корректной и конечной.
Разработчик может реализовать Unwrap как обычный метод, но по ошибке вернуть текущий объект вместо сохранённой причины. Тогда поиск причины начинает посещать одну и ту же ошибку снова и снова.
Последствие — вызов errors.Is или errors.As может не вернуть результат, удерживать горутину и загружать процессор. Особенно опасно, если такой вызов находится в обработчике запроса: зависшие операции постепенно исчерпают доступные рабочие горутины.
Unwrap должен возвращать следующую причину, а не текущую ошибку и не одну из уже посещённых ошибок. Для обычной обёртки это означает конечную цепочку: внешняя ошибка указывает на внутреннюю, внутренняя — на свою причину или на nil.
В примере Unwrap возвращает сам объект. errors.Is проверяет текущую ошибку, затем вызывает Unwrap и получает ту же ошибку снова. Поскольку цикл не имеет конечной причины, поиск не достигает ни совпадения, ни nil.
Методы Unwrap() error образуют цепочку, а Unwrap() []error — дерево причин, например после объединения ошибок. В обоих случаях структура должна быть ацикличной. Не следует рассчитывать на автоматическое обнаружение циклов: контракт обработки ошибок не предназначен для циклических графов.
Если у ошибки нет причины, лучше вообще не объявлять Unwrap. Если причина есть, храните её в отдельном поле и возвращайте именно это поле. Полезно также покрывать пользовательские типы ошибок тестами, проверяющими, что errors.Is и errors.As завершаются и находят ожидаемую причину.
В HTTP-сервисе добавили тип ошибки для записи в журнал. Он должен был сохранять исходную ошибку базы данных, но реализация Unwrap по невнимательности возвращала саму ошибку. При проверке результата запроса через errors.Is обработчик переставал отвечать, а таймаут на уровне клиента не освобождал уже занятую горутину немедленно.
Рассматривались три варианта. Удалить Unwrap было бы безопасно, но потеряло бы возможность программно анализировать исходную причину. Добавить защиту от повторного посещения непосредственно в каждом обработчике можно, но это дублирует логику и не исправляет ошибочный тип ошибки. Исправить Unwrap так, чтобы он возвращал сохранённую причину, — наиболее простой и совместимый вариант.
Выбрали третий вариант и добавили тесты на прямую причину, длинную цепочку и отсутствие цикла. В результате контекст ошибки сохранился, errors.Is снова начал корректно находить причину, а обработчик перестал зависать.
1. Может ли такая же проблема возникнуть у errors.As?
Да. errors.As также обходит цепочку или дерево, полученное через Unwrap. Если структура циклическая и нужный тип не найден до входа в цикл, поиск может не завершиться. Поэтому корректность Unwrap важна не только для проверки категорий через errors.Is, но и для извлечения структурированной ошибки через errors.As.
2. Обнаруживает ли errors.Is цикл автоматически?
На это нельзя полагаться. Публичный контракт описывает поиск по цепочке или дереву причин, но не обещает защиту от произвольных циклических графов. Цикл нужно предотвращать в реализации ошибки, а не ожидать, что стандартная библиотека исправит некорректную структуру.
3. Является ли любая повторная причина ошибкой?
Нет. Одна и та же ошибка может быть осмысленно использована в нескольких ветвях дерева, например при объединении независимых причин. Проблема возникает именно тогда, когда обход из ошибки позволяет снова попасть в уже пройденный путь и не имеет гарантированного конечного узла. Поэтому важна ацикличность, а не простое отсутствие одинаковых значений в разных ветвях.