Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Использование PVS-Studio при разработке встраиваемых систем - 14.05

>
>
>
Как можно ошибиться при обработке...

Как можно ошибиться при обработке ошибок в Go

08 Май 2026

Tе, кто до Go писал на языках с конструкциями try/catch, таких как Java и C#, наверняка испытывают легкое замешательство. Руки так и тянутся к try/catch заменителю — использованию recover в связке с defer — как к самому очевидному аналогу. Но это плохая практика и так делать не стоит. В этой статье разберёмся, почему и как можно ошибиться при обработке ошибок в Go.

Введение

Идея статьи появилась спонтанно. Материалов про обработку ошибок в Go и так достаточно, поэтому не хотелось повторять одно и то же.

Но ситуация изменилась в процессе реализации и тестирования диагностических правил для нового Go анализатора PVS-Studio. Мы уже описывали в статье, как тестируем свой анализатор на реальных проектах.

Если кратко: во время тестирования нашего статического анализатора PVS-Studio на реальных и достаточно крупных проектах с GitHub были обнаружены типовые ошибки при обработке ошибок (извините за тавтологию), которыми мы и хотим поделиться в этой статье. Возможно, в будущем таких ошибок станет меньше, как говорится: "Предупреждён — значит вооружён".

Но для тех, кто не в курсе или хочет постепенно войти в контекст, давайте разберёмся вообще, как принято обрабатывать ошибки в Go, и какая идея за этим стоит.

Философия обработки ошибок в Go

Отказ от обработки ошибок через выброс и перехват исключений — не недоработка разработчиков языка, а осознанное решение. Авторы языка считают, что привязывание обработки ошибок к управляющей конструкции try catch приводит к тому, что код становится излишне извилистым и сложным. А также вынуждает рассматривать простые ошибки как исключения (прочитать эту информацию можно здесь).

Например, наша программа должна открывать файл и вносить туда какие-то изменения. Возможны различные ситуации: файл может как существовать, так и отсутствовать, поэтому странно относиться к ошибке открытия файла, как к исключению.

Go придерживается простого принципа: ошибка — это значение. Обычное значение, которое функция возвращает явно и которое вызывающий код обязан явно обработать. Например:

file, err := os.Open(filename)
if err != nil {
  // error handling
}

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

Но зачем тогда panic и recover?

Паника — это сигнал о неустранимой ошибке. Это ситуации, которых в корректно написанной программе быть не должно: выход за границы среза, разыменование nil-указателя и так далее. Механизм recover же позволяет перехватить панику без падения всей программы. Но использовать эту связку как замену try/catch — значит идти против идиоматики языка и общепринятых норм.

Однако есть сценарии использования отлавливания паники, когда это уместно. Самый распространенный вариант — на границах пакетов или горутин, чтобы паника внутри не роняла всю программу:

func (e *encodeState) marshal(v any, opts encOpts) (err error) {
  defer func() {
    if r := recover(); r != nil {
      if je, ok := r.(jsonError); ok {
        err = je.error
      } else {
        panic(r)
      }
    }
  }()
  e.reflectValue(reflect.ValueOf(v), opts)
  return nil
}

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

То есть recover должен служить защитным барьером от падения программы, а не механизмом бизнес-логики.

Как можно ошибиться при обработке ошибок

Мы разобрались, что в Go ошибку надо записывать в переменную и проверять её на nil. Казалось бы, проще некуда, но даже при таком простом подходе можно ошибиться. В качестве примера давайте рассмотрим код из открытого инструмента для менеджмента чувствительных данных — vault.

func (c *Core) PersistTOTPKey(....) error {
  ks := &totpKey{
    Key: key,
  }
  val, err := jsonutil.EncodeJSON(ks)
  if err != nil {
    return err
  }
  if c.barrier.Put(ctx, &logical.StorageEntry{
    Key:   fmt.Sprintf(....),
    Value: val,
  }); err != nil {
    return err
  }
  return nil
}

Предупреждение PVS-Studio: V8014 Two 'if' statements have identical conditions. The first 'if' statement contains function return, making the second 'if' redundant, or the code contains a logical error. login_mfa.go 1099

В данном фрагменте кода можно заметить проверку ошибки, полученную в результате вызова функции jsonutil.EncodeJSON(ks). Затем эта же ошибка проверяется в следующем условии:

if c.barrier.Put(ctx, &logical.StorageEntry{
        Key:   fmt.Sprintf(....),
        Value: val,
    }); err != nil {
        return err
    }

Очевидно, что нет смысла дважды проверять одну и ту же ошибку. Кроме того, если err != nil является верным, то произойдёт возврат из метода. Скорее всего, предполагалось получить из метода Put какое-то значение и проверить его. Тем более метод Put возвращает значение ошибки, которое как раз стоит проверить:

type Storage interface {
  ....
  Put(context.Context, *StorageEntry) error
  ....
}

Поэтому исправить код можно следующим образом:

val, err := jsonutil.EncodeJSON(ks)
if err != nil {
  return err
}
if err := c.barrier.Put(ctx, &logical.StorageEntry{
  Key:   fmt.Sprintf(....),
  Value: val,
  }); err != nil {
    return err
}

В таком случае во втором if мы проверим ошибку, полученную в результате метода Put.

А в исходном коде проекта FerretDB, который является альтернативой MongoDB, можно встретить следующее упущение:

func (h *Handler) msgKillCursors(....) (*middleware.Response, error) {
  ....
  cursorsV, err := getRequiredParamAny(doc, "cursors")
  if err != nil {
    return nil, err
  }

  curArr, ok := cursorsV.(wirebson.AnyArray)
  if !ok {                                             // <=
    msg := fmt.Sprintf(....)
    return nil, lazyerrors.Error(....)
  }

  cursors, err := curArr.Decode()
  if !ok {                                             // <=
    return nil, lazyerrors.Error(err)
  }
  ....
}

Предупреждение PVS-Studio: V8014 Two 'if' statements have identical conditions. The first 'if' statement contains function return, making the second 'if' redundant, or the code contains a logical error. msg_killcursors.go 61

В данном случае значение переменной err изменяется. Но зато дважды проверяется успешность приведения переменной cursorsV к типу wirenson.AnyArray. Скорее всего второе условие !ok написали случайно, а код должен выглядеть следующим образом:

cursors, err := curArr.Decode()
if err != nil {
  return nil, lazyerrors.Error(err)
}

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

func (d *proxy) Start() (*deviceConfig.RunConfig, error) {
  ....
  err = p.Save(pidPath)
  if err != nil {
    // Kill Process if started, but could not save the file
    err2 := p.Stop()
    if err != nil {
      return fmt.Errorf("....: %s: %s", err, err2)
  }

    return fmt.Errorf("....: %w", d.name, err)
  }
  ....
}

Предупреждение PVS-Studio: V8020 Recurring check. Thе 'err != nil' condition was already verified on line 407 proxy.go 407

Очевидно, что во втором условии стоит проверять err2 вместо err, учитывая, что один раз err уже была проверена, и после этого она не менялась. А также err2 фигурирует в обработке ошибки:

return fmt.Errorf("....: %s: %s", err, err2)

Поэтому было бы странно её не проверить.

Больше всего подобных ошибок с помощью анализатора PVS-Studio было найдено в открытом коде платформы Rancher. Целых 65 срабатываний такого вида:

func (c *MachineClient) ListAll(opts *types.ListOpts) (....) {
  resp := &MachineCollection{}
  resp, err := c.List(opts)
  if err != nil {
    return resp, err
  }
  data := resp.Data
  for next, err := resp.Next(); 
    next != nil && err == nil; next, err = next.Next() {
    data = append(data, next.Data...)
    resp = next
    resp.Data = data
  }
  if err != nil {
    return resp, err
  }
  return resp, err
}

Предупреждение PVS-Studio: V8014 Two 'if' statements have identical conditions. The first 'if' statement contains function return, making the second 'if' redundant, or the code contains a logical error. zz_generated_cluster.x-k8s.io.machine.go 113

Этот фрагмент кода очень интересен тем, что вполне себе логичен: итерация по коллекции происходит до тех пор, пока next != nil и err == nil. И после цикла полезно проверитьerr != nil. Но есть один нюанс.

Анализатор подсказывает нам, что фигурирующий в цикле err — это не тот же самый err, который проверяется после цикла. err, проверяемый после цикла, — это все та же ошибка, которая получается в результате объявления:

resp, err := c.List(opts)

То есть, если во время итерации окажется, что err != nil, то цикл прервется, и в таком случае хотелось бы вывести ошибку. Но будет выведена ошибка, которая получена в результате приведённого выше объявления.

Чтобы исправить это, достаточно проверять err внутри самого цикла с последующим return. Тогда метод вернёт ту ошибку, которая получилась в результате итераций.

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

Мне стало интересно поискать корни этого кода, и я наткнулся на коммит, в котором добавили аж 307251 строчек кода. А комментарий "Add generated code" позволяет нам окончательно убедиться в том, что код с ошибкой был сгенерирован, и затем его разнесли по всему проекту

Заключение

Что в итоге? Go бесспорно более ясный и явный в обработке ошибок. Но, тем не менее, ошибку допустить все-таки возможно. Вы могли убедиться в этом при просмотре примеров из открытых проектов с GitHub. Примечательно, что проекты достаточно большие и популярные (некоторые больше 30k звезд), но в них все равно можно найти простейшие ошибки.

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

Недавно в PVS-Studio появилась программа раннего доступа к нашим новым анализаторам, в которой вы можете принять участие по ссылке! EAP доступно по направлениям JavaScript, TypeScript и Go.

Берегите себя и свой код!

Подписаться на рассылку
Хотите раз в месяц получать от нас подборку вышедших в этот период самых интересных статей и новостей? Подписывайтесь!
Популярные статьи по теме

Комментарии (0)

Следующие комментарии next comments
close comment form