4.1Работа с ошибками
Разберём, как добавить контекст к ошибке, не теряя возможность распознать её тип или категорию.
Ошибка внутри ошибки: обёртка, цепочка, sentinel
Ошибка родилась глубоко внизу и всплыла наверх. Как наверху понять, что именно там случилось, и что с этим делать?
1. Обёртка - это новая ошибка, внутри которой лежит старая
Драйвер базы вернул «нет строк». Отдавать это наверх как есть репозиторий не хочет: ему надо дописать «...и случилось это, когда я искал пользователя 42». Но и потерять исходную причину нельзя - выше по стеку кто-то захочет отличить «нет строк» от «база недоступна».
Выход: сделать новую ошибку, у которой есть свой текст и поле со ссылкой на старую. Это и называется обёрткой, а пишется одной строкой:
err = fmt.Errorf("get user 42: %w", err) // %w = «положи то, что было, внутрь новой ошибки»
Снаружи теперь текст про пользователя 42, а внутри осталась нетронутая ошибка драйвера. Буквально ошибка внутри ошибки. Текст никто не копирует: старая ошибка лежит целиком, со всеми своими полями и своим типом.
2. Цепочка ошибок - эта матрёшка целиком
Каждый слой приложения оборачивает то, что пришло снизу, и добавляет своё слово. Три слоя дают три вложенные друг в друга ошибки. Такую конструкцию называют цепочкой ошибок: снаружи самый верхний слой, внутри него следующий, и так до первой ошибки, которая уже ничего не оборачивает. Это корень цепочки, она же первопричина (root cause).
Чего в цепочке нет, так это стектрейса. Она хранит логический путь «сервис → репозиторий → драйвер», а не список кадров стека. В какой строке какого файла всё пошло не так, стандартная библиотека не запоминает. Если это нужно, берут стороннюю библиотеку или вписывают файл и строку в текст обёртки руками.
3. Unwrap - способ снять ровно один слой матрёшки
Чтобы по цепочке можно было ходить, каждое звено обязано уметь отдать то, что лежит у него внутри.
Договорённость простая: у звена есть метод Unwrap() error (буквально «развернуть»),
который возвращает вложенную ошибку. У корня такого метода либо нет вовсе, либо он возвращает
nil, и это сигнал «дальше пусто, цепочка кончилась».
Руками этот метод почти никогда не зовут. На нём стоят две функции, которые ходят по цепочке
сами: errors.Is («лежит ли где-то в матрёшке
вот эта конкретная ошибка?») и errors.As («есть ли в матрёшке ошибка вот такого
типа - и если да, дай её мне, у неё внутри нужные поля»).
4. Sentinel - заранее объявленная ошибка-маркер
Sentinel - одна-единственная переменная уровня пакета, объявленная раз и навсегда:
var ErrNotFound = errors.New("not found") // объявлена один раз на весь пакет
Весь смысл в том, что она одна. Пакет всегда возвращает её, а вызывающий всегда
сравнивает результат именно с ней - не с текстом сообщения и не с копией. В стандартной библиотеке
так устроены io.EOF,
sql.ErrNoRows, context.Canceled, и вся программная проверка
«а что именно случилось?» в Go построена на сравнении с такими значениями. Их и ищет
errors.Is, спускаясь по цепочке слой за слоем:
«этот слой - не он? а этот?»
«Error - это значение»: что за этим стоит
Формулировка Роба Пайка «errors are values» звучит как лозунг, но у неё есть точный смысл:
ошибка в Go ведёт себя как любое значение интерфейсного типа. Её можно
положить в переменную, передать в функцию, сохранить в поле структуры, сложить в слайс, сравнить,
обернуть и вернуть. Отдельного механизма языка под ошибки нет. Весь «фреймворк обработки ошибок»
сводится к одному интерфейсу с одним методом и пакету errors на полторы сотни строк.
// Тип целиком, объявлен в builtin, живёт в universe scope:
type error interface {
Error() string
}
Из этого вытекает всё остальное. Ошибка попадает в сигнатуру функции, а значит видна
на ревью и в godoc. Компилятор знает про множественный возврат, поэтому
(T, error) ничего не стоит. Управление никогда не «улетает» в неизвестное место:
из функции уходят только через return или панику, причём паника в идиоматичном
Go считается исключительной ситуацией, а не рабочим путём.
Почему не исключения
| Аргумент | Как это в Go | Как в языках с исключениями |
|---|---|---|
| Видимость в сигнатуре | func Do() (Result, error) - контракт написан прямо в типе |
Что бросает метод, из сигнатуры не видно (кроме checked exceptions в Java, от которых все убежали в RuntimeException) |
| Пути выхода из функции | Один: return. Читая тело, ты видишь весь поток управления |
Любая строка - потенциальный выход. Инварианты приходится защищать try/finally вокруг всего |
| Стоимость | Возврат значения. Ноль накладных расходов в happy path | Раскрутка стека, сбор стектрейса. Бросок исключения в горячем цикле - заметная просадка |
| Композиция | Ошибка - значение: её можно положить в []error, вернуть из горутины через канал, накопить, сравнить |
Исключение вне catch жить не умеет: чтобы «сохранить на потом», его надо поймать и превратить в объект |
| Что по умолчанию | По умолчанию ошибка на виду: чтобы взять результат, её придётся присвоить, хотя бы в _. Молча её выбрасывает только вызов без присваивания, это ловит errcheck |
По умолчанию исключение летит наверх. Удобно, пока не окажется, что «наверху» - это цикл воркера, который молча съедает всё |
Обратную сторону лучше назвать самому, не дожидаясь вопроса: код становится
многословным. Легендарные три строки if err != nil { return nil, err } занимают
заметную долю файла. Команда Go несколько раз пыталась это починить: предложения
check/handle (2018) и try() (2019) отклонили, а в 2025 году
официально объявили, что синтаксического сахара для ошибок не будет. Сообщество так и не
сошлось ни на одном варианте, а платить пришлось бы той самой явностью.
«Go меняет многословность на предсказуемость. В каждом if err != nil я обязан
решить: обернуть, обработать, деградировать или пробросить. В языке с
исключениями это решение можно не принимать, и тогда его принимают за тебя, обычно неудачно.»
Что физически лежит в переменной типа error
Раз error интерфейс, в переменной лежат два машинных слова: указатель на
itab и указатель на данные. Отсюда два практических
следствия, которые проверяют почти всегда.
Первое. errors.New возвращает указатель на структуру, а не значение:
// src/errors/errors.go
func New(text string) error { return &errorString{text} }
type errorString struct {
s string
}
func (e *errorString) Error() string { return e.s }
Указатель выбран сознательно. Если бы возвращалось значение, две ошибки с одинаковым текстом
сравнивались бы как равные, и errors.Is путал бы разные ошибки из разных пакетов
с совпавшим сообщением. С указателем каждый вызов errors.New даёт уникальную
идентичность - на этом и стоят sentinel-ошибки.
a := errors.New("EOF")
b := errors.New("EOF")
fmt.Println(a == b) // false: разные аллокации, разные указатели
fmt.Println(a == io.EOF) // false, хотя текст совпадает
Второе, и это классическая ловушка: интерфейс с ненулевым типом и нулевыми данными
не равен nil. Функция, которая возвращает конкретный тип ошибки вместо error,
ломает err != nil.
type MyErr struct {
Code int
}
func (e *MyErr) Error() string { return "code " + strconv.Itoa(e.Code) }
func broken() error {
var e *MyErr = nil // конкретный тип, значение nil
return e // упаковали в интерфейс: itab = (*MyErr, error), data = nil
}
func main() {
err := broken()
fmt.Println(err == nil) // false
fmt.Printf("%T %v\n", err, err) // *main.MyErr <nil>
fmt.Println(err.Error()) // паника, точный текст ниже
}
// stdout:
// false
// *main.MyErr <nil>
// stderr, exit status 2:
// panic: runtime error: invalid memory address or nil pointer dereference
// [signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x10461cd10]
//
// goroutine 1 [running]:
// main.(*MyErr).Error(...)
// main.main()
// Печатать «panic: nil pointer dereference» рантайм не умеет: это всегда
// «runtime error: invalid memory address or nil pointer dereference» плюс строка про SIGSEGV.
Никогда не объявляй переменную конкретного типа ошибки, если собираешься её вернуть как
error. Возвращай либо nil явным литералом, либо результат конструктора,
типизированный как error. И не объявляй у функции возврат
*MyErr, только error. Ловит это staticcheck (проверка SA4023):
она замечает сравнение с nil, которое из-за typed nil всегда истинно.
Четыре способа создать ошибку
| Способ | Что получаешь | Когда |
|---|---|---|
errors.New("...") | *errorString, без обёртки и без данных | Sentinel на уровне пакета. Или простая локальная ошибка без контекста |
fmt.Errorf("...: %v", x) | Тоже *errorString - форматирование есть, цепочки нет | Когда причину надо показать в тексте, но не отдавать наружу для errors.Is |
fmt.Errorf("...: %w", err) | *fmt.wrapError с методом Unwrap() error | Основной рабочий способ добавить контекст, сохранив причину |
| Собственный тип | Структура с полями + Error(), опционально Unwrap(), Is(), As() | Когда вызывающему нужны данные ошибки, а не только факт |
Есть и пятый, errors.Join(a, b) из Go 1.20, но он не создаёт ошибку, а
склеивает несколько.
Оборачивание: %w против %v
До Go 1.13 обёрток в стандартной библиотеке не было - все пользовались
github.com/pkg/errors. В 1.13 в язык (точнее, в fmt и errors)
приехали три вещи: глагол %w, соглашение о методе Unwrap() error и функции
errors.Is / errors.As / errors.Unwrap.
Внутри fmt.Errorf считает, сколько раз встретился %w, и в зависимости
от этого возвращает разные типы:
// упрощённая логика fmt.Errorf (Go 1.20+)
switch len(wrappedErrs) {
case 0:
err = errors.New(s) // обычная строка, цепочки нет
case 1:
err = &wrapError{msg: s, err: w} // есть Unwrap() error
default:
err = &wrapErrors{msg: s, errs: ws} // есть Unwrap() []error, с Go 1.20
}
Отсюда вывод, который редко проговаривают: %w меняет публичный
контракт. Обернув чужую ошибку, ты разрешил всем вызывающим писать
errors.Is(err, sql.ErrNoRows) на твой результат. Заменишь потом
database/sql на что-то другое - сломаешь их код, и компилятор про это не скажет.
%v, наоборот, ошибку инкапсулирует: текст в логе останется, а программная
зависимость не возникнет.
// %w: причина остаётся доступной
err := fmt.Errorf("get user %d: %w", id, sql.ErrNoRows)
errors.Is(err, sql.ErrNoRows) // true
errors.Unwrap(err) // sql.ErrNoRows
err.Error()
// "get user 42: sql: no rows in result set"
// %v: только текст, связи нет
err := fmt.Errorf("get user %d: %v", id, sql.ErrNoRows)
errors.Is(err, sql.ErrNoRows) // false
errors.Unwrap(err) // nil
err.Error()
// "get user 42: sql: no rows in result set"
%wставь внутри одного модуля/сервиса и там, где причину действительно нужно различать программно. И всегда дляcontext.Canceledиcontext.DeadlineExceeded: их проверяют выше по стеку.%vнужен на границе публичного API пакета, чтобы ошибка чужой библиотеки не протекла в контракт. И там, где причина остаётся деталью реализации (какой конкретно драйвер БД, какой парсер).- Оборачивая, добавляй операцию и идентификатор, а не «ошибка!»:
"repo.GetUser(id=42): %w". Обёртка нужна, чтобы по логу восстановить путь.
Сообщение ошибки в Go пишется с маленькой буквы и без точки в конце
(ST1005 в staticcheck): его почти всегда встраивают в другое сообщение,
и «Failed To Load Config.: unexpected EOF» читается плохо. Слова
failed to, error, could not тоже лишние - на выходе получится
«failed to get user: failed to query: failed to connect». Идиоматичный формат:
"операция: причина", где каждое звено добавляет ровно одно слово о себе.
Цепочка обёрток и как по ней ходят Is/As
Обёртки складываются в односвязный список. Каждое звено хранит указатель на предыдущее и умеет отдать его
методом Unwrap() error. Стектрейса, как уже говорилось, нет: цепочка
восстанавливает логический путь ошибки по слоям, а не физический путь по кадрам стека.
errors.Is
дошёл до конца и нашёл sentinel; errors.As остановился раньше - на первом звене
нужного типа, и заполнил переменную.Алгоритм errors.Is и errors.As - точно
Внутри обеих функций цикл по цепочке и две точки расширения: метод Is у самой
ошибки и ветвление на Unwrap() []error. Как Is ходит по цепочке,
спрашивают почти всегда, а про эти две точки почти всегда забывают.
// errors.Is упрощённо, но суть та же
func Is(err, target error) bool {
if target == nil { return err == target }
isComparable := reflect.TypeOf(target).Comparable()
for {
// 1. сравниваем напрямую, только если target вообще сравним
if isComparable && err == target { return true }
// 2. кастомная логика звена
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 3. спуск по цепочке
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil { return false }
case interface{ Unwrap() []error }: // Go 1.20: дерево
for _, e := range x.Unwrap() {
if Is(e, target) { return true } // обход в глубину
}
return false
default:
return false
}
}
}
errors.As устроен так же, но вместо == проверяет
присваиваемость через рефлексию и записывает найденное значение по указателю.
Требования к target жёсткие, и нарушение приводит к панике, а не к ошибке компиляции:
var qe *QueryError
errors.As(err, &qe) // ок: указатель на тип, реализующий error
errors.As(err, qe) // panic: errors: target must be a non-nil pointer
errors.As(err, &struct{}{}) // panic: errors: *target must be interface or implement error
// target может быть и указателем на интерфейс:
var te interface{ Timeout() bool }
if errors.As(err, &te) && te.Timeout() { /* сетевой таймаут где-то в глубине */ }
Go 1.26: errors.AsType - дженерик-версия As
У errors.As два врождённых неудобства, с которыми жили с 2019 года. Первое:
переменную приходится объявлять заранее, отдельной строкой, даже если она нужна ровно на один
if. Второе: тип проверяет не компилятор, а рефлексия в рантайме - передал не то,
и получил панику там, где хотелось бы ошибку сборки. В Go 1.26 появилась дженерик-форма,
которая снимает оба:
func AsType[E error](err error) (E, bool)
// Было: переменная наружу, две строки
var qe *QueryError
if errors.As(err, &qe) {
log.Error("db", "op", qe.Op, "id", qe.ID)
}
// qe продолжает жить до конца функции
// Стало (Go 1.26): всё внутри одного if
if qe, ok := errors.AsType[*QueryError](err); ok {
log.Error("db", "op", qe.Op, "id", qe.ID)
}
// qe виден только внутри блока
По сравнению со старой формой выигрыш в трёх местах:
- Типобезопасность вместо паники. Искомый тип задан параметром
[E error], и констрейнтerrorпроверяет компилятор. Обе классические паникиAs(target must be a non-nil pointerи*target must be interface or implement error) здесь невозможны: передавать нечего, значит, и ошибиться нечем. - Быстрее. Старый
Asна каждом звене цепочки лезет вreflectlite: проверить присваиваемость типов и записать значение по указателю.AsTypeобходится обычным приведением типаerr.(E)- рефлексии в его реализации нет вообще. - Область видимости. Результат живёт внутри
if, а не растекается по функции. Выигрыш тот же, что уif v, ok := m[k]; okпо сравнению с объявлением переменной строкой выше.
В остальном всё повторяет As один в один: тот же обход цепочки, тот же спуск
в Unwrap() []error обходом в глубину, та же точка расширения через метод
As(any) bool у звена. В качестве E можно подставить и интерфейс -
errors.AsType[net.Error](err) работает так же, как As с указателем
на интерфейс. Старую форму никто не удаляет и не объявляет устаревшей: она нужна коду, который
обязан собираться на Go 1.25 и старше.
- Почему в
Isесть проверкаComparable(). Если target окажется структурой со слайсом внутри, сравнениеerr == targetпаникнуло бы в рантайме («comparing uncomparable type»). Поэтому несравнимые target просто пропускают шаг 1 и полагаются на методIs. - Метод
Is(error) boolу звена позволяет строить «семейства» ошибок. Так сделанsyscall.Errno(EACCES и EPERM равныfs.ErrPermission), так делают доменные коды: одна ошибка объявляет себя равной другой по коду, а не по указателю. errors.Unwrapраскрывает ровно один уровень и не умеет работать сUnwrap() []error, так что для join-ошибок вернётnil. Чтобы обойти дерево, нужен свой рекурсивный обход илиerrors.Is/As.- Начиная с Go 1.20 у
contextестьcontext.Cause(ctx): он отдаёт настоящую причину отмены, переданную вWithCancelCause(с Go 1.21 - ещё и вWithTimeoutCause), тогда какctx.Err()вернёт обезличенныйcontext.Canceled.
// Полный рабочий пример: собственный тип + Unwrap + Is + As
type QueryError struct {
Op string
ID int64
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("%s(id=%d): %v", e.Op, e.ID, e.Err)
}
func (e *QueryError) Unwrap() error { return e.Err }
// где-то в repository:
func (r *Repo) GetUser(ctx context.Context, id int64) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx, qGetUser, id).Scan(&u.ID, &u.Name)
if err != nil {
return nil, &QueryError{Op: "select_user", ID: id, Err: err}
}
return &u, nil
}
// в service:
u, err := repo.GetUser(ctx, id)
if err != nil {
return fmt.Errorf("service.Profile: %w", err)
}
// в handler нужны оба инструмента:
switch {
case errors.Is(err, sql.ErrNoRows): // факт: сработал ли конкретный случай
http.Error(w, "not found", http.StatusNotFound)
default:
var qe *QueryError // данные: какая операция и по какому id
if errors.As(err, &qe) {
log.Error("db failed", "op", qe.Op, "id", qe.ID, "err", err)
}
http.Error(w, "internal", http.StatusInternalServerError)
}
// Что выходит на деле (запуск на go1.27, id=42,
// внутри QueryError лежит sql.ErrNoRows):
//
// fmt.Println(err) service.Profile: select_user(id=42): sql: no rows in result set
// errors.Is(err, sql.ErrNoRows) true - звено с %w и Unwrap у QueryError, цепочка насквозь
// errors.As(err, &qe) true, qe.Op = "select_user", qe.ID = 42
// fmt.Printf("%T", err) *fmt.wrapError - верхнее звено, а не *QueryError
//
// Последняя строка объясняет, зачем вообще нужны Is/As: тип верхней ошибки
// после обёртки уже не тот, и обычный type assertion промахнётся.
errors.Is отвечает на вопрос «это оно?» и сравнивает с конкретным значением.
errors.As отвечает на вопрос «есть ли в цепочке ошибка такого типа, и если да -
дай её мне», сравнивает по типу и заполняет переменную. Первое годится для sentinel-ошибок,
второе для ошибок с данными.
Sentinel errors
Sentinel, как уже говорилось, это заранее объявленная на уровне пакета ошибка, с которой сравнивают результат. Техника в Go самая старая, стандартная библиотека ею пронизана.
| Sentinel | Пакет | Что означает |
|---|---|---|
io.EOF | io | Данные кончились. Это не сбой, а нормальное завершение чтения |
io.ErrUnexpectedEOF | io | Поток кончился посреди структуры - вот это уже сбой |
sql.ErrNoRows | database/sql | QueryRow().Scan() не нашёл строк |
sql.ErrTxDone | database/sql | Транзакция уже закоммичена или откачена |
fs.ErrNotExist / os.ErrNotExist | io/fs | Файла нет. Проверять надо через errors.Is, не через == |
context.Canceled | context | Отменили сверху - клиент ушёл, вызывающий передумал |
context.DeadlineExceeded | context | Истёк дедлайн. Реализует Timeout() bool |
http.ErrServerClosed | net/http | ListenAndServe вернулся из-за Shutdown - штатное завершение |
redis.Nil, pgx.ErrNoRows | драйверы | Те же «пусто» в чужих экосистемах |
// Объявляем всегда через var: const не может быть интерфейсом
var ErrInsufficientFunds = errors.New("insufficient funds")
// Используем
if err := acc.Withdraw(100); errors.Is(err, ErrInsufficientFunds) {
return c.JSON(422, "not enough money")
}
- Жёсткая связность. Чтобы сравниться с
sql.ErrNoRows, слой транспорта обязан импортироватьdatabase/sql. Ошибка инфраструктуры протекает в домен и дальше - до HTTP-хендлера. - Нет данных. Sentinel несёт ровно один бит информации - «случилось». Ни какого поля не хватило, ни какой лимит превышен, ни сколько ждать. Всё это приходится доклеивать текстом, а текст не разбирают программно.
- Часть публичного API навсегда. Переименовать её, удалить или поменять условие, при
котором она возвращается, значит внести ломающее изменение. Компилятор про него не скажет:
errors.Isпросто начнёт возвращатьfalse, и код у пользователей тихо сломается в проде. - Плохо переживают обёртки без
%w. Одна библиотека в середине стека обернула через%v- и всеerrors.Isвыше по стеку молча перестают срабатывать.
Обычно просят описать компромисс: sentinel-ы объявляет доменный слой
(domain.ErrNotFound, domain.ErrConflict, domain.ErrForbidden).
Их немного, они стабильны, и весь верхний код зависит только от домена. Инфраструктурные ошибки
репозиторий на своей границе переводит в доменные, а не пускает наверх как есть.
Кастомные типы ошибок
Правило простое: если вызывающему нужны данные, а не факт, заводи тип. Валидация с именем поля, HTTP-ошибка со статусом и телом, ошибка ретрая с «подожди столько-то», ошибка лимита с текущим и максимальным значением - всё это sentinel-ом не выразить.
type ValidationError struct {
Field string
Value any
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %q: %s", e.Field, e.Reason)
}
// RetryableError подсказывает, когда повторить
type RetryableError struct {
After time.Duration
Err error
}
func (e *RetryableError) Error() string { return "retry after " + e.After.String() + ": " + e.Err.Error() }
func (e *RetryableError) Unwrap() error { return e.Err } // цепочка не рвётся
func (e *RetryableError) Timeout() bool { return true } // видна проверкам на Timeout()
// Метод Is: ошибки равны по коду, а не по указателю
type APIError struct {
Code int
}
func (e *APIError) Error() string { return "api error " + strconv.Itoa(e.Code) }
func (e *APIError) Is(target error) bool {
t, ok := target.(*APIError)
return ok && t.Code == e.Code
}
// теперь работает так:
errors.Is(err, &APIError{Code: 429}) // true для любой 429-ошибки
Error() stringобъявляй на указательном ресивере, если структура не совсем крошечная. Смешивать value- и pointer-ресиверы нельзя:errors.As(err, &e)с*MyErrне найдётMyErr, и наоборот. Выбери одно и держись.Unwrap() errorобязателен, если внутри есть причина. Без него цепочка обрывается на тебе, иerrors.Is(err, context.Canceled)выше по стеку перестанет работать.Is(error) boolнужен, только когда сравнивают по значению полей, а не по идентичности.- Конструктор возвращает
error, а не*MyErr, иначе получишь typed nil. - Экспортировать ли сам тип или только конструктор + предикат
IsValidation(err) bool, решает стабильность API: предикат менять проще, чем структуру.
errors.Join - агрегация (Go 1.20)
До 1.20 «несколько ошибок сразу» приходилось собирать руками: []error плюс свой тип
с Error(), который склеивает сообщения. В 1.20 добавили errors.Join
и вторую форму Unwrap (Unwrap() []error). С этого момента цепочка
ошибок формально стала деревом, а errors.Is и errors.As
обходят его в глубину.
func Join(errs ...error) error // nil-ы отбрасываются; если все nil, вернётся nil
err := errors.Join(err1, nil, err2)
// Error() = "err1\nerr2", сообщения через перевод строки
// Unwrap() []error = [err1, err2]
// errors.Unwrap(err) == nil: одноуровневый Unwrap с деревом не работает
errors.Unwrap - нет: у join-узла просто нет метода
Unwrap() error.Join реально экономит код в трёх типичных местах:
// 1) Валидация: собрать все ошибки, а не первую
func (r CreateUserReq) Validate() error {
var errs []error
if r.Name == "" {
errs = append(errs, fmt.Errorf("name: %w", ErrRequired))
}
if r.Age < 0 || r.Age > 150 {
errs = append(errs, fmt.Errorf("age %d: %w", r.Age, ErrRange))
}
if !strings.Contains(r.Email, "@") {
errs = append(errs, &ValidationError{Field: "email", Reason: "no @"})
}
return errors.Join(errs...) // если срез пуст, вернётся ровно nil
}
// 2) Закрытие нескольких ресурсов в defer: ни одну ошибку не потерять
func (s *Server) Close() error {
return errors.Join(
s.httpSrv.Shutdown(context.Background()),
s.db.Close(),
s.broker.Close(),
)
}
// 3) Классический паттерн "не потерять ошибку Close" через именованный возврат
func writeFile(name string, data []byte) (err error) {
f, err := os.Create(name)
if err != nil { return err }
defer func() { err = errors.Join(err, f.Close()) }() // раньше здесь молча терялось
_, err = f.Write(data)
return err
}
То же самое, что Join, умеет и fmt.Errorf с несколькими
%w - тоже с Go 1.20. Разница в том, что Errorf даёт человекочитаемое
сообщение с контекстом, а Join просто склеивает через \n:
err := fmt.Errorf("создание пользователя: %w, а также %w", ErrRequired, ErrRange)
errors.Is(err, ErrRequired) // true
errors.Is(err, ErrRange) // true
errors.Join()без аргументов иerrors.Join(nil, nil)возвращают ровно nil - не «пустую ошибку». Поэтому можно писатьreturn errors.Join(errs...)без предварительной проверки длины.- Возвращённое значение не равно ни одной из исходных через
==- толькоerrors.Is. - Join-ошибка не реализует
Unwrap() error, поэтому библиотеки, написанные до 1.20 и вручную вызывающиеUnwrapв цикле, её не развернут. Если пишешь обход цепочки сам, обрабатывай обе формыUnwrap. - Достать все листья дерева стандартная библиотека не умеет - своя рекурсия на 12 строк:
func Flatten(err error) []error {
switch x := err.(type) {
case interface{ Unwrap() []error }:
var out []error
for _, e := range x.Unwrap() { out = append(out, Flatten(e)...) }
return out
case interface{ Unwrap() error }:
if inner := x.Unwrap(); inner != nil { return Flatten(inner) }
}
if err == nil { return nil }
return []error{err}
}
Ошибки в слоистом приложении
У ошибки в трёхслойном сервисе есть жизненный цикл: она рождается в инфраструктуре, получает контекст при подъёме, ровно один раз логируется и превращается в код ответа на границе. За каждый шаг отвечает свой слой, и на собесе это надо уметь проговорить.
// ── domain/errors.go: словарь ошибок, от которого зависят все слои ──
package domain
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrForbidden = errors.New("forbidden")
ErrInvalidInput = errors.New("invalid input")
ErrUnavailable = errors.New("dependency unavailable")
)
// ── repository: переводим инфраструктуру в домен, наружу sql не течёт ──
func (r *UserRepo) Get(ctx context.Context, id int64) (domain.User, error) {
var u domain.User
err := r.db.QueryRowContext(ctx, qGet, id).Scan(&u.ID, &u.Email)
switch {
case errors.Is(err, sql.ErrNoRows):
return u, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
case err != nil:
return u, fmt.Errorf("userRepo.Get(%d): %w", id, err) // техническую не переводим
}
return u, nil
}
// ── service: добавляет операцию, не логирует ──
func (s *Profile) Show(ctx context.Context, id int64) (View, error) {
u, err := s.users.Get(ctx, id)
if err != nil {
return View{}, fmt.Errorf("profile.Show: %w", err)
}
return render(u), nil
}
// ── transport: только здесь логируем и маппим ──
func (h *Handler) Show(w http.ResponseWriter, r *http.Request) {
v, err := h.svc.Show(r.Context(), chi.URLParamInt64(r, "id"))
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, v)
}
func writeError(w http.ResponseWriter, r *http.Request, err error) {
status, code := httpStatus(err)
if status >= 500 {
// полный контекст - только во внутренний лог
slog.ErrorContext(r.Context(), "request failed",
"err", err, "path", r.URL.Path, "status", status)
} else {
slog.InfoContext(r.Context(), "request rejected", "code", code, "err", err)
}
// наружу - только код и trace_id, никакого err.Error()
writeJSON(w, status, map[string]any{
"code": code,
"trace_id": trace.SpanContextFromContext(r.Context()).TraceID().String(),
})
}
func httpStatus(err error) (int, string) {
switch {
case errors.Is(err, context.Canceled):
return 499, "client_closed_request" // nginx-код: клиент ушёл сам
case errors.Is(err, context.DeadlineExceeded):
return http.StatusGatewayTimeout, "timeout"
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound, "not_found"
case errors.Is(err, domain.ErrConflict):
return http.StatusConflict, "conflict"
case errors.Is(err, domain.ErrForbidden):
return http.StatusForbidden, "forbidden"
case errors.Is(err, domain.ErrInvalidInput):
return http.StatusUnprocessableEntity, "invalid_input"
case errors.Is(err, domain.ErrUnavailable):
return http.StatusServiceUnavailable, "unavailable"
default:
return http.StatusInternalServerError, "internal"
}
}
| Доменная ошибка | HTTP | gRPC code | Ретраить клиенту? |
|---|---|---|---|
ErrInvalidInput | 400 / 422 | InvalidArgument | нет - детерминированно упадёт снова |
| нет авторизации | 401 | Unauthenticated | нет (можно после обновления токена) |
ErrForbidden | 403 | PermissionDenied | нет |
ErrNotFound | 404 | NotFound | нет |
ErrConflict (версия/дубль) | 409 | AlreadyExists / Aborted | Aborted - да, после разрешения конфликта |
| rate limit | 429 | ResourceExhausted | да, по Retry-After |
| всё остальное | 500 | Internal | осторожно: операция могла пройти |
ErrUnavailable | 503 | Unavailable | да - это транзиентная ошибка по определению |
DeadlineExceeded | 504 | DeadlineExceeded | да, но не бесконечно |
context.Canceled | 499 (nginx) | Canceled | нет - клиент ушёл сам |
- Логируй или возвращай - не оба. Ошибка логируется ровно там, где перестаёт двигаться
наверх: в HTTP-хендлере, в consumer'е Kafka, в теле воркера. Везде ниже - только
%w. - Оборачивай ровно один раз на слой, добавляя имя операции и основные идентификаторы. Без этого лог выглядит как «not found» без единой подсказки, где именно.
- Наружу не должно вытекать ничего внутреннего. Ни текста SQL, ни имени таблицы, ни
хоста БД, ни стектрейса. Клиент получает код и
trace_id, а остальное узнает у саппорта по этомуtrace_id.
Антипаттерны
// плохо
_ = json.Unmarshal(b, &v) // молча
f, _ := os.Open(path) // молча
if strings.Contains(err.Error(), "no rows") { // по тексту
return nil
}
func Load() *Config { // паника вместо ошибки
b, err := os.ReadFile(p)
if err != nil { panic(err) }
...
}
if err != nil {
log.Println(err) // и логируем,
return err // и возвращаем
}
if err != nil {
return err // контекст потерян
}
// хорошо
if err := json.Unmarshal(b, &v); err != nil {
return fmt.Errorf("decode payload: %w", err)
}
if errors.Is(err, sql.ErrNoRows) { // по значению
return domain.ErrNotFound
}
func Load(p string) (*Config, error) {
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", p, err)
}
...
}
if err != nil {
return fmt.Errorf("charge order %s: %w", id, err)
}
// а если игнорируем осознанно, пишем почему:
//nolint:errcheck // best-effort: метрику потерять не страшно
_ = metrics.Push(ctx, m)
Текст ошибки не входит в контракт ни одного пакета. Он меняется в патч-релизах, зависит
от ОС (на Windows тексты syscall-ошибок ещё и переведены на язык системы), от версии
драйвера БД и от того, обернул ли кто-то ошибку по дороге.
strings.Contains(err.Error(), "connection refused") прекрасно
работает у тебя на ноутбуке и разваливается на проде под другой ОС. Правильные инструменты:
errors.Is для sentinel, errors.As для типа, а для системных ошибок
errors.Is(err, syscall.ECONNREFUSED).
Ещё три антипаттерна встречаются реже, но назвать их на собесе выигрышно:
возврат конкретного типа вместо error (typed nil, разбирали выше);
panic в библиотечном коде вместо возврата ошибки (вызывающий лишается выбора
политики); и обёртка всего подряд через %w - ошибка сторонней
библиотеки становится частью твоего публичного контракта, и без мажорной версии её оттуда
уже не убрать.
Вопросы
9
Никакого «фреймворка ошибок» в языке нет: есть интерфейс error с единственным
методом Error() string и пакет errors. Ошибку, как любое обычное
значение, можно положить в переменную, в поле структуры, в слайс, передать по каналу,
сравнить, обернуть.
Аргументы против исключений
- Контракт виден в сигнатуре.
func Do() (Result, error)сообщает, что операция может не получиться. Из сигнатуры Java-метода безthrowsэто не видно, а checked exceptions в живом коде почти все обходят. - Один путь выхода. В Go функция заканчивается
return. В языке с исключениями выйти можно из любой строки, и чтобы удержать инварианты, нуженtry/finallyвокруг всего. В Go для этого естьdefer, и он привязан к функции, а не к блоку. - Стоимость. Возврат значения ничего не стоит. Бросок исключения раскручивает стек и собирает трейс, в горячем цикле разница измерима.
- Композиция. Исключение вне
catchне живёт. Значение живёт: можно собрать[]error, вернуть ошибку из горутины через канал, отложить решение. - Умолчание. В Go ошибка по умолчанию на виду: чтобы взять результат, её придётся
присвоить, хотя бы в
_, а вызов без присваивания ловитerrcheck. В языках с исключениями по умолчанию она летит наверх, и «наверху» часто оказывается цикл, который всё съедает.
Честная критика - назови её сам
Код многословен: if err != nil { return nil, err } занимает заметную часть
файла, и легко получить лестницу из десяти проверок. Команда Go дважды пыталась это
исправить: check/handle (2018) и try() (2019) отклонили. В 2025 году
объявили, что синтаксического сахара не будет вовсе: любой вариант
прятал место, где принимается решение, а ценность как раз в нём.
«Panic в Go не аналог исключения. Это механизм для невозможных состояний, а не для управления потоком. Как только panic начинают ловить в бизнес-логике, возвращается ровно та проблема, от которой уходили: невидимый в сигнатуре путь выхода.»
error - интерфейс с одним методом
Error() string. Ошибкой является всё, что этот метод реализует.type error interface {
Error() string
}
Четыре способа
errors.New("текст")возвращает*errorString, внутри просто строка. Основное применение: sentinel-переменные уровня пакета.fmt.Errorf("...%v...", x)форматирует без цепочки. Внутри всё тот жеerrors.Newот готовой строки.fmt.Errorf("...%w...", err)оборачивает и возвращает*fmt.wrapErrorс методомUnwrap() error. С Go 1.20 можно несколько%w- тогда будетUnwrap() []error.- Свой тип: структура с полями и методом
Error(). Нужен, когда вызывающему требуются данные ошибки, а не только факт.
Почему errors.New возвращает указатель
Чтобы каждая созданная ошибка имела уникальную идентичность. Если бы возвращалось значение
структуры, две ошибки с одинаковым текстом из разных пакетов сравнивались бы как равные, и
errors.Is путал бы их.
func New(text string) error { return &errorString{text} }
a, b := errors.New("boom"), errors.New("boom")
fmt.Println(a == b) // false
Что ещё стоит упомянуть
- Sentinel объявляют через
var, а неconst: константа не может иметь интерфейсный тип. errors.Newаллоцирует. В горячем пути ошибку создают один раз при старте пакета, а не на каждый вызов.- Строку ошибки пишут с маленькой буквы и без точки - она почти всегда попадёт внутрь
другого сообщения (правило
ST1005изstaticcheck).
Интерфейс хранит пару (тип, данные). Если положить в него nil-указатель
конкретного типа, интерфейс станет ненулевым: err != nil будет
true, а вызов err.Error() закончится паникой. Поэтому функции всегда
объявляют возврат как error, а не как *MyErr.
func f() error { var e *MyErr; return e }
fmt.Println(f() == nil) // false
%w строит цепочку
и делает причину видимой для errors.Is/As; %v вклеивает только
текст и разрывает связь.
fmt.Errorf считает количество %w в формате. Ноль даёт
errors.New(готовая_строка), один - *fmt.wrapError с
Unwrap() error, два и больше (с Go 1.20) - *fmt.wrapErrors с
Unwrap() []error.
base := sql.ErrNoRows
w := fmt.Errorf("get user %d: %w", 42, base)
v := fmt.Errorf("get user %d: %v", 42, base)
w.Error() == v.Error() // true: сообщения совпадают
errors.Is(w, sql.ErrNoRows) // true
errors.Is(v, sql.ErrNoRows) // false
errors.Unwrap(w) // sql.ErrNoRows
errors.Unwrap(v) // nil
Как выбирать
%wбери по умолчанию внутри своего сервиса. Дляcontext.Canceled,context.DeadlineExceededи доменных sentinel-ов он обязателен: их проверяют выше по стеку.%vставят на публичной границе пакета, чтобы ошибка чужой библиотеки не стала частью твоего контракта. И там, где причина остаётся деталью реализации, которую ты собираешься менять.
«%w меняет публичный API. Обернув sql.ErrNoRows,
я разрешил всем вызывающим делать errors.Is(err, sql.ErrNoRows) на мой
результат. Если завтра я перееду с database/sql на pgx, их код
сломается, и компилятор об этом не предупредит - Is просто начнёт возвращать
false. Поэтому наружу я обычно отдаю свою доменную ошибку, а чужую оборачиваю
через %v или перевожу.»
Как оформлять текст обёртки
Формат "операция: причина", каждый слой добавляет одно короткое слово о себе
плюс идентификаторы. Слова failed to, error while не нужны:
после трёх обёрток получится «failed to X: failed to Y: failed to Z». Хороший результат
выглядит так: "profile.Show: userRepo.Get(42): dial tcp 10.0.0.5:5432: i/o timeout".
По одной строке видно и путь, и причину.
- «Стектрейс же появляется?» - нет. Стандартная библиотека кадры стека не
сохраняет. Цепочка
%wописывает логический путь по слоям, а не физический по стеку. Трейсы даётpkg/errors(устарел) или свой тип сruntime.Callers. %wс не-error аргументом даёт%!w(...)в тексте - ошибку формата, а не панику.go vetэто ловит.
Is - «это оно?», сравнение с конкретным значением.
As - «есть ли в цепочке ошибка такого типа, дай её мне», сравнение по типу с
записью в переменную. Оба обходят одну и ту же цепочку Unwrap.Алгоритм Is
- Если
targetсравним, проверитьerr == target. - Если у
errесть методIs(error) bool, спросить его. - Спуститься:
Unwrap() error- на следующее звено;Unwrap() []error- рекурсивно во все ветки (обход в глубину). - Нет
Unwrapили он вернулnil-false.
Алгоритм As
Обход тот же, только на каждом звене рефлексия проверяет, присваивается ли значение
звена типу, на который указывает target. Если совпало, значение записывается,
и функция возвращает true. Расширять поведение можно методом As(any) bool.
type NotFoundError struct {
Kind string
ID int64
}
func (e *NotFoundError) Error() string { return e.Kind + " not found" }
var ErrNoAccess = errors.New("no access")
func find(id int64) error {
inner := &NotFoundError{Kind: "user", ID: id}
return fmt.Errorf("service.Load: %w", fmt.Errorf("repo.Get: %w", inner))
}
err := find(42)
fmt.Println(err) // service.Load: repo.Get: user not found
// Is проверяет факт (здесь false: sentinel в цепочке нет)
fmt.Println(errors.Is(err, ErrNoAccess)) // false
// As достаёт данные с любой глубины
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println(nf.Kind, nf.ID) // user 42
}
// Is умеет и с деревом Join
joined := errors.Join(ErrNoAccess, err)
fmt.Println(errors.Is(joined, ErrNoAccess)) // true
fmt.Println(joined) // две строки: Join склеивает через \n
// no access
// service.Load: repo.Get: user not found
// Unwrap снимает ровно одно звено за раз:
fmt.Println(errors.Unwrap(err)) // repo.Get: user not found
fmt.Println(errors.Unwrap(errors.Unwrap(err))) // user not found
Требования к target у As - это паника, а не ошибка компиляции
var nf *NotFoundError
errors.As(err, &nf) // ок
// Точные тексты паник (go1.27), у обеих префикс "errors: ":
errors.As(err, nf) // если nf == nil:
// panic: errors: target must be a non-nil pointer
// если nf уже заполнен, паника другая, потому что
// *target это NotFoundError, а error реализует *NotFoundError:
// panic: errors: *target must be interface or implement error
errors.As(err, &struct{}{}) // panic: errors: *target must be interface or implement error
// target может указывать на интерфейс, так ловят «любой таймаут»:
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() { /* ретраим */ }
Go 1.26: errors.AsType - то же самое, но дженериком
Сильный ход на собесе: сказать, что с Go 1.26 у As есть типобезопасная
форма, и объяснить, чем она удобнее.
func AsType[E error](err error) (E, bool)
// вместо двух строк с переменной наружу:
if nf, ok := errors.AsType[*NotFoundError](err); ok {
fmt.Println(nf.Kind, nf.ID) // user 42
}
// с интерфейсом работает так же:
if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() { /* ретраим */ }
- Не паникует. Тип задан параметром, констрейнт
errorпроверяет компилятор, поэтому обеих паникAs(«target must be a non-nil pointer», «*target must implement error») здесь просто не может быть. - Быстрее. Внутри обычное
err.(E), безreflectlite, которыйAsдёргает на каждом шаге цепочки. - Не нужна переменная заранее - результат живёт внутри
if. - Обход цепочки, дерево
Unwrap() []errorи методAs(any) boolу звена работают ровно как у старогоAs.
Старый errors.As остаётся и устаревшим не объявлен: он нужен коду, который
собирается на Go 1.25 и старше.
- В
Isесть проверкаreflect.TypeOf(target).Comparable(): иначе сравнение==с несравнимым типом (структура со слайсом) паникнуло бы. - Метод
Is(error) boolу своего типа позволяет сделать «семейство»:errors.Is(err, &APIError{Code: 429})сработает для любой ошибки с этим кодом, а не только для того же указателя. errors.Unwrapделает один шаг и понимает только формуUnwrap() error. Для join-ошибки он вернётnil.- Обе функции появились в Go 1.13, поддержка
Unwrap() []error- в 1.20, дженерик-формаerrors.AsType[E]- в 1.26.
Нужен только ответ «да/нет» и есть заранее известное значение - Is.
Нужны поля (какое поле не прошло, какой код, сколько ждать) - As.
Никогда не == и никогда не по тексту.
errors.Is. Дёшево и понятно, но несёт ровно один бит информации
и намертво связывает слои.var ErrNotFound = errors.New("not found") // var, не const
Примеры из стандартной библиотеки
io.EOF: данные кончились. И это не сбой, а нормальное завершение. Отсюда идиомаif err == io.EOF { break }в цикле чтения. А вотio.ErrUnexpectedEOFуже сбой: поток оборвался посреди структуры.sql.ErrNoRowsозначает, чтоQueryRow().Scan()ничего не нашёл.sql.ErrTxDoneвозвращается, когда транзакция уже завершена.fs.ErrNotExist/os.ErrNotExist,fs.ErrPermissionпроверяют только черезerrors.Is, потому что реально возвращается*fs.PathError, обёрнутый вокругsyscall.ENOENT.context.Canceledиcontext.DeadlineExceeded.http.ErrServerClosed-ListenAndServeзавершился штатно из-заShutdown; в логе это не ошибка.
Минусы
- Связность. Чтобы сравнить с
sql.ErrNoRows, HTTP-хендлер обязан импортироватьdatabase/sql. Инфраструктура протекает через все слои. - Нет данных. Только факт. «Какого поля не хватило», «какой лимит», «через сколько повторить» - не выразить.
- Публичный API навсегда. Смена условия возврата ломает пользователей молча:
компилятор ничего не скажет,
errors.Isпросто станетfalse. - Хрупкость к обёрткам. Одна библиотека по дороге обернула через
%v- все проверки выше перестали работать. - Глобальное изменяемое состояние.
io.EOFобъявлен черезvar, и технически его можно переприсвоить из чужого пакета. В проде не случается, но как аргумент против звучит.
Заводят небольшой стабильный набор доменных sentinel-ов (domain.ErrNotFound,
ErrConflict, ErrForbidden) и на границе репозитория переводят в них
инфраструктурные ошибки. Где нужны данные, берут кастомный тип и errors.As.
Иногда экспортируют предикат func IsNotFound(err error) bool: его
реализацию можно менять, не ломая пользователей.
«Почему errors.Is(err, os.ErrNotExist), а не err == os.ErrNotExist?»
Потому что os.Open возвращает *fs.PathError, внутри которого
лежит syscall.ENOENT, а ENOENT своим методом
Is объявляет себя равным fs.ErrNotExist. Прямое
сравнение всегда даст false.
Unwrap() error подключает твой тип к цепочке, чтобы errors.Is/As
видели то, что лежит под ним.Когда заводить тип
- Валидация: нужно имя поля и причина, иногда список.
- Ошибки внешнего API: код, тело ответа, заголовок
Retry-After. - Ошибки БД: операция, таблица, идентификатор - для лога и метрик.
- Транзиентность: флаг «можно ретраить» и через сколько.
- Когда набор ошибок большой и sentinel-ов стало бы двадцать штук.
type ValidationError struct {
Field string
Reason string
Err error // причина, если есть
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %q: %s", e.Field, e.Reason)
}
func (e *ValidationError) Unwrap() error { return e.Err }
// использование
var ve *ValidationError
if errors.As(err, &ve) {
w.WriteHeader(422)
json.NewEncoder(w).Encode(map[string]string{"field": ve.Field, "reason": ve.Reason})
}
Зачем именно Unwrap
Без него цепочка кончается на твоём типе. Всё, что было под ним, становится невидимым:
errors.Is(err, context.DeadlineExceeded) выше по стеку вернёт
false, ретрай-логика перестанет понимать, что это был таймаут, а маппинг на
HTTP отдаст 500 вместо 504. Unwrap буквально говорит: «пропусти проверку дальше,
вглубь».
- Смешанные ресиверы. Если
Error()объявлен на*MyErr, тоMyErrне реализуетerror, иerrors.As(err, &e)сvar e MyErrупадёт с паникой (go vetпоймает это заранее). Выбирай один вариант (обычно указательный) и создавай ошибку только через&MyErr{...}. - Typed nil. Конструктор должен возвращать
error, а не*MyErr, иначеerr != nilстанет истиной при пустой ошибке. Error(), который паникует на нулевых полях. Метод вызывается из логгера и изfmtв самый неудачный момент - он должен быть безопасен всегда.
Is(target error) boolзадаёт своё правило сравнения. Так реализуют «равенство по коду», а не по указателю.As(target any) boolделает то же для приведения. Нужен редко: например, когда ошибка хочет притворяться типом из другого пакета.Unwrap() []errorпригодится, если твой тип агрегирует несколько причин (Go 1.20+).Timeout() bool/Temporary() boolвходят в интерфейсnet.Error. Реализовав оба, ты попадаешь под чужие проверкиerrors.As(err, &netErr), а одинTimeout()- под проверки черезinterface{ Timeout() bool }.Temporary()при этом объявлен deprecated - на него полагаться не стоит.
errors.Join(errs...) склеивает несколько ошибок в
одну, у которой есть Unwrap() []error. Цепочка превращается в дерево, а
Is/As обходят его в глубину.func Join(errs ...error) error
// nil-аргументы отбрасываются
// если ненулевых не осталось, возвращается ровно nil
// Error() склеивает сообщения через "\n"
Где применяется
- Валидация целиком. Пользователь получает список всех проблем формы, а не первую.
- Закрытие ресурсов.
errors.Join(srv.Shutdown(ctx), db.Close(), mq.Close())закроет всё и не потеряет ни одной ошибки. - Не потерять ошибку
Closeвdeferчерез именованный возврат:defer func(){ err = errors.Join(err, f.Close()) }(). Раньше эту ошибку почти всегда молча теряли. - Параллельные задачи, когда важны все сбои, а не первый (в отличие от
errgroup, который хранит только первую ошибку).
func (r Req) Validate() error {
var errs []error
if r.Name == "" { errs = append(errs, fmt.Errorf("name: %w", ErrRequired)) }
if r.Age < 0 { errs = append(errs, fmt.Errorf("age: %w", ErrRange)) }
return errors.Join(errs...) // пусто → nil, проверять длину не нужно
}
err := Req{}.Validate()
errors.Is(err, ErrRequired) // true
errors.Is(err, ErrRange) // true
errors.Unwrap(err) // nil: одноуровневый Unwrap с деревом не работает
Тот же эффект через fmt.Errorf
С Go 1.20 в одном fmt.Errorf можно использовать несколько %w.
Разница: Errorf даёт осмысленное сообщение с контекстом, Join -
механическую склейку через перевод строки.
err := fmt.Errorf("create user: %w и %w", ErrRequired, ErrRange)
errors.Unwrap(joined)вернётnil: у join-ошибки нетUnwrap() error, только слайс-форма.- Join-ошибка не равна ни одной из исходных через
==. - Код, написанный до 1.20 и разворачивающий цепочку вручную циклом
errors.Unwrap, дерево не обойдёт. Свой обход должен уметь обе формы. - Стандартной функции «достать все листья» нет - пишется рекурсией на десяток строк.
go.uber.org/multierr и hashicorp/go-multierror. Оба живы и
встречаются в проде, но для нового кода лучше стандартный Join:
его понимают errors.Is/As без адаптеров.
%w на каждом слое, логируется
ровно один раз там, где перестаёт подниматься, и там же превращается в код ответа.Кто что делает
- Repository. Переводит
sql.ErrNoRowsи коды драйвера в доменные ошибки. Технические ошибки оборачивает с именем операции и идентификатором. Не логирует. - Service / usecase. Оборачивает с именем сценария, принимает бизнес-решения (можно ли деградировать, нужен ли fallback). Не логирует.
- Transport (HTTP/gRPC/consumer). Единственная точка, где ошибка умирает: пишут
структурированный лог, увеличивают метрику, выбирают статус и собирают тело
ответа. Наружу уходят код и
trace_id, никаких внутренних деталей.
Почему «логируй или возвращай - не оба»
Если каждый слой пишет в лог и пробрасывает ошибку дальше, один сбой БД даёт четыре записи об одном событии. Алерт «ошибок больше N в минуту» срабатывает в четыре раза раньше, дедупликация по сообщению не работает, а при разборе инцидента непонятно, сколько на самом деле было отказов. Вдобавок дублируются PII и растёт объём логов. Исключение из правила ровно одно: если ты ошибку обработал (сходил в кэш, вернул дефолт) и не возвращаешь наверх - вот тогда логируй, потому что иначе о ней никто не узнает.
// repository
if errors.Is(err, sql.ErrNoRows) {
return u, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
}
// service
return View{}, fmt.Errorf("profile.Show: %w", err)
// transport
slog.ErrorContext(ctx, "request failed", "err", err, "path", r.URL.Path)
writeJSON(w, httpStatus(err), errBody{Code: code, TraceID: traceID})
Маппинг
Хватает одного switch с errors.Is в транспортном слое, и проверки
статусов не расползаются по коду. Основные соответствия: ErrNotFound → 404 /
NotFound; ErrConflict → 409 / AlreadyExists;
ErrInvalidInput → 422 / InvalidArgument;
ErrForbidden → 403 / PermissionDenied; rate limit → 429 /
ResourceExhausted; ErrUnavailable → 503 /
Unavailable; DeadlineExceeded → 504; всё непонятное → 500 /
Internal.
В gRPC возвращают status.Error(codes.NotFound, "..."), а детали
кладут в status.WithDetails (типы из google.rpc.ErrorInfo,
BadRequest). В HTTP-мире тем же занимается RFC 9457 (раньше RFC 7807) application/problem+json.
- «Отдельно обрабатываю
context.Canceled: это не ошибка сервиса, клиент ушёл сам. Логирую на уровнеinfoи не считаю в SLI, иначе при каждом всплеске отмен горит алерт.» - «В логе всегда
trace_idиз контекста - по нему саппорт находит запрос за секунды.» - «500-е логирую с полной ошибкой, 4xx - на info: 4xx это норма работы API, а не сбой.»
- «Ошибку в ответ клиенту не пишу никогда: там бывают имена таблиц, хосты и куски запроса.»
1. _ = err и голый f, _ := os.Open(...)
Самая дорогая строка в кодовой базе: она превращает отказ в тихую порчу данных.
json.Unmarshal вернул ошибку - структура осталась нулевой, и дальше по коду
«пользователь с id 0». Классика: не проверили ошибку у tx.Commit(), и
транзакция не применилась, а сервис ответил 200.
Ловится линтером errcheck (входит в golangci-lint). Если
игнорировать действительно надо, пиши явно и с комментарием, почему:
//nolint:errcheck // best-effort метрика. Молчаливый
defer f.Close() для файла, открытого на чтение, допустим; для файла
на запись - уже нет, там теряется ошибка сброса буфера.
2. Сравнение по тексту
// никогда
if strings.Contains(err.Error(), "no rows") { ... }
if err.Error() == "connection refused" { ... }
Текст ошибки не входит в контракт ни одного пакета: он меняется в патч-релизах,
зависит от версии драйвера, от локали ОС для syscall-ошибок и от того, обернул
ли кто-то ошибку по дороге. Такой код работает у тебя и разваливается на проде.
Правильно: errors.Is для sentinel, errors.As для типа,
errors.Is(err, syscall.ECONNREFUSED) для системных.
3. Паника вместо ошибки
Библиотека, которая паникует на невалидном вводе, отнимает у вызывающего право выбрать политику. Ошибка чтения конфига, недоступная БД, битый JSON от клиента относятся к ожидаемым ситуациям, у них есть обработка. Panic оставляют для нарушенных инвариантов программы (подробнее в главе 4.2).
Ещё пять, которые стоит назвать
- Логировать и возвращать одну и ту же ошибку, плодя дубли в логах и в алертах.
return errбез контекста на каждом слое: в логе окажется голое «not found» без единой подсказки, где именно.- Возврат конкретного типа вместо
errorдаёт typed nil, иerr != nilстановится истиной при отсутствии ошибки. - Оборачивать всё через
%wбез разбора. Так ошибка чужой библиотеки становится частью твоего публичного контракта. - Отдавать
err.Error()клиенту - наружу утекут имена таблиц, хосты, куски SQL и внутренняя структура сервиса.
«У нас в CI включены errcheck, govet, staticcheck,
wrapcheck и err113 - последний как раз ругается на динамические
ошибки и сравнение ошибок через ==. Плюс правило на ревью: обёртка
обязана добавлять имя операции, иначе она бесполезна.»
Что стоит запомнить
- %w сохраняет причину в error chain.
- errors.Is проверяет категорию, errors.As извлекает типизированную ошибку.
- Публичная цепочка ошибок задаёт контракт библиотеки.
Частые ошибки
- Не сравнивай текст Error().
- %v не поддерживает errors.Is/As.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи цепочку ошибки
Каталог создан программой и в нём нет файла missing. Что напечатает проверка после оборачивания реальной ошибки os.Open?
dir, err := os.MkdirTemp("", "errors-")
if err != nil { panic(err) }
defer os.RemoveAll(dir)
_, err = os.Open(filepath.Join(dir, "missing"))
err = fmt.Errorf("read config: %w", err)
fmt.Println(errors.Is(err, fs.ErrNotExist))
Ответ и разбор
Печатается true: os.Open вернул ошибку отсутствующего файла, %w сохранил её в цепочке, а errors.Is распознал fs.ErrNotExist.
Код-ревью
2. Найди дефект и исправь
Вызывающий код проверяет errors.Is(err, fs.ErrNotExist), но эта проверка не срабатывает после Load. Исправь оборачивание причины.
func load() error {
if err := read(); err != nil { return fmt.Errorf("read config: %v", err) }
return nil
}
Ответ и разбор
Замени %v на %w: return fmt.Errorf("read config: %w", err). %v добавляет только текст, а %w сохраняет цепочку для errors.Is и errors.As. Оборачивай только если причина должна быть частью публичного контракта.
Применение
3. Разбери сценарий
В сервисе есть две ошибки: файл конфигурации отсутствует и файл есть, но содержимое невалидно. Как вернуть вызывающему коду контекст, сохранив различение этих случаев?
Ответ и разбор
Для отсутствующего файла сохрани конкретную ошибку ОС: после os.Open(path) верни fmt.Errorf("load config %q: %w", path, err). Тогда вызывающий различит случай через errors.Is(err, fs.ErrNotExist) и сможет получить *os.PathError через errors.As. Для невалидного содержимого верни отдельный sentinel или типизированную validation error, обернув именно её. Не создавай synthetic fs.ErrNotExist вместо исходной ошибки.
Применение
4. Выбери границу обработки
Публичная библиотека оборачивает внутреннюю ошибку базы %w, но не хочет, чтобы клиенты зависели от выбранной БД. Как оформить границу?
Ответ и разбор
Верни стабильный sentinel ErrUnavailable, а причину драйвера запиши во внутренний лог: logger.ErrorContext(ctx, "load account", "err", err); return fmt.Errorf("load account: %w", ErrUnavailable). Не включай исходную driver error через %w, иначе клиенты смогут зависеть от конкретного драйвера через errors.Is/As. Если нужна диагностика без логгера в этой точке, передай причину во внутренний слой, который владеет логированием.