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

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

![1371_Error_Handling_Issue_ru/image1.png](https://import.viva64.com/docx/blog/1371_Error_Handling_Issue_ru/image1.png)

## Введение

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

Но ситуация изменилась в процессе реализации и тестирования диагностических правил для нового Go анализатора PVS\-Studio\. Мы уже описывали в [статье](https://pvs-studio.ru/ru/blog/posts/go/1342/), как тестируем свой анализатор на реальных проектах\. 

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

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

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

Отказ от обработки ошибок через выброс и перехват исключений — не недоработка разработчиков языка, а осознанное решение\. Авторы языка считают, что привязывание обработки ошибок к управляющей конструкции `try catch` приводит к тому, что код становится излишне извилистым и сложным\. А также вынуждает рассматривать простые ошибки как исключения \(прочитать эту информацию можно [здесь](https://go.dev/doc/faq#exceptions)\)\.

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

Go придерживается простого принципа: [ошибка — это значение](https://go.dev/blog/errors-are-values)\. Обычное значение, которое функция возвращает явно и которое вызывающий код обязан явно обработать\. Например:

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

Да, на первый взгляд выглядит многословно, и это явный [бойлерплейт](https://ru.wikipedia.org/wiki/Шаблонный_код)\. Но, с другой стороны, поток управления абсолютно понятен, и программу удобно читать сверху вниз\.

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

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

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

```cpp
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`](https://go.dev/src/encoding/json/encode.go)\. В данном случае, если происходит паника, которая разворачивает стек до вызова функции верхнего уровня, то программа восстанавливается после ошибки и возвращает соответствующее значение ошибки\. 

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

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

Мы разобрались, что в Go ошибку надо записывать в переменную и проверять её на `nil`\. Казалось бы, проще некуда, но даже при таком простом подходе можно ошибиться\. В качестве примера давайте рассмотрим код из открытого инструмента для менеджмента чувствительных данных — [vault](https://github.com/hashicorp/vault)\.

```cpp
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](https://pvs-studio.ru/ru/docs/warnings/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](https://github.com/hashicorp/vault/blob/d73581e2356f73d0138b06da2e66ce5ebfdf1a0a/vault/login_mfa.go#L1099)

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

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

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

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

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

```cpp
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](https://github.com/FerretDB/FerretDB/), который является альтернативой MongoDB, можно встретить следующее упущение:

```cpp
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](https://pvs-studio.ru/ru/docs/warnings/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](https://github.com/FerretDB/FerretDB/blob/799235dab9e350655e72e65a0b24d849f7d68143/internal/handler/msg_killcursors.go#L61)

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

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

Код, в котором случайно проверяют одну и ту же ошибку, можно также встретить в менеджере виртуальных машин [Incus](https://github.com/lxc/incus)\.

```cpp
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](https://pvs-studio.ru/ru/docs/warnings/v8020/) Recurring check\. Thе 'err \!\= nil' condition was already verified on line 407 [proxy\.go 407](https://github.com/lxc/incus/blob/321c40f963e9c1e7b68736fb4941431cccac3d60/internal/server/device/proxy.go#L407)

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

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

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

Больше всего подобных ошибок с помощью анализатора PVS\-Studio было найдено в открытом коде платформы [Rancher](https://github.com/rancher/rancher)\. Целых 65 срабатываний такого вида:

```cpp
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](https://pvs-studio.ru/ru/docs/warnings/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](https://github.com/rancher/rancher/blob/ec043c0cdf36b6bfe8ebc938c24af878a7a76d20/pkg/client/generated/cluster/v1beta2/zz_generated_cluster.x-k8s.io.machine.go#L113)

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

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

```cpp
resp, err := c.List(opts)
```

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

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

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

Мне стало интересно поискать корни этого кода, и я наткнулся на [коммит](https://github.com/rancher/rancher/commit/3e5ab6a7baf58a2d63d1f07b9bdeb79b2c7d3875), в котором добавили аж 307251 строчек кода\. А комментарий "Add generated code" позволяет нам окончательно убедиться в том, что код с ошибкой был сгенерирован, и затем его разнесли по всему проекту

## Заключение

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

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


> Недавно в PVS\\\-Studio появилась программа раннего доступа к нашим новым анализаторам, в которой вы можете принять участие по \[ссылке\]\(https://pvs\-studio\.ru/ru/pvs\-studio\-eap/\)\\\! EAP доступно по направлениям JavaScript, TypeScript и Go\\\.



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