В публичном пакете sentinel-ошибка объявлена экспортируемой переменной: какой риск для корректности errors.Is создаёт её переназначение?
Экспортируемую sentinel-ошибку можно переназначить из другого пакета. После этого errors.Is начнёт сравнивать ошибки с новым значением, поэтому ошибки, созданные до переназначения, могут перестать распознаваться как та же категория.
Для стабильного контракта не следует допускать изменение такой переменной: обычно её не переназначают по соглашению, а для более строгого контроля используют типизированную ошибку или публичный предикат, скрывающий внутренний sentinel.
В Go sentinel-ошибки традиционно представляют заранее известные категории вроде отсутствия ресурса или отмены операции. Пакет экспортирует значение ошибки, а вызывающий код сравнивает его напрямую либо через errors.Is.
Позднее стандартный механизм wrapping позволил сохранять исходную причину внутри обёрток. Однако сам sentinel остаётся обычной переменной Go, а не неизменяемой константой: значения интерфейсного типа нельзя объявить через const.
Пусть пакет вернул ошибку, содержащую старое значение sentinel. Если затем экспортируемая переменная была переназначена, выражение errors.Is(err, package.ErrCategory) проверит уже новое значение.
В результате одинаковые по смыслу ошибки начнут обрабатываться по-разному в зависимости от момента их создания. Переназначение во время работы программы дополнительно создаёт риск гонки данных при одновременном чтении и записи переменной.
Минимальная иллюстрация:
Содержимое текстов ошибок здесь одинаково, но errors.Is не сравнивает строки. Он проверяет идентичность или специально определённое соответствие ошибки, поэтому две разные переменные, созданные через errors.New, не становятся одной причиной только из-за одинакового текста.
Экспортируемая переменная является частью публичного API, но её значение изменяемо. Импортирующий код технически может присвоить ей другую ошибку, даже если автор пакета рассчитывал на использование только для чтения.
Нормальная практика — не переназначать sentinel после публикации пакета. Это сохраняет стабильную идентичность значения, благодаря чему errors.Is распознаёт как сам sentinel, так и ошибки, обёрнутые вокруг него.
Если API должен сильнее защищать внутреннюю реализацию, можно скрыть sentinel и предоставить функцию-предикат, например IsNotFound(error) bool. Тогда вызывающий код не зависит от экспортируемой переменной, а пакет сам контролирует критерий классификации.
Другой вариант — публичный тип ошибки с методом Is, который определяет принадлежность к категории. Это удобно, когда кроме категории нужно передавать контекст: имя ресурса, идентификатор или дополнительные поля. Компромисс состоит в том, что тип становится частью API и требует осторожного эволюционного проектирования.
Скрытая переменная, возвращаемая функцией, сохраняет стабильность только при условии, что функция каждый раз возвращает одно и то же внутреннее значение. Если она создаёт новую ошибку при каждом вызове, сравнение через errors.Is по идентичности работать не будет.
В библиотеке был экспортирован sentinel ErrUnavailable. Один из тестов приложения заменял его на локальную ошибку, чтобы проверить обработку отказа. После этого часть уже созданных ошибок перестала распознаваться через errors.Is, а поведение зависело от порядка запуска тестов.
Рассматривались три решения. Оставить переменную как есть было проще всего, но сохраняло возможность случайного изменения. Заменить её на публичный тип обеспечивало расширяемость, но добавляло тип в контракт. Скрыть sentinel и предоставить функцию IsUnavailable уменьшало связанность с реализацией, однако требовало отдельного API-метода.
Для библиотеки выбрали предикат и внутренний sentinel. Предикат проверял категорию через errors.Is с внутренней причиной, поэтому внешний код больше не мог заменить используемое значение. В результате обработка стала стабильной, а внутреннее представление ошибки осталось изменяемым.
Достаточно ли сделать переменную неэкспортируемой, чтобы вызывающий код мог использовать errors.Is?
Нет, напрямую передать скрытый sentinel вызывающий код не сможет. Если нужно сохранить именно внешний вызов errors.Is, пакет может вернуть стабильную ошибку через функцию, но тогда эта функция фактически публикует способ получить объект для сравнения. Более независимый от представления вариант — предоставить предикат или публичный тип с методом Is.
Поможет ли одинаковый текст ошибки после переназначения sentinel?
Нет. Для обычных ошибок errors.Is не считает равными два разных значения только потому, что их Error() возвращает одинаковую строку. Текст предназначен прежде всего для диагностики, а не для надёжной машинной классификации.
Почему типизированная ошибка часто безопаснее sentinel-переменной, если категории нужно расширять?
Тип позволяет передавать структурированные данные и определять принадлежность через errors.As либо метод Is, не полагаясь на изменяемую экспортируемую переменную. При этом тип становится частью публичного API: его поля, методы и правила совместимости придётся поддерживать. Sentinel проще для фиксированной категории, но хуже подходит для богатого контекста и не защищён от переназначения, если экспортирован как переменная.