Обработка ошибок в Go
- пятница, 13 июня 2025 г. в 00:00:10
Обработка ошибок — это один из самых важных аспектов написания надёжного кода. В Go к этому вопросу подошли нестандартно: вместо традиционного механизма try/catch
, как в Java или Python, ошибки просто возвращаются как значения. Изначально это может показаться странным, но на практике этот подход делает обработку ошибок более явной и честной.
В этой главе мы разберёмся с тем, когда стоит использовать panic
, какие есть распространённые ошибки при его использовании и как правильно обрабатывать исключительные ситуации в Go.
Функция 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
считается допустимым:
Если проблема возникла из-за неправильного использования API или неверной логики кода, то это классический случай для panic
. Например, в пакете net/http
метод WriteHeader
проверяет, что код ответа находится в диапазоне 100–999:
func checkWriteHeaderCode(code int) {
if code < 100 || code > 999 {
panic(fmt.Sprintf("invalid WriteHeader code %v", code))
}
}
Это ошибка программиста, дальнейшее выполнение бессмысленно и лучше вызвать панику.
Иногда приложение зависит от чего-то, без чего оно не может работать вообще. Например, регулярное выражение, которое нужно скомпилировать для валидации email. Если оно не скомпилируется, программа не сможет выполнять свою ключевую задачу.
Go предоставляет две функции: regexp.Compile
(возвращает ошибку) и regexp.MustCompile
(вызывает панику при ошибке). В случае обязательной зависимости правильнее использовать MustCompile
:
re := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
Если регулярное выражение некорректно — это ошибка разработчика, и продолжать работу бессмысленно.
Ещё один пример — регистрация драйвера базы данных в пакете 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
для обработки обычных ошибок времени выполнения, таких как:
Неверный ввод пользователя.
Сбой сети.
Ошибка чтения файла.
Проблемы с доступом к БД.
Эти ситуации — часть нормального жизненного цикла программы. Для них предусмотрены стандартные ошибки (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
, чтобы проверить, является ли эта ошибка определённым значением или типом.
Если необходимо понять где, почему и в каком контексте возникла ошибка:
err := db.QueryRow("SELECT ...")
if err != nil {
return fmt.Errorf("failed to fetch user info: %w", err)
}
Теперь на верхнем уровне можно увидеть не только техническую ошибку, но и то, где она случилась.
Иногда важно знать, какого типа ошибка произошла. Например, можно создать собственный тип ошибки:
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 важно понимать, как правильно их сравнивать и проверять. Особенно это критично, если вы используете оборачивание ошибок через %w
, как это стало возможно начиная с версии Go 1.13. Одна из распространённых ошибок — неправильная проверка типа обёрнутой ошибки. Разберём пример, который поможет понять, почему так происходит, и как исправить ситуацию.
У нас есть 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
не найдёт нужный тип.
В 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)
}
Эта функция:
Рекурсивно "разворачивает" ошибку.
Ищет совпадение по типу.
Возвращает true
, если такой тип найден где-либо в цепочке.
var target *transientError
if errors.As(err, &target) {
// Здесь можно также получить значение target, если нужно
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
}
Второй аргумент должен быть указателем на указатель (*T
, где T
— ваш тип ошибки). Это важно для корректной работы функции.
Одной из самых распространённых ошибок при работе с ошибками в 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
для оборачивания ошибок. Что позволяет:
Добавлять контекст: теперь видно, относится ли ошибка к начальной или конечной точке.
Сохранять исходную ошибку: вызывающий код может использовать errors.Is
или errors.As
, чтобы проверить тип или значение первоначальной ошибки.
Пример вывода:
2021/06/01 20:35:12 Error calculating route: failed to validate source coordinates:
invalid latitude: 200.000000
Мы получаем одну запись в логе, но с достаточным количеством информации.
Есть редкие случаи, когда логирование ошибки внутри функции может быть оправдано:
Если вы пишете библиотеку и хотите предоставить пользователю возможность включать детальные логи для отладки.
Если ошибка критическая и требует немедленного внимания, независимо от того, кто её вызвал.
Но даже в этих случаях лучше предусмотреть опциональность логирования через интерфейс или флаг:
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
}
// ... остальной код
}
Правильная обработка ошибок делает ваш код чище, предсказуемее и проще в поддержке.
Иногда возникают ситуации, когда ошибка, возвращаемая функцией, не требует никакого внимания. Это может быть связано с тем, что:
Ошибка не влияет на дальнейшую работу программы.
Невозможно или бессмысленно её обрабатывать.
Произошла ситуация, которую можно просто проигнорировать без последствий.
В таких случаях важно явно показать, что вы осознанно игнорируете ошибку , а не забыли её обработать.
func f() {
// ...
notify() // Обработка ошибки пропущена
}
func notify() error {
// ...
}
Такой код работает, но создаёт двусмысленность:
Читатель кода видит, что notify()
возвращает ошибку, но не понимает, почему она не обрабатывается.
Возникает вопрос: это ошибка программиста? Или так и задумано?
Также это плохая практика, потому что отсутствие обработки ошибки не документировано.
Если вы уверены, что ошибку можно игнорировать, нужно сделать это явно, используя пустой идентификатор _
:
_ = notify()
Этот подход говорит читателю кода:
"Я знаю, что эта функция возвращает ошибку, и я сознательно решил её проигнорировать."
С точки зрения выполнения — разницы нет. Но с точки зрения читаемости и поддерживаемости кода — это огромная разница.
Примеры, когда игнорирование ошибок допустимо:
// Логирование уведомления не требуется — доставка не гарантируется
_ = sendNotification("task completed")
file, _ := os.Create("temp.txt")
defer func() { _ = file.Close() }()
Если файл не удалось закрыть — это не повлияет на дальнейшее выполнение программы, и мы готовы это принять.
// Вспомогательная функция в тесте, где ошибка не важна
_ = os.Setenv("TEST_MODE", "true")
Комментарий должен объяснять почему ошибка игнорируется, а не то, что вы её игнорируете:
// Игнорируем ошибку
_ = notify()
Этот комментарий дублирует сам код.
// Доставка сообщений производится максимум один раз.
// Ошибки доставки можно игнорировать.
_ = sendMessage(msg)
Теперь читатель знает контекст и принимает решение как свои.
Важно то что, игнорирование ошибок должно быть исключением , а не правилом. Чаще всего лучше:
Логировать ошибку, даже если вы не собираетесь её обрабатывать.
Пробросить её выше, чтобы обработала другая часть системы.
Проверить и обработать, если это возможно.
Например:
if err := notify(); err != nil {
log.Printf("notification failed: %v", err)
}
В Go оператор 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()
может вернуть ошибку, если не удалось освободить соединение из пула. Лучше это не игнорировать.
Если точно понятно, что ошибка 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)
}
}()
// ... работа с результатами ...
}
Если rows.Close()
вернёт ошибку, а основная функция завершилась успешно (err == nil
) — эта ошибка станет результатом функции.
Если уже есть другая ошибка (err != nil
) — новая игнорируется, но может быть записана в лог.
Допустим: rows.Scan()
вернул ошибку, и rows.Close()
тоже вернул ошибку. Что возвращать?
Существует несколько стратегий:
defer func() {
closeErr := rows.Close()
if err != nil {
if closeErr != nil {
log.Printf("ignored close error: %v", closeErr)
}
return
}
err = closeErr
}()
Можно использовать стандартный пакет 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
}
}()
Ошибки в defer
— такие же ошибки, как и любые другие. Их нужно обрабатывать .
Не игнорировать их молча — всегда делайте это явно через _ =
.
Если ошибка важна — логировать или возвращать .
При необходимости лучше объединить ошибки или учитывать приоритеты.
Обработка ошибок — это не просто формальность. Это важнейшая часть создания надёжного и легко поддерживаемого кода. После прочтения этой главы у меня сложилось чёткое понимание того, как правильно работать с ошибками в Go. Основные мысли, которые я для себя выделил:
panic
и recover
могут быть полезны, но только в действительно исключительных ситуациях. Например:
Если произошла ошибка программиста (например, невалидный HTTP-статус).
Если приложение не может продолжить работу из-за фатальной ошибки (например, невозможно скомпилировать регулярное выражение, необходимое для работы).
Во всех остальных случаях используйте возврат значения error
.
Директива %w
в fmt.Errorf
позволяет добавлять контекст к ошибке и сохранять доступ к исходной ошибке. Это удобно, но есть нюанс: вызывающая сторона теперь зависит от деталей реализации.
Если вы хотите избежать такой связанности, лучше использовать %v
, чтобы преобразовать , а не оборачивать ошибку.
С появлением %w
проверка ошибок через ==
или switch
перестала работать корректно, если ошибка обёрнута. Вместо этого:
Используйте errors.Is
(err, ErrFoo)
для сравнения с сигнальной ошибкой.
Используйте errors.As
(err, &target)
для проверки типа.
Эти функции рекурсивно "разворачивают" цепочку ошибок и находят нужную вам ошибку где бы она ни была.
Ожидаемые ошибки лучше представлять в виде переменных (var ErrNotFound =
errors.New
("not found")
) — их можно сравнивать через errors.Is
.
Непредвиденные ошибки — те, что требуют особой логики обработки — должны быть собственными типами, реализующими error
.
Ошибка должна быть обработана один раз . Это может быть:
Возврат ошибки выше по стеку.
Логирование ошибки.
Пользовательская обработка.
Плохая идея логировать ошибку и возвращаете её обратно. Это приводит к дублированию записей в логах и путанице при отладке.
Если вы всё же решили добавить контекст — использовать оборачивание (%w
). Это позволит сохранить возможность анализа исходной ошибки.
Если уверены, что ошибку можно проигнорировать — делать это явно:
_ = someFunction()
Так мы показываем другим разработчикам, что это осознанное решение , а не забытая обработка. При необходимости нужно добавить комментарий, объясняющий, почему эта ошибка не важна.
Функции, вызываемые через defer
, тоже могут возвращать ошибки. Особенно это важно, когда речь идёт о закрытии ресурсов (rows.Close()
, file.Close()
и т. д.).
Лучше всего:
Либо логировать ошибку.
Либо пробрасывать её выше (через именованный возврат).
Либо явно игнорировать с помощью _
.
Пример:
defer func() {
if err := rows.Close(); err != nil {
log.Printf("failed to close rows: %v", err)
}
}()