golang

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

  • пятница, 13 июня 2025 г. в 00:00:10
https://habr.com/ru/articles/916904/

Обработка ошибок — это один из самых важных аспектов написания надёжного кода. В Go к этому вопросу подошли нестандартно: вместо традиционного механизма try/catch, как в Java или Python, ошибки просто возвращаются как значения. Изначально это может показаться странным, но на практике этот подход делает обработку ошибок более явной и честной.

В этой главе мы разберёмся с тем, когда стоит использовать panic , какие есть распространённые ошибки при его использовании и как правильно обрабатывать исключительные ситуации в Go.

Что такое panic?

Функция panic в Go останавливает нормальное выполнение программы и начинает "подниматься" по стеку вызовов, пока не завершится работа горутины или пока ошибка не будет перехвачена с помощью recover.

Пример:

func main() {

fmt.Println("a")

panic("foo")

fmt.Println("b") // Этот код никогда не выполнится

}

Результат:

a
panic: foo
goroutine 1 [running]:
main.main()
        main.go:7 +0xb3

После вызова panic выполнение текущей функции останавливается, и управление передаётся выше по стеку. Чтобы "отловить" панику, используется recover. Но важно понимать: recover работает только внутри функций, вызываемых через defer.

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recover", r)
        }
    }()
    fmt.Println("a")
    panic("foo")
}

Результат:

a
recover foo

Такой механизм полезен, если необходимо отреагировать на фатальную ошибку и предотвратить завершение работы всей программы.

Когда уместно использовать panic?

Хотя panic кажется удобным инструментом, его использование должно быть ограничено. Основные случаи, когда panic считается допустимым:

1. Ошибки программиста

Если проблема возникла из-за неправильного использования API или неверной логики кода, то это классический случай для panic. Например, в пакете net/http метод WriteHeaderпроверяет, что код ответа находится в диапазоне 100–999:

func checkWriteHeaderCode(code int) {
    if code < 100 || code > 999 {
        panic(fmt.Sprintf("invalid WriteHeader code %v", code))
    }
}

Это ошибка программиста, дальнейшее выполнение бессмысленно и лучше вызвать панику.

2. Невозможность инициализировать обязательную зависимость

Иногда приложение зависит от чего-то, без чего оно не может работать вообще. Например, регулярное выражение, которое нужно скомпилировать для валидации email. Если оно не скомпилируется, программа не сможет выполнять свою ключевую задачу.

Go предоставляет две функции: regexp.Compile (возвращает ошибку) и regexp.MustCompile(вызывает панику при ошибке). В случае обязательной зависимости правильнее использовать MustCompile:

re := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)

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

3. Регистрация драйверов в init-функциях

Ещё один пример — регистрация драйвера базы данных в пакете database/sql. Функция Register вызывается через init, и если произойдёт ошибка (например, драйвер уже зарегистрирован), она вызывает panic. Это оправдано, потому что, такие ошибки происходят ещё до запуска приложения. И их невозможно обработать штатно — они указывают на проблему в самой структуре кода.

func Register(name string, driver Driver) {
    if driver == nil {
        panic("sql: Register driver is nil")
    }
    if _, dup := drivers[name]; dup {
        panic("sql: Register called twice for driver " + name)
    }
    drivers[name] = driver
}

Когда НЕ стоит использовать panic?

Лучше не использовать panic для обработки обычных ошибок времени выполнения, таких как:

  • Неверный ввод пользователя.

  • Сбой сети.

  • Ошибка чтения файла.

  • Проблемы с доступом к БД.

Эти ситуации — часть нормального жизненного цикла программы. Для них предусмотрены стандартные ошибки (error) и их обработка.

Вызов panic в таких случаях усложняет управление состоянием программы, затрудняет тестирование и приводит к неожиданному поведению.

Игнорирование ошибки

В Go 1.13 появилась возможность оборачивание ошибок с помощью директивы %wв fmt.Errorf. Это дало нам инструмент для добавления контекста к ошибке и сохранения её оригинального значения. Но как часто бывает, с ростом возможностей приходит и риск их неправильного использования.

Что такое оборачивание ошибки?

Оборачивание — это процесс создания "контейнера", внутри которого хранится исходная ошибка. Это позволяет добавить полезный контекст (например, где произошла ошибка). Также можно маркировать ошибку как специфическую (например, пометить её типом ForbiddenError).

Пример:

if err != nil {
    return fmt.Errorf("failed to access DB for user alice: %w", err)
}

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

1. Если нужно добавить контекст

Если необходимо понять где, почему и в каком контексте возникла ошибка:

err := db.QueryRow("SELECT ...")
if err != nil {
    return fmt.Errorf("failed to fetch user info: %w", err)
}

Теперь на верхнем уровне можно увидеть не только техническую ошибку, но и то, где она случилась.

2. Если нужно пометить ошибку

Иногда важно знать, какого типа ошибка произошла. Например, можно создать собственный тип ошибки:

type ForbiddenError struct {
    Resource string
}

func (e ForbiddenError) Error() string {
    return fmt.Sprintf("access denied to %s", e.Resource)
}

Или просто обернуть ошибку, указав, что это была попытка доступа к запрещённому ресурсу:

return fmt.Errorf("forbidden access to document %q: %w", docID, err)

Потом, используя errors.As, проверить, есть ли в цепочке такая ошибка.

Когда оборачивать ошибку не лучшая идея

Есть случаи, когда оборачивание становится антипаттерном. Основная проблема здесь — привязка к деталям реализации.

Допустим: мы обернули ошибку, чтобы передать её выше. Теперь вызывающий код может проверить, является ли ошибка конкретной (errors.Is(err, sql.ErrNoRows)). Но если изменить внутреннюю логику, и источник ошибки изменится — вызывающий код сломается.

// Было
err := validateEmail(email)
if err != nil {
    return fmt.Errorf("invalid email: %w", err)
}

// Стало
err := validateEmailFormat(email)
if err != nil {
    return fmt.Errorf("invalid email: %w", err)
}

Если раньше проверка сводилась к вызову validateEmail, которая могла возвращать ErrInvalidDomain, а теперь используется validateEmailFormat, которая возвращает уже другой тип ошибки — вызывающая сторона будет получать другое значение.

Как избежать такой зависимости?

Если нужно, чтобы внешний код зависел от внутренней структуры ошибки — можно преобразовать ошибку, вместо того чтобы оборачивать.

Здесь ошибка преобразуется в новую строку, и исходная информация скрывается. Так мы освобождаемся от необходимости разбираться во внутренних деталях.

Неточная проверка типа ошибки в Go

При работе с ошибками в Go важно понимать, как правильно их сравнивать и проверять. Особенно это критично, если вы используете оборачивание ошибок через %w, как это стало возможно начиная с версии Go 1.13. Одна из распространённых ошибок — неправильная проверка типа обёрнутой ошибки. Разберём пример, который поможет понять, почему так происходит, и как исправить ситуацию.

Сценарий: HTTP-обработчик транзакций

У нас есть HTTP-обработчик, который получает ID транзакции и возвращает её сумму. Ошибки могут возникнуть в двух случаях:

  • ID недействителен (например, длина строки не равна 5 символам) → хотим вернуть 400 Bad Request.

  • Ошибка при обращении к БД → хотим вернуть 503 Service Unavailable.

Чтобы отличать временные ошибки от других, мы создаём собственный тип ошибки:

type transientError struct {
    msg string
}

func (e *transientError) Error() string {
    return e.msg
}

Функция getTransactionAmount может вернуть эту ошибку напрямую или обернуть другую ошибку:

func getTransactionAmount(id string) (float64, error) {
    if len(id) != 5 {
        return 0, fmt.Errorf("invalid transaction ID: %s", id)
    }

    amount, err := getTransactionAmountFromDB(id)
    if err != nil {
        return 0, &transientError{msg: "failed to fetch amount: " + err.Error()}
    }

    return amount, nil
}

Наш HTTP-обработчик:

func transactionHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    amount, err := getTransactionAmount(id)

    if err != nil {
        switch err.(type) {
        case *transientError:
            http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
        default:
            http.Error(w, "Bad request", http.StatusBadRequest)
        }
        return
    }

    fmt.Fprintf(w, "Amount: %.2f", amount)
}

На первый взгляд всё логично: если ошибка типа *transientError — возвращаем 503, иначе — 400.

Но теперь предположим, что мы немного рефакторим getTransactionAmount. Теперь transientErrorвозвращается функцией getTransactionAmountFromDB, а getTransactionAmountпросто оборачивает её:

func getTransactionAmount(id string) (float64, error) {
    if len(id) != 5 {
        return 0, fmt.Errorf("invalid transaction ID: %s", id)
    }

    amount, err := getTransactionAmountFromDB(id)
    if err != nil {
        return 0, fmt.Errorf("failed to get transaction amount: %w", err)
    }

    return amount, nil
}

func getTransactionAmountFromDB(id string) (float64, error) {
    return 0, &transientError{"database unreachable"}
}

Теперь err внутри transactionHandler — это обёрнутая ошибка, а не сама *transientError. Если мы запустим этот код, он всегда будет возвращать 400 Bad Request, даже если ошибка на самом деле является *transientError.

Почему так происходит?

Потому что оператор switch проверяет точный тип текущей ошибки, а не ищет его в цепочке обёрток.

Когда делаем так:

switch err.(type) {
case *transientError:
    // ...
}

Мы проверяем, является ли текущая ошибка именно этого типа. Но если она обёрнута, например, через %w, то err — это уже *fmt.wrapError, и switch не найдёт нужный тип.

Правильное решение: использовать errors.As

В Go 1.13 появилась стандартная функция errors.As, которая рекурсивно проверяет цепочку ошибок на наличие нужного типа:

if errors.As(err, new(*transientError)) {
    http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
} else {
    http.Error(w, "Bad request", http.StatusBadRequest)
}

Эта функция:

  1. Рекурсивно "разворачивает" ошибку.

  2. Ищет совпадение по типу.

  3. Возвращает true, если такой тип найден где-либо в цепочке.

var target *transientError
if errors.As(err, &target) {
    // Здесь можно также получить значение target, если нужно
    http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
}

Второй аргумент должен быть указателем на указатель (*T, где T — ваш тип ошибки). Это важно для корректной работы функции.

Двойная обработка ошибки в Go

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

Пример плохого кода

Представим функцию GetRoute, которая рассчитывает маршрут между двумя точками:

func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        log.Printf("invalid latitude: %f", srcLat)
        return Route{}, err
    }

    err = validateCoordinates(dstLat, dstLng)
    if err != nil {
        log.Printf("invalid longitude: %f", dstLng)
        return Route{}, err
    }

    return getRoute(srcLat, srcLng, dstLat, dstLng)
}

Вот реализация validateCoordinates:

func validateCoordinates(lat, lng float32) error {
    if lat > 90 || lat < -90 {
        return fmt.Errorf("invalid latitude: %f", lat)
    }
    if lng > 180 || lng < -180 {
        return fmt.Errorf("invalid longitude: %f", lng)
    }
    return nil
}

Если координаты неверны, например широта равна 200, в логе появятся две записи:

2021/06/01 20:35:12 invalid latitude: 200.000000
2021/06/01 20:35:12 failed to validate source coordinates:
    invalid latitude: 200.000000

Почему это плохо?

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

Правильный подход: обрабатывать ошибку один раз

Ошибка должна быть обработана только один раз. Это может быть либо возврат ошибки , либо её логирование. Ни в коем случае не оба действия вместе.

Изменим нашу функцию так, чтобы она просто возвращала ошибку, без логирования:

func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {
    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        return Route{}, fmt.Errorf("failed to validate source coordinates: %w", err)
    }

    err = validateCoordinates(dstLat, dstLng)
    if err != nil {
        return Route{}, fmt.Errorf("failed to validate target coordinates: %w", err)
    }

    return getRoute(srcLat, srcLng, dstLat, dstLng)
}

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

route, err := GetRoute(200, 45, 40, 80)
if err != nil {
    log.Printf("Error calculating route: %v", err)
    // Здесь будет полное описание ошибки
}

Это упрощает внутреннюю логику функции и даёт больше гибкости внешнему коду.

Преимущества оборачивания ошибок

В примере выше мы использовали %w для оборачивания ошибок. Что позволяет:

  1. Добавлять контекст: теперь видно, относится ли ошибка к начальной или конечной точке.

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

Пример вывода:

2021/06/01 20:35:12 Error calculating route: failed to validate source coordinates:
    invalid latitude: 200.000000

Мы получаем одну запись в логе, но с достаточным количеством информации.

Когда стоит логировать ошибку внутри функции?

Есть редкие случаи, когда логирование ошибки внутри функции может быть оправдано:

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

  2. Если ошибка критическая и требует немедленного внимания, независимо от того, кто её вызвал.

Но даже в этих случаях лучше предусмотреть опциональность логирования через интерфейс или флаг:

type Option func(*Config)

var (
    verboseLogs = false
)

func WithVerboseLogging() Option {
    return func(c *Config) {
        verboseLogs = true
    }
}

func GetRoute(srcLat, srcLng, dstLat, dstLng float32, opts ...Option) (Route, error) {
    var cfg Config
    for _, opt := range opts {
        opt(&cfg)
    }

    err := validateCoordinates(srcLat, srcLng)
    if err != nil {
        msg := fmt.Errorf("failed to validate source coordinates: %w", err)
        if verboseLogs {
            log.Println(msg)
        }
        return Route{}, msg
    }
    // ... остальной код
}

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

Как правильно игнорировать ошибки в Go

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

  1. Ошибка не влияет на дальнейшую работу программы.

  2. Невозможно или бессмысленно её обрабатывать.

  3. Произошла ситуация, которую можно просто проигнорировать без последствий.

В таких случаях важно явно показать, что вы осознанно игнорируете ошибку , а не забыли её обработать.

Плохой способ: просто вызвать функцию и ничего не делать

func f() {
    // ...
    notify() // Обработка ошибки пропущена
}

func notify() error {
    // ...
}

Такой код работает, но создаёт двусмысленность:

  • Читатель кода видит, что notify() возвращает ошибку, но не понимает, почему она не обрабатывается.

  • Возникает вопрос: это ошибка программиста? Или так и задумано?

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

Хороший способ: использовать пустой идентификатор _

Если вы уверены, что ошибку можно игнорировать, нужно сделать это явно, используя пустой идентификатор _:

_ = notify()

Этот подход говорит читателю кода:

"Я знаю, что эта функция возвращает ошибку, и я сознательно решил её проигнорировать."

С точки зрения выполнения — разницы нет. Но с точки зрения читаемости и поддерживаемости кода — это огромная разница.

Когда стоит игнорировать ошибки?

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

1. Уведомления, которые не критичны для работы приложения

// Логирование уведомления не требуется — доставка не гарантируется
_ = sendNotification("task completed")

2. Закрытие ресурса, которое уже не важно

file, _ := os.Create("temp.txt")
defer func() { _ = file.Close() }()

Если файл не удалось закрыть — это не повлияет на дальнейшее выполнение программы, и мы готовы это принять.

3. Ошибки в тестах или вспомогательных функциях

// Вспомогательная функция в тесте, где ошибка не важна
_ = os.Setenv("TEST_MODE", "true")

Комментарии к игнорируемым ошибкам

Комментарий должен объяснять почему ошибка игнорируется, а не то, что вы её игнорируете:

Так не надо:

// Игнорируем ошибку
_ = notify()

Этот комментарий дублирует сам код.

А вот так хорошо:

// Доставка сообщений производится максимум один раз.
// Ошибки доставки можно игнорировать.
_ = sendMessage(msg)

Теперь читатель знает контекст и принимает решение как свои.

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

  1. Логировать ошибку, даже если вы не собираетесь её обрабатывать.

  2. Пробросить её выше, чтобы обработала другая часть системы.

  3. Проверить и обработать, если это возможно.

Например:

if err := notify(); err != nil {
    log.Printf("notification failed: %v", err)
}

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

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

Это ошибка. Давайте разберёмся, почему и как исправить ситуацию.

Пример плохого кода: игнорирование ошибки в defer

func getBalance(db *sql.DB, clientID string) (float32, error) {
    rows, err := db.Query("SELECT balance FROM accounts WHERE id = $1", clientID)
    if err != nil {
        return 0, err
    }
    defer rows.Close()

    // ... работа с результатами ...
}

Здесь используется rows.Close() внутри defer, но не проверяется, вернула ли она ошибку .

Функция Close() интерфейса io.Closer может вернуть ошибку:

type Closer interface {
    Close() error
}

В случае с базой данных, например, rows.Close() может вернуть ошибку, если не удалось освободить соединение из пула. Лучше это не игнорировать.

Как игнорировать ошибку в defer правильно?

Если точно понятно, что ошибка Close() не важна для нашей логики, игнорировать лучше явно , чтобы читатель кода понимал, что вы это сделали намеренно:

defer func() { _ = rows.Close() }()

Так вы показываете, что вы знаете о возвращаемой ошибке, но считаете её незначительной.

Можно добавить комментарий:

// Закрытие rows обязательно, но ошибка не критична
defer func() { _ = rows.Close() }()

Лучший подход

Если ошибка всё-таки важна, тогда легируем её:

defer func() {
    if err := rows.Close(); err != nil {
        log.Printf("failed to close rows: %v", err)
    }
}()

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

Передача ошибки выше

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

func getBalance(db *sql.DB, clientID string) (balance float32, err error) {
    rows, err := db.Query("SELECT balance FROM accounts WHERE id = $1", clientID)
    if err != nil {
        return 0, err
    }

    defer func() {
        closeErr := rows.Close()
        if err == nil {
            err = closeErr
        } else if closeErr != nil {
            log.Printf("close error ignored: %v", closeErr)
        }
    }()

    // ... работа с результатами ...
}

Что здесь происходит?

  1. Если rows.Close() вернёт ошибку, а основная функция завершилась успешно (err == nil) — эта ошибка станет результатом функции.

  2. Если уже есть другая ошибка (err != nil) — новая игнорируется, но может быть записана в лог.

А что если произошло две ошибки?

Допустим: rows.Scan() вернул ошибку, и rows.Close() тоже вернул ошибку. Что возвращать?

Существует несколько стратегий:

1. Возвращать первую ошибку, вторую — логировать

defer func() {
    closeErr := rows.Close()
    if err != nil {
        if closeErr != nil {
            log.Printf("ignored close error: %v", closeErr)
        }
        return
    }
    err = closeErr
}()

2. Собрать обе ошибки в одну

Можно использовать стандартный пакет errors.Join:

defer func() {
    closeErr := rows.Close()
    if err != nil && closeErr != nil {
        err = fmt.Errorf("%w; additionally failed to close: %v", err, closeErr)
    } else if closeErr != nil {
        err = closeErr
    }
}()

Важно помнить

  1. Ошибки в defer — такие же ошибки, как и любые другие. Их нужно обрабатывать .

  2. Не игнорировать их молча — всегда делайте это явно через _ =.

  3. Если ошибка важна — логировать или возвращать .

  4. При необходимости лучше объединить ошибки или учитывать приоритеты.

Заключение

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

1. Не злоупотреблять panic

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

  • Если произошла ошибка программиста (например, невалидный HTTP-статус).

  • Если приложение не может продолжить работу из-за фатальной ошибки (например, невозможно скомпилировать регулярное выражение, необходимое для работы).

Во всех остальных случаях используйте возврат значения error.

2. Оборачивать ошибки с умом

Директива %w в fmt.Errorf позволяет добавлять контекст к ошибке и сохранять доступ к исходной ошибке. Это удобно, но есть нюанс: вызывающая сторона теперь зависит от деталей реализации.

Если вы хотите избежать такой связанности, лучше использовать %v, чтобы преобразовать , а не оборачивать ошибку.

3. Проверять типы и значения правильно

С появлением %w проверка ошибок через == или switch перестала работать корректно, если ошибка обёрнута. Вместо этого:

  • Используйте errors.Is(err, ErrFoo) для сравнения с сигнальной ошибкой.

  • Используйте errors.As(err, &target) для проверки типа.

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

4. Сигнальные ошибки против типов ошибок

  • Ожидаемые ошибки лучше представлять в виде переменных (var ErrNotFound = errors.New("not found")) — их можно сравнивать через errors.Is.

  • Непредвиденные ошибки — те, что требуют особой логики обработки — должны быть собственными типами, реализующими error.

5. Обрабатывать ошибку один раз

Ошибка должна быть обработана один раз . Это может быть:

  • Возврат ошибки выше по стеку.

  • Логирование ошибки.

  • Пользовательская обработка.

Плохая идея логировать ошибку и возвращаете её обратно. Это приводит к дублированию записей в логах и путанице при отладке.

Если вы всё же решили добавить контекст — использовать оборачивание (%w). Это позволит сохранить возможность анализа исходной ошибки.

6. Явно игнорировать ошибки

Если уверены, что ошибку можно проигнорировать — делать это явно:

_ = someFunction()

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

Не игнорировать ошибки в defer

Функции, вызываемые через defer, тоже могут возвращать ошибки. Особенно это важно, когда речь идёт о закрытии ресурсов (rows.Close(), file.Close() и т. д.).

Лучше всего:

  1. Либо логировать ошибку.

  2. Либо пробрасывать её выше (через именованный возврат).

  3. Либо явно игнорировать с помощью _.

Пример:

defer func() {
    if err := rows.Close(); err != nil {
        log.Printf("failed to close rows: %v", err)
    }
}()