Функция, которая может завершиться неудачей, возвращает error последним результатом, а вызывающий сразу проверяет if err != nil; error представляет собой встроенный интерфейс с единственным методом Error() string.
Ошибки являются значениями: их хранят, сравнивают, оборачивают и возвращают, как любое другое значение; ничто не перепрыгивает через ваши кадры стека.
Явный поток управления: каждый вызов, который может упасть, виден в месте вызова. Именно поэтому Go выбрал этот путь, а не исключения: у функции нет скрытых точек выхода.
Обработайте или верните с контекстом: return fmt.Errorf("load user %d: %w", id, err). Если сделать и то и другое (залогировать и вернуть), один сбой попадёт в лог дважды.
**panic не служит механизмом исключений**: она для багов и невозможных состояний, а не для ожидаемых сбоев вроде отсутствующего файла или плохого ввода.
GoJunior
Ошибки и паники
error как значение, обёртка через %w, errors.Is/As, panic и recover
Закрыто вопросов
0 / 21
- 0Повторить
- 3Часто спрашивают
Вопросы
Sentinel представляет собой ошибку-переменную уровня пакета вроде io.EOF или sql.ErrNoRows, с которой сравнивают вызывающие. Два вызова errors.New никогда не равны: каждый возвращает новый указатель, а ошибки сравниваются по идентичности, а не по тексту.
Объявите её один раз (var ErrUserNotFound = errors.New("user not found")) и возвращайте эту переменную или оборачивайте её.
Сравнивайте через errors.Is(err, ErrUserNotFound), а не ==, чтобы проверка работала и после того, как какой-то слой добавит контекст через %w.
Никогда не сравнивайте по err.Error(): любой слой вправе переписать эту строку.
io.EOF и есть та ошибка, которую обычно сравнивают через ==: по контракту ридеры обязаны возвращать её необёрнутой. errors.Is для неё тоже работает.
Текст ошибки пишут как фрагмент, а не предложение: с маленькой буквы, без точки в конце, без failed to. Контекст добавляют через fmt.Errorf("operation: %w", err), и наверху стека всё читается одной строкой.
С маленькой буквы и без точки, потому что текст печатается после другого текста: run: load config: open /etc/app.json: no such file or directory. Исключение составляет строка, которая начинается с экспортированного имени или аббревиатуры.
Уберите failed to и error:, потому что тип error уже означает неудачу, а повтор на каждом слое даёт failed to sync: failed to load: ....
Каждый слой добавляет свою операцию и те операнды, которые знает только он. Не повторяйте то, что уже есть в причине: ошибка os.Open уже содержит путь.
Оборачивайте через %w. Склейка с err.Error() сохраняет текст, но выбрасывает саму причину, и errors.Is выше вернёт false.
Любой тип с методом Error() string является ошибкой. Структуру заводят, когда вызывающему нужны данные о сбое (имя поля, код статуса, задержка перед повтором), а не только ответ «да/нет».
**Sentinel из errors.New**: вызывающему достаточно знать, какой именно сбой произошёл.
**fmt.Errorf: вы лишь добавляете контекст для человека, который читает лог.
Тип-структура**: вызывающий должен прочитать детали; он достаёт значение из обёрнутой цепочки через errors.As.
Объявляйте Error на указательном получателе и возвращайте &ValidationError{...}: так делает стандартная библиотека с *fs.PathError и *json.SyntaxError.
Возвращайте её через результат типа error, а не *ValidationError, иначе нулевой указатель превратится в ненулевую ошибку.
panic прерывает обычное выполнение и раскручивает текущую горутину, по пути выполняя её отложенные вызовы. recover останавливает раскрутку, но только если его вызывает напрямую отложенная функция паникующей горутины; в любом другом месте он возвращает nil.
«Напрямую» понимается буквально: defer logPanic() работает, если logPanic вызывает recover, а defer func() { logPanic() }() не работает, потому что там recover на один вызов глубже.
После recover функция, отложившая его, нормально возвращается к вызывающему. Чтобы отдать панику наружу ошибкой, сделайте результат именованным и присвойте его внутри отложенного замыкания.
recover возвращает any (то, что передали в panic). Сбои рантайма (запись в nil-карту, выход за границы, разыменование nil) несут runtime.Error: это баг, который надо чинить, а не ситуация, которую надо обрабатывать.
С Go 1.21 panic(nil) паникует значением *runtime.PanicNilError, поэтому nil из recover надёжно означает «паники нет».
Снимайте debug.Stack() внутри отложенной функции: после её возврата кадров уже нет.
panic раскручивает стек горутины и выполняет её отложенные вызовы, поэтому recover может её остановить; os.Exit завершает процесс на месте и не выполняет ни одного defer; log.Fatal сводится к строке лога плюс os.Exit(1).
Невосстановленная паника выполняет defer паникующей горутины, печатает значение паники и стек и завершает процесс с кодом 2.
При os.Exit пропускаются все отложенные вызовы: Flush, Close, tx.Rollback, удаление временного файла. Буферизованный вывод просто теряется.
log.Panic: вариант, который вместо выхода паникует, поэтому defer выполняются.
Держите выход в main: вынесите работу в run() error, а main пусть печатает ошибку и вызывает os.Exit(1); к этому моменту все defer внутри run уже отработали.
Библиотечный код возвращает ошибку; решение завершать процесс принимает main.
Панику оставляют для ошибок программиста, то есть состояний, означающих, что неверна сама программа; для всего, что может сломаться во время работы, возвращают error. Хелпер MustX служит санкционированным исключением: паникующий двойник X для значений, зашитых в исходник.
Рантайм и стандартная библиотека паникуют на багах: запись в nil-карту, выход за границы, закрытие закрытого канала.
regexp.MustCompile и template.Must предназначены для var уровня пакета: плохой литерал падает при старте, где вызывающему всё равно нечего делать. Никогда не зовите MustX на пользовательском вводе.
Паника не должна выходить из пакета через экспортированный API. Если внутри вы паникуете ради управления потоком (раскрутка глубокого рекурсивного парсера), делайте recover в экспортированной функции и превращайте панику в ошибку; восстанавливайте только свой тип паники, остальное перебрасывайте.
Никогда не паникуйте ради того, чтобы не писать if err != nil.
%w оборачивает: новая ошибка хранит исходное значение и получает метод Unwrap, поэтому errors.Is и errors.As по-прежнему находят причину. %v лишь копирует текст, и цепочка на этом обрывается.
Оба глагола печатают ровно одно и то же сообщение, так что логи и тесты, сравнивающие строки, разницы не видят. Замена одного на другой даёт тихую поломку, которую ловит только тест на errors.Is.
Оборачивайте через %w, когда вызывающим можно матчиться на причину: context.DeadlineExceeded, fs.ErrNotExist, ваши собственные sentinel'ы.
%v ставьте сознательно на границе, где причина относится к деталям реализации. Обернув ошибку драйвера БД, вы делаете этот драйвер частью своего API; вместо этого переведите её в свой sentinel.
С Go 1.20 в одном fmt.Errorf может быть несколько %w; у результата есть Unwrap() []error, и каждый операнд остаётся доступным для матчинга.
Она напечатает false, true, false, затем false true. == видит только внешнюю обёртку, errors.Is идёт по Unwrap звено за звеном, а обход заканчивается на первой ошибке без Unwrap (здесь это ошибка, собранная через %v).
Строка вывода 1: err имеет тип *fmt.wrapError, это другое значение, не ErrNotFound.
Строка вывода 2: на каждом уровне errors.Is проверяет err == target, затем собственный метод ошибки Is(error) bool, если он есть, затем вызывает Unwrap и спускается на звено ниже. Для Unwrap() []error (errors.Join, несколько %w) он обходит все ветки в глубину.
Строка вывода 3: %v скопировал только текст, у cut нет Unwrap, и обход сразу останавливается. Пересборка ошибки из строки (конкатенация, граница RPC) рвёт цепочку точно так же.
Строка вывода 4: os.IsNotExist старше оборачивания ошибок и не заглядывает внутрь обёрток fmt. Пишите errors.Is(err, fs.ErrNotExist).
Берите errors.Is для sentinel'ов, даже если сегодня ошибка не обёрнута.
Используйте errors.As(err, &target): он обходит ту же цепочку, что и errors.Is, и присваивает первую ошибку нужного типа. Утверждение err.(*ValidationError) смотрит только на самое внешнее значение, а после %w это обёртка.
Целью служит ненулевой указатель на тип ошибки или на интерфейс. Для типа с указательным получателем это var ve *ValidationError; errors.As(err, &ve), то есть вы передаёте **ValidationError.
Неверная цель приводит к панике, а не к false: errors.As(err, ve) даёт errors: target must be a non-nil pointer, а значение ValidationError, у которого Error на указательном получателе, даёт *target must be interface or implement error.
Цель-интерфейс спрашивает о поведении: var ne net.Error; errors.As(err, &ne) && ne.Timeout().
В Go 1.26 появился дженерик errors.AsType[*ValidationError](err): он возвращает значение и ok без отдельной переменной-цели.
errors.Is спрашивает «это та самая ошибка?», а errors.As спрашивает «есть ли здесь ошибка такого типа, и дай её мне».
Дайте типу метод Unwrap() error, который возвращает причину, и тогда errors.Is и errors.As пройдут сквозь него. Добавьте метод Is(target error) bool, когда значение вашего типа должно совпадать с sentinel'ом, которому оно не равно.
**Unwrap() error**: тип, хранящий причину, отдаёт её наружу, как это делают *fs.PathError и *url.Error, и вызывающие по-прежнему находят под ним fs.ErrNotExist.
**Unwrap() []error**: для типа, который хранит несколько ошибок; Is и As обходят каждую ветку. errors.Unwrap эту форму не вызывает и возвращает nil.
**Is(error) bool**: своё правило совпадения: например, любой *StatusError с кодом 429 считается ErrRateLimited. Сравнивайте только с целями, которыми владеете, и не разворачивайте цепочку внутри, ведь обход делает это сам.
**As(any) bool: нужен редко; позволяет типу выдать себя за другой тип цели.
Получатели**: если Error на указательном получателе, ошибкой является только *NotFound, поэтому целью должен быть *NotFound (var nf *NotFound; errors.As(err, &nf)); цель NotFound вызовет панику. Если Error на получателе-значении, ошибками являются обе формы, и As совпадает только с тем типом, который вы запросили.
Используйте errors.Join (Go 1.20+) или несколько глаголов %w в одном fmt.Errorf. Результат отдаёт Unwrap() []error, а errors.Is и errors.As обходят каждую ветку, так что любая из объединённых ошибок остаётся доступной для матчинга.
errors.Join отбрасывает nil-аргументы и возвращает nil, если nil все, поэтому можно добавлять без условий и закончить return errors.Join(errs...).
В его сообщении каждая ошибка стоит на отдельной строке. Если логи построчные, форматируйте список сами.
errors.Unwrap(joined) вернёт nil, потому что у объединённой ошибки Unwrap() []error, а не Unwrap() error. Чтобы прочитать части, приведите к interface{ Unwrap() []error }.
fmt.Errorf("import row %d: %w, %w", n, errA, errB) строит такую же мульти-ошибку, но позволяет написать фразу самому.
Типичные случаи: валидатор, который сообщает обо всех плохих полях сразу, и отложенный Close, чья ошибка не должна скрыть основную.
Четыре вещи: она и логирует ошибку, и возвращает её, отбрасывает ошибку json.Marshal, теряет причину ошибки записи, склеивая текст, и игнорирует ошибку Close у файла, в который писала.
Залогировать и вернуть: вызывающий залогирует её ещё раз, и один сбой превратится в две строки лога, а возвращённая копия без контекста. Либо обработайте (залогируйте и остановитесь), либо верните обёрнутой.
**data, _ :=**: если Marshal упадёт, data будет nil, запишется пустой файл, а функция вернёт nil. Явный _ на месте ошибки требует рядом объяснения.
**errors.New("Failed to ..." + err.Error())**: текст сохраняется, а причина теряется: errors.Is(err, fs.ErrPermission) выше вернёт false. Оборачивайте через %w, с маленькой буквы, без Failed to.
**defer f.Close() у записанного файла**: на некоторых файловых системах (NFS, проверка квот) неудачная запись проявляется только на close; если это проигнорировать, для битого файла вернётся успех. Добавьте её в именованный результат через errors.Join. Для надёжности сохранения вызывайте ещё и f.Sync().
fmt.Errorf возвращает не-nil ошибку, даже если в %w передан nil, поэтому успешный коммит возвращается как transfer: commit: %!w(<nil>). Деньги уже переведены, вызывающий видит ошибку и повторяет запрос, так что перевод выполняется дважды.
Почему: fmt.Errorf не проверяет свои аргументы: она форматирует текст и всегда возвращает новое значение ошибки. Nil-аргумент превращается только в %!w(<nil>) в сообщении, но результат nil от этого не становится.
Исправление: сначала проверить tx.Commit(), оборачивать только не-nil ошибку и при успехе явно вернуть nil. Отложенный tx.Rollback() остаётся: после успешного Commit он ничего не делает и возвращает sql.ErrTxDone.
Та же ловушка в других местах: return fmt.Errorf("close: %w", f.Close()) и любой хелпер wrap(err, msg), который не начинается с if err == nil { return nil }. Только errors.Join возвращает nil, когда все её аргументы nil.
Как поймать: go vet молчит, потому что операнд типа error для %w допустим. Тест на успешный сценарий с проверкой err == nil падает сразу, а %!w(<nil>) в продовых логах служит верным признаком.
Невосстановленная паника в любой горутине роняет весь процесс, и остановить её может только recover в отложенных вызовах той же горутины; middleware восстановления или recover в родителе её не увидят.
Рантайм выполняет defer паникующей горутины, печатает панику и стек и завершает процесс с кодом 2. Остальные горутины свои defer не выполняют: запросы обрываются на середине, буферы не сбрасываются.
net/http восстанавливает паники только на горутине соединения. Горутина, которую запустил хендлер, не защищена.
Каждому go, чей код может запаниковать (ваш или из сторонней библиотеки), нужен свой отложенный recover. Вынесите это в один хелпер и используйте его вместо голого go.
fatal error: concurrent map writes и all goroutines are asleep не являются паниками; recover их не перехватит.
Восстановление сохраняет процесс живым, но не чинит состояние: логируйте стек из debug.Stack(), считайте такие случаи метрикой и относитесь к ним как к багу.
Результат runJob безымянный, поэтому отложенное замыкание пишет в локальную err, которую никто не возвращает. После паники return err не выполняется, функция возвращает нулевое значение nil, и упавшая задача засчитывается как успешная.
Почему: recover останавливает раскрутку стека, и функция возвращается к вызывающему с тем, что лежит в её результатах. Безымянный результат присваивается только оператором return, а его паника пропустила.
Исправление: назвать результат: func runJob(job Job) (err error). Отложенная функция может читать и менять именованные результаты после return или паники, поэтому err = fmt.Errorf(...) в замыкании становится возвращаемым значением.
То же правило в других местах: отложенный Close или Rollback, ошибка которого должна дойти до вызывающего (err = errors.Join(err, f.Close())), тоже требует именованного результата; с безымянным присваивание молча теряется.
Как поймать: ни компилятор, ни go vet не ругаются, потому что err используется. Юнит-тест с задачей, которая паникует, и проверкой err != nil падает сразу.
Нет: ошибка из errors.New или fmt.Errorf хранит только сообщение и обёрнутую причину. Go рассчитывает на то, что каждый слой добавляет контекст и сообщение читается как трасса операций, а настоящий стек вы снимаете сами там, где это окупается.
Почему не по умолчанию: снятие стека через runtime.Callers стоит времени и аллокаций на каждой ошибке, а многие ошибки ожидаемы и обрабатываются на месте (io.EOF, промах кэша). Платить за это на горячем пути незачем.
Вместо этого нужен контекст: оборачивание на каждом слое даёт handle order: load user 42: query: connection refused, и обычно этого хватает, чтобы найти место сбоя без номеров строк.
Когда нужны строки: запишите стек один раз, там, где ошибка входит в ваш код: небольшой тип-обёртка над runtime.Callers или библиотека вроде github.com/cockroachdb/errors. Архивный github.com/pkg/errors всё ещё встречается в старом коде и печатает стек через %+v.
У паник стек есть: берите debug.Stack() в отложенном recover. slog с AddSource: true записывает место вызова лога, а не место, где создана ошибка.
В одной функции на транспортном слое: домен возвращает свои sentinel'ы и типизированные ошибки, а хендлер или интерсептор переводит их через errors.Is и errors.As в статус и безопасное сообщение, отдавая 500 или codes.Internal для всего неизвестного.
Доменный слой и слой хранения ничего не знают об HTTP. ErrNotFound означает «не найдено», кто бы ни вызывал: REST, gRPC или CLI.
Одна функция сопоставления на транспорт: каждый эндпоинт сообщает об одном и том же сбое одинаково, а новая ошибка добавляется одной строкой.
Клиент получает стабильный код и сообщение, написанное для него. Полная цепочка (с SQL, именами хостов, путями к файлам) уходит только в лог, рядом с request id, который видит и клиент.
Типизированные ошибки несут то, что клиенту можно показать: *ValidationError превращается в 400 с именем поля.
В gRPC возвращайте status.Error(codes.NotFound, "order not found") из интерсептора или хендлера; обычная ошибка Go дойдёт до клиента как codes.Unknown.
Цикл максимум на N попыток: возврат при успехе или при ошибке, которая не исправится со временем, а между попытками идёт ожидание в select на ctx.Done() и таймере, чья задержка каждый раз удваивается, с потолком и случайным джиттером.
Остановка при отмене: select возвращается, как только вызывающий сдался; time.Sleep проспал бы отмену.
Повторяйте только то, что может измениться: таймауты, 503, сброс соединения. 400 или ошибка валидации каждый раз упадут одинаково, а отменённый контекст повторять нельзя никогда.
Джиттер: без него клиенты, упавшие одновременно, одновременно и повторяют, и бьют восстанавливающийся сервис волнами.
Ограничьте задержку и число попыток: и верните последнюю ошибку обёрнутой, чтобы вызывающий мог матчить её через errors.Is.
Идемпотентность: повторяйте только операции, которые безопасно выполнить дважды; повторный POST /charges может списать деньги с карты два раза.
Любая ошибка, на которую вызывающий может матчиться через errors.Is или errors.As, становится частью вашего API, такой же обязывающей, как сигнатура функции. Экспортируйте как можно меньше: sentinel'ы для идентичности; типы, когда вызывающим нужны данные; интерфейсы поведения, когда им нужно лишь задать вопрос.
Sentinel-значения
var ErrNotFound = errors.New("not found"): самый простой вариант, без данных, и вызывающим приходится импортировать ваш пакет только ради сравнения.
Вы уже никогда не сможете перестать её возвращать или убрать %w, который её выставляет. Замена этого %w на %v компилируется, проходит vet и ломает всех вызывающих во время работы.
Экспортируемые типы
*ValidationError несёт поля, которые вызывающие читают через errors.As. Теперь эти поля тоже входят в API: переименование любого из них ломает совместимость.
Интерфейсы поведения
Экспортируйте вопрос, а тип оставьте приватным: вызывающие проверяют interface{ NotFound() bool } через errors.As и могут вообще не импортировать ваш пакет, потому что интерфейс объявляется на их стороне.
Так устроен net.Error с Timeout(). Его Temporary() объявили устаревшим, потому что у «временности» не было чёткого смысла; называйте поведение точно.
Как держать обещание
Документируйте, какие ошибки может вернуть каждая функция; всё недокументированное непрозрачно, и вызывающим стоит проверять только err != nil.
Переводите ошибки зависимостей на своей границе, а не оборачивайте их, чтобы обновление зависимости не меняло ваш API.
Пишите по тесту на errors.Is или errors.As для каждой обещанной ошибки: больше ничто не поймает удалённый %w.
Он должен перебрасывать http.ErrAbortHandler, которым хендлеры намеренно прерывают ответ, и писать 500 только если ещё ничего не отправлено. Если статус уже ушёл в сеть, нужно оборвать соединение, а не дать ответу завершиться как обрезанный 200.
**http.ErrAbortHandler: сервер сам восстанавливает эту панику и закрывает соединение, не логируя стек. Если её проглотить, каждый намеренный обрыв (например, когда reverse proxy теряет апстрим) превращается в залогированный 500.
Уже записано**: второй WriteHeader игнорируется с сообщением superfluous response.WriteHeader call. Если middleware просто вернётся, chunked-ответ корректно закроется, и клиент примет половину JSON как полноценный 200. Паника с http.ErrAbortHandler вместо этого покажет клиенту оборванный ответ.
Отслеживайте запись в обёртке: встраивайте http.ResponseWriter и добавьте Unwrap() http.ResponseWriter, чтобы http.NewResponseController по-прежнему добирался до Flush, Hijack и дедлайнов; голая структура их прячет и ломает стриминг и вебсокеты.
Логируйте внутри отложенной функции: стек из debug.Stack(), маршрут и request id, плюс метрика; после её возврата кадров уже нет.
Он защищает только горутину запроса; горутинам, которые запускает хендлер, нужно своё восстановление.