Спецификация обработки ошибок в Golang

Типы ошибок

Существует несколько вариантов объявления ошибок. Прежде чем выбрать наилучший вариант для вашего случая использования, рассмотрите следующее:

  • Нужно ли вызывающему соответствовать ошибке, чтобы обрабатывать её? Если да, то мы должны поддерживать функции errors.Is или errors.As, объявляя переменные верхнего уровня ошибок или пользовательские типы.
  • Является ли сообщение об ошибке статической строкой или динамической строкой, требующей контекстной информации? Для статических строк мы можем использовать errors.New, но для последних мы должны использовать fmt.Errorf или пользовательский тип ошибки.
  • Передаем ли мы новые ошибки, возвращаемые вложенными функциями? Если да, обратитесь к разделу об оборачивании ошибок.
Соответствие ошибке? Сообщение об ошибке Руководство
Нет статическое errors.New
Нет динамическое fmt.Errorf
Да статическое верхнеуровневая var с errors.New
Да динамическое пользовательский тип error

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

Без сопоставления ошибок

// пакет foo

func Open() error {
  return errors.New("не удалось открыть")
}

// пакет bar

if err := foo.Open(); err != nil {
  // Невозможно обработать ошибку.
  panic("неизвестная ошибка")
}

Сопоставление ошибок

// пакет foo

var ErrCouldNotOpen = errors.New("не удалось открыть")

func Open() error {
  return ErrCouldNotOpen
}

// пакет bar

if err := foo.Open(); err != nil {
  if errors.Is(err, foo.ErrCouldNotOpen) {
    // обработать ошибку
  } else {
    panic("неизвестная ошибка")
  }
}

Для ошибок с динамическими строками используйте fmt.Errorf, если вызывающему не нужно сопоставлять её. Если вызывающему действительно нужно сопоставить её, используйте пользовательский тип error.

Без сопоставления ошибок

// пакет foo

func Open(file string) error {
  return fmt.Errorf("файл %q не найден", file)
}

// пакет bar

if err := foo.Open("testfile.txt"); err != nil {
  // Невозможно обработать ошибку.
  panic("неизвестная ошибка")
}

Сопоставление ошибок

// пакет foo

type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("файл %q не найден", e.File)
}

func Open(file string) error {
  return &NotFoundError{File: file}
}

// пакет bar

if err := foo.Open("testfile.txt"); err != nil {
  var notFound *NotFoundError
  if errors.As(err, &notFound) {
    // обработать ошибку
  } else {
    panic("неизвестная ошибка")
  }
}

Обратите внимание, что если вы экспортируете переменные или типы ошибок из пакета, они становятся частью публичного API пакета.

Обертывание ошибок

При возникновении ошибки при вызове другого метода обычно существуют три способа ее обработки:

  • Возвращение исходной ошибки в исходном виде.
  • Использование fmt.Errorf с %w для добавления контекста к ошибке и затем возврат ее.
  • Использование fmt.Errorf с %v для добавления контекста к ошибке и затем возврат ее.

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

В противном случае добавьте контекста к сообщению об ошибке насколько это возможно, чтобы избежать появления неоднозначных ошибок, например "соединение отклонено". Вместо этого вы получите более полезные ошибки, такие как "вызов сервиса foo: соединение отклонено".

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

  • Используйте %w, если вызывающий должен иметь доступ к исходной ошибке. Это хороший выбор для большинства обертывающих ошибок, но имейте в виду, что вызывающий может начать полагаться на это поведение. Поэтому для обертывающих ошибок, которые известны как переменные или типы, записывайте и тестируйте их в рамках контракта функции.
  • Используйте %v для замещения исходной ошибки. Вызывающий не сможет ее сопоставить, но вы можете переключиться на %w в будущем при необходимости.

При добавлении контекста к возвращенной ошибке избегайте использования фраз вроде "не удалось", чтобы сохранить контекст кратким. Когда ошибка пронизывает стек, она будет накапливаться слоями:

Не рекомендуется:

s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "не удалось создать новое хранилище: %w", err)
}

// не удалось выполнить x: не удалось выполнить y: не удалось создать новое хранилище: ошибка

Рекомендуется:

s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "новое хранилище: %w", err)
}
// x: y: новое хранилище: ошибка

Однако, когда ошибка отправляется в другую систему, должно быть понятно, что сообщение является ошибкой (например, тег "err" или префикс "Failed" в журналах).

Некорректное наименование

Для значений ошибок, хранящихся как глобальные переменные, используйте префикс Err или err в зависимости от того, экспортируются они или нет. Пожалуйста, обратитесь к рекомендациям. Для неэкспортируемых констант и переменных верхнего уровня используйте подчеркивание (_).

var (
  // Экспортируйте следующие две ошибки, чтобы пользователи этого пакета могли сопоставить их с errors.Is.
  ErrBrokenLink = errors.New("ссылка сломана")
  ErrCouldNotOpen = errors.New("не удалось открыть")

  // Эта ошибка не экспортируется, потому что мы не хотим, чтобы она была частью нашего общедоступного API. Однако мы все равно можем использовать ее внутри пакета с помощью errors.
  errNotFound = errors.New("не найдено")
)

Для пользовательских типов ошибок используйте суффикс Error.

// Точно так же эта ошибка экспортируется, чтобы пользователи этого пакета могли сопоставить ее с errors.As.
type NotFoundError struct {
  File string
}

func (e *NotFoundError) Error() string {
  return fmt.Sprintf("файл %q не найден", e.File)
}

// Эта ошибка не экспортируется, потому что мы не хотим, чтобы она была частью общедоступного API. Однако мы все равно можем использовать ее внутри пакета с помощью errors.As.
type resolveError struct {
  Path string
}

func (e *resolveError) Error() string {
  return fmt.Sprintf("решение %q", e.Path)
}

Обработка ошибок

При получении вызывающим ошибку от вызываемой стороны, он может обрабатывать ошибку различными способами в зависимости от понимания ошибки.

Это включает в себя, но не ограничивается:

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

Независимо от того, как вызывающая сторона обрабатывает ошибку, typically она должна обрабатывать каждую ошибку только один раз. Например, вызывающая сторона не должна регистрировать ошибку и затем возвращать ее, так как ее вызывающая сторона также может обработать ошибку.

Например, рассмотрим следующие сценарии:

Плохо: Регистрация ошибки и возвращение ее

Другие вызывающие стороны далее в стеке могут предпринять аналогичные действия по этой ошибке. Это создаст много шума в журналах приложения с малым эффектом.

u, err := getUser(id)
if err != nil {
  // ПЛОХО: Смотри описание
  log.Printf("Could not get user %q: %v", id, err)
  return err
}

Хорошо: Обертывание ошибки и возвращение ее

Ошибки, далее в стеке, будут обрабатывать эту ошибку. Использование %w гарантирует, что они могут сопоставить ошибку с errors.Is или errors.As, если это уместно.

u, err := getUser(id)
if err != nil {
  return fmt.Errorf("get user %q: %w", id, err)
}

Хорошо: Регистрация ошибки и плавное ухудшение

Если операция не является абсолютно необходимой, мы можем обеспечить гармоничное ухудшение, восстановившись от нее, не прерывая опыт.

if err := emitMetrics(); err != nil {
  // Неудачная попытка записи метрик не должна
  // нарушить работу приложения.
  log.Printf("Could not emit metrics: %v", err)
}

Хорошо: Сопоставление ошибки и гармоничное ухудшение по необходимости

Если вызываемая сторона определила конкретную ошибку в своем соглашении и отказ можно восстановить, сопоставьте этот случай ошибки и выполните гармоничное ухудшение. Для всех остальных случаев оберните ошибку и верните ее. Ошибки, далее в стеке, будут обрабатывать другие ошибки.

tz, err := getUserTimeZone(id)
if err != nil {
  if errors.Is(err, ErrUserNotFound) {
    // Пользователь не существует. Используйте UTC.
    tz = time.UTC
  } else {
    return fmt.Errorf("get user %q: %w", id, err)
  }
}

Обработка утверждений оказания услуг

Типовые утверждения вызовут панику с одним возвращаемым значением в случае неправильного определения типа. Поэтому всегда используйте "запятая, ок" идиому.

Не рекомендуется:

t := i.(string)

Рекомендуется:

t, ok := i.(string)
if !ok {
  // Гармонично обработайте ошибку
}

Избегайте использования panic

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

Не рекомендуется:

func run(args []string) {
  if len(args) == 0 {
    panic("требуется аргумент")
  }
  // ...
}

func main() {
  run(os.Args[1:])
}

Рекомендуется:

func run(args []string) error {
  if len(args) == 0 {
    return errors.New("требуется аргумент")
  }
  // ...
  return nil
}

func main() {
  if err := run(os.Args[1:]); err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
}

panic/recover не является стратегией обработки ошибок. Он должен вызываться только тогда, когда происходит неисправимое событие (например, nil-ссылка). Исключение составляет инициализация программы: ситуации, которые могут привести к вызову panic, должны быть обработаны во время запуска программы.

var _statusTemplate = template.Must(template.New("name").Parse("_statusHTML"))

Даже в тестовом коде предпочтительнее использовать t.Fatal или t.FailNow вместо panic для обеспечения пометки сбоев.

Не рекомендуется:

// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  panic("сбой настройки теста")
}

Рекомендуется:

// func TestFoo(t *testing.T)

f, err := os.CreateTemp("", "test")
if err != nil {
  t.Fatal("сбой настройки теста")
}