сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Ошибки и рантайм Go

Sentinel и свои типы ошибок в Go

Sentinel и собственные типы ошибок

Пользователю нужно отличить «записи нет» от «нет прав» - в первом случае показать пустую форму, во втором отправить на страницу входа. Разбирать текст ошибки строкой нельзя: он поменяется на следующем рефакторинге. Значит пакет обязан дать способ различать причины программно, и способов ровно два.

Стержень: sentinel - одно известное значение для факта, свой тип - для факта вместе с деталями.

// Формулировки: «что такое sentinel-ошибка?», «когда нужен свой тип?», «как отличить not found от отказа в доступе?»

Sentinel: одно значение на пакет

Sentinel - переменная уровня пакета, созданная через errors.New один раз при запуске: var ErrNotFound = errors.New("не найдено"). Она сравнивается по идентичности, поэтому важна именно та самая переменная, а не другая с тем же текстом.

Проверяют через errors.Is - он пройдёт цепочку обёрток и найдёт эталон на любой глубине. Именно так устроены sql.ErrNoRows, io.EOF (end of file, признак конца потока), os.ErrNotExist: ты сравниваешь с известным значением и точно знаешь, что случилось.

Ограничение очевидно из устройства: sentinel несёт только факт. Какое поле не прошло проверку, какой идентификатор не нашёлся, сколько осталось попыток - всё это в него не помещается.

// Объявляй sentinel-ошибки экспортируемыми и с префиксом Err - это устоявшаяся конвенция, по которой их узнают. И помни, что публичный sentinel - часть контракта пакета: убрать его потом нельзя, кто-то уже написал на него проверку.

var ErrNotFound = errors.New("не найдено")

// в глубине пакета
return fmt.Errorf("пользователь %d: %w", id, ErrNotFound)

// у вызывающего
if errors.Is(err, ErrNotFound) { return http.StatusNotFound }

Свой тип: факт плюс детали

Когда нужны подробности, объявляют структуру с методом Error() string. Внутри - всё, что понадобится вызывающему: поле, значение, код ответа, время до следующей попытки.

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

Одна деталь ловит новичков: метод Error объявляют на указателе, значит и в цепочке лежит *MyErr, и в As надо передавать указатель на переменную типа *MyErr. Смешение получателя-значения и получателя-указателя тут даёт «ошибка не находится», хотя она есть.

// Если типов ошибок в пакете много, их часто объединяют одним интерфейсом с дополнительным методом - например Temporary() bool или Code() int. Тогда вызывающий проверяет через As по интерфейсу и не зависит от конкретных структур.

type ValidationError struct {
    Field string
    Value any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("поле %s: недопустимое значение %v", e.Field, e.Value)
}

var ve *ValidationError
if errors.As(err, &ve) {
    highlight(ve.Field)   // подсветить именно это поле формы
}

Что выбрать

Правило простое: нужен только факт - sentinel, нужны детали - свой тип. «Записи нет» это чистый факт, тут sentinel. «Поле email не прошло проверку» это факт с деталями, тут структура.

Не плоди сущности заранее. Три десятка sentinel-значений на пакет - признак того, что автор пытался предусмотреть каждый случай; пользователи всё равно проверяют два-три из них. Начинай с одного-двух и добавляй по реальному спросу.

И не превращай в контракт то, что им быть не должно. Внутренние ошибки, интересные только тебе, наружу не выставляют: их заворачивают в свою ошибку через %v, чтобы пользователи не начали на них завязываться.

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

Как отвечать: «Sentinel или свой тип ошибки - что выбрать?»

Смотрю, нужен ли вызывающему только факт или ещё и подробности. Если факт - беру sentinel: переменная уровня пакета через errors.New, экспортируемая, с префиксом Err, проверяется через errors.Is. Так устроены sql.ErrNoRows и io.EOF, и это самый дешёвый вариант. Если нужны детали - какое поле не прошло, какой код вернул сервис, через сколько повторять - делаю структуру с методом Error и достаю её через errors.As. Тогда у вызывающего обычные поля, а не разбор текста. Метод Error я объявляю на указателе и помню, что в As надо передавать указатель на переменную типа указателя, иначе ошибка просто не найдётся. Чего стараюсь избегать: тридцати sentinel-значений на пакет заранее - реально проверяют два-три, остальные превращаются в контракт, который придётся тащить. И внутренние ошибки наружу через %w не пробрасываю: заворачиваю в свою, чтобы никто не завязался на детали реализации.

Критерий выбора сформулирован одним вопросом, названы конвенции и способ проверки для обоих вариантов, и упомянута ловушка с получателем-указателем в As.

На чём валят

  • Сравнивают ошибки по тексту вместо sentinel или своего типа.
  • Объявляют sentinel внутри функции, и каждое обращение создаёт новое значение.
  • Берут sentinel там, где вызывающему нужны детали, и заставляют его парсить строку.
  • Путают получатель метода Error и передают в As указатель не того уровня.
  • Заводят три десятка ошибок заранее и превращают их все в публичный контракт.
  • Не дают способа отличить временную ошибку от постоянной - и клиент повторяет всё подряд.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.

  1. #go_sentinel_custom1 / 5
    Нужно вернуть ошибку с полем Code int и прочитать его у вызывающего. Как правильно?
    A)Закодировать код прямо в текст сообщения и парсить эту строку у вызывающего
    B)Хранить код в глобальной переменной рядом с sentinel-ошибкой
    C)Свой тип с полем Code, реализующий error; доставать через errors.As
    D)Возвращать пару (error, int), чтобы код шёл отдельным значением рядом
    показать ответ и разбор
    +C)Свой тип с полем Code, реализующий error; доставать через errors.As

    // разбор: Для ошибки с данными заводят свой ТИП: type APIError struct{ Code int; ... } с методом Error(). Вернув его (в т.ч. обёрнутым через %w), вызывающий достаёт его из цепочки через errors.As(err, &apiErr) и читает apiErr.Code. Это типобезопасно и переживает оборачивание. Парсить текст или держать код в глобали — хрупко; лишний int в сигнатуре ломает единый error-контракт.

  2. #go_sentinel_custom2 / 5
    Как правильно вызвать errors.As, чтобы достать свою ошибку типа *MyErr?
    A)Передать строку с именем типа для сравнения через рефлексию
    B)Передать указатель на переменную: var e *MyErr; errors.As(err, &e)
    C)Передать сам тип: errors.As(err, (*MyErr)(nil))
    D)Передать нулевое значение типа: errors.As(err, MyErr{})
    показать ответ и разбор
    +B)Передать указатель на переменную: var e *MyErr; errors.As(err, &e)

    // разбор: As должен куда-то записать найденную ошибку, поэтому вторым аргументом идёт указатель на переменную нужного типа. Передадите не указатель — получите панику про «target must be a non-nil pointer». Разница с Is по смыслу: Is отвечает «это та самая ошибка?», As — «достань мне её как конкретный тип, чтобы прочитать поля».

  3. #go_sentinel_custom3 / 5
    Метод Error() объявлен на указателе (func (e *MyErr) Error() string). Что из этого следует?
    A)Интерфейс error реализует только *MyErr, значение MyErr — нет
    B)Ошибку не получится обернуть через fmt.Errorf с глаголом %w
    C)errors.Is перестанет находить такую ошибку в цепочке обёрток
    D)Компилятор потребует объявить и метод String() для парного вывода
    показать ответ и разбор
    +A)Интерфейс error реализует только *MyErr, значение MyErr — нет

    // разбор: Тот же набор методов, что и у любого типа: метод на указателе входит только в набор *MyErr. Возврат значения MyErr там, где ожидается error, просто не соберётся, и вернуть придётся &MyErr{...}. Отсюда же общее правило по типам-ошибкам: работать с ними через указатель и в errors.As объявлять переменную как *MyErr.

  4. #go_sentinel_custom4 / 5
    Почему sentinel-ошибки объявляют как переменные уровня пакета, а не создают заново при каждом возврате?
    A)Так экономится память: одно значение вместо аллокации на каждый возврат
    B)Сравнение идёт по идентичности значения, и новый экземпляр ему не равен
    C)Локальные переменные ошибок не видны снаружи пакета из-за области видимости
    D)Компилятор запрещает возвращать значение errors.New напрямую из функции
    показать ответ и разбор
    +B)Сравнение идёт по идентичности значения, и новый экземпляр ему не равен

    // разбор: errors.New каждый раз возвращает новое значение, и сравнение с ним через == или errors.Is даст ложь, даже если текст совпал дословно. Поэтому sentinel заводят один раз (var ErrNotFound = errors.New("not found")) и все возвраты ссылаются на него. Плата за такой контракт — жёсткая связанность: sentinel становится частью публичного API пакета и удалить его потом непросто.

  5. #go_sentinel_custom5 / 5
    Что нужно своему типу, чтобы его можно было вернуть как error?
    A)Поле с именем Message
    B)Встраивание типа errors.Base
    C)Метод Error() string
    D)Регистрация типа через errors.Register
    показать ответ и разбор
    +C)Метод Error() string

    // разбор: Интерфейс error состоит из единственного метода Error() string, и любой тип с ним автоматически ему удовлетворяет — объявлять это явно не нужно. Отсюда и типовая ловушка: объявишь метод на указателе — и интерфейс станет реализовывать только *MyErr.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.