voidlearn
 

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, спускаясь по цепочке слой за слоем: «этот слой - не он? а этот?»

Одна переменная err - а внутри неё матрёшка из трёх ошибок слой 1 - сервис, обернул то, что пришло снизу fmt.Errorf("profile 42: %w", err) слой 2 - репозиторий, тоже обернул fmt.Errorf("select id=42: %w", err) sql.ErrNoRows - корень, внутри уже пусто Что с этой матрёшкой можно делать err.Error() - только текст, все слои через двоеточие: "profile 42: select id=42: sql: no rows..." errors.Unwrap(err) - снять ровно ОДИН слой: вернёт ошибку слоя 2, слой 1 отброшен errors.Is / errors.As - пройти слои до самого дна и найти нужный. Про них - вся середина главы. Зачем эта конструкция вообще нужна Чтобы текст рассказывал путь («что я делал»), а программная проверка добиралась до причины («что именно сломалось»). Одно без другого не работает.
Матрёшка, а не склеенная строка. Обёртка кладёт старую ошибку внутрь новой целиком. Поэтому наверху есть сразу две вещи: читаемое сообщение о пройденном пути и сама первопричина, с которой можно сравниться программно.

«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. Стектрейса, как уже говорилось, нет: цепочка восстанавливает логический путь ошибки по слоям, а не физический путь по кадрам стека.

err, который вернулся в HTTP-хендлер transport *fmt.wrapError "handler: %w" service *fmt.wrapError "get user 42: %w" repository *QueryError Op: "select", ID: 42 корень цепочки sql.ErrNoRows errors.New(...) Unwrap() Unwrap() Unwrap() errors.Is(err, sql.ErrNoRows) == target? нет == target? нет == target? нет == target? ДА errors.As(err, &qe) не *QueryError не *QueryError тип совпал → qe сюда уже не дойдёт Обход один и тот же - отличается только предикат на звене errors.Is - сравнение == с target; если у звена есть метод Is(error) bool, сначала спрашивают его. errors.As - проверка присваиваемости типа звена типу *target; если есть метод As(any) bool - спрашивают его. Оба останавливаются на первом совпадении. Когда Unwrap() вернул nil - цепочка кончилась, ответ отрицательный.
Цепочка обёрток. Внешнее звено знает только про следующее. 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 промахнётся.
Однострочное различие Is и As

errors.Is отвечает на вопрос «это оно?» и сравнивает с конкретным значением. errors.As отвечает на вопрос «есть ли в цепочке ошибка такого типа, и если да - дай её мне», сравнивает по типу и заполняет переменную. Первое годится для sentinel-ошибок, второе для ошибок с данными.

Sentinel errors

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

SentinelПакетЧто означает
io.EOFioДанные кончились. Это не сбой, а нормальное завершение чтения
io.ErrUnexpectedEOFioПоток кончился посреди структуры - вот это уже сбой
sql.ErrNoRowsdatabase/sqlQueryRow().Scan() не нашёл строк
sql.ErrTxDonedatabase/sqlТранзакция уже закоммичена или откачена
fs.ErrNotExist / os.ErrNotExistio/fsФайла нет. Проверять надо через errors.Is, не через ==
context.CanceledcontextОтменили сверху - клиент ушёл, вызывающий передумал
context.DeadlineExceededcontextИстёк дедлайн. Реализует Timeout() bool
http.ErrServerClosednet/httpListenAndServe вернулся из-за 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")
}
Минусы, которые надо назвать самому
  1. Жёсткая связность. Чтобы сравниться с sql.ErrNoRows, слой транспорта обязан импортировать database/sql. Ошибка инфраструктуры протекает в домен и дальше - до HTTP-хендлера.
  2. Нет данных. Sentinel несёт ровно один бит информации - «случилось». Ни какого поля не хватило, ни какой лимит превышен, ни сколько ждать. Всё это приходится доклеивать текстом, а текст не разбирают программно.
  3. Часть публичного API навсегда. Переименовать её, удалить или поменять условие, при котором она возвращается, значит внести ломающее изменение. Компилятор про него не скажет: errors.Is просто начнёт возвращать false, и код у пользователей тихо сломается в проде.
  4. Плохо переживают обёртки без %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.Join(e1, e2, e3) - не цепочка, а дерево *errors.joinError errs []error - 3 ветки ErrEmptyName sentinel пакета validate *fmt.wrapError "age 200: %w" *FieldError Field: "email" Unwrap() error ErrRange errors.Is(joined, ErrRange) → true, хотя ErrRange лежит на два уровня ниже Is и As спускаются в Unwrap() []error и обходят все ветки в глубину, слева направо, до первого совпадения. А вот errors.Unwrap(joined) вернёт nil: одноуровневый Unwrap умеет только Unwrap() error.
errors.Join. Join-ошибка не «оборачивает» одну причину, а держит слайс. Поэтому Is/As работают, а 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
Тонкости Join
  • 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}
}

Ошибки в слоистом приложении

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

драйвер инфраструктура домен транспорт наружу database/sql sql.ErrNoRows repository &QueryError{...} service %w + операция handler errors.Is → код HTTP 404 code: not_found источник ошибки оборачиваем НЕ логируем оборачиваем НЕ логируем ЛОГИРУЕМ - один раз маппим на статус без SQL и стектрейса + trace_id для саппорта Логируй или возвращай - но не то и другое сразу Если каждый слой и пишет в лог, и пробрасывает ошибку наверх, один сбой БД даёт четыре записи об одном событии: алерты шумят, дедупликация по сообщению ломается, а «5 ошибок в минуту» на дашборде - это одна ошибка.
Жизненный цикл ошибки. Контекст добавляет каждый слой, а в лог ошибка попадает ровно один раз: там, где её окончательно обработали, то есть в транспорте (или в воркере, если это фоновая задача).
// ── 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"
    }
}
Доменная ошибкаHTTPgRPC codeРетраить клиенту?
ErrInvalidInput400 / 422InvalidArgumentнет - детерминированно упадёт снова
нет авторизации401Unauthenticatedнет (можно после обновления токена)
ErrForbidden403PermissionDeniedнет
ErrNotFound404NotFoundнет
ErrConflict (версия/дубль)409AlreadyExists / AbortedAborted - да, после разрешения конфликта
rate limit429ResourceExhaustedда, по Retry-After
всё остальное500Internalосторожно: операция могла пройти
ErrUnavailable503Unavailableда - это транзиентная ошибка по определению
DeadlineExceeded504DeadlineExceededда, но не бесконечно
context.Canceled499 (nginx)Canceledнет - клиент ушёл сам
Три правила, которые стоит произнести дословно
  1. Логируй или возвращай - не оба. Ошибка логируется ровно там, где перестаёт двигаться наверх: в HTTP-хендлере, в consumer'е Kafka, в теле воркера. Везде ниже - только %w.
  2. Оборачивай ровно один раз на слой, добавляя имя операции и основные идентификаторы. Без этого лог выглядит как «not found» без единой подсказки, где именно.
  3. Наружу не должно вытекать ничего внутреннего. Ни текста 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
Суть: ошибка - обычное значение интерфейсного типа, а не отдельный механизм языка. Go меняет многословность на явный, видимый в сигнатуре и в теле функции поток управления.

Никакого «фреймворка ошибок» в языке нет: есть интерфейс error с единственным методом Error() string и пакет errors. Ошибку, как любое обычное значение, можно положить в переменную, в поле структуры, в слайс, передать по каналу, сравнить, обернуть.

Аргументы против исключений

  1. Контракт виден в сигнатуре. func Do() (Result, error) сообщает, что операция может не получиться. Из сигнатуры Java-метода без throws это не видно, а checked exceptions в живом коде почти все обходят.
  2. Один путь выхода. В Go функция заканчивается return. В языке с исключениями выйти можно из любой строки, и чтобы удержать инварианты, нужен try/finally вокруг всего. В Go для этого есть defer, и он привязан к функции, а не к блоку.
  3. Стоимость. Возврат значения ничего не стоит. Бросок исключения раскручивает стек и собирает трейс, в горячем цикле разница измерима.
  4. Композиция. Исключение вне catch не живёт. Значение живёт: можно собрать []error, вернуть ошибку из горутины через канал, отложить решение.
  5. Умолчание. В 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).
Классическая ловушка: typed nil

Интерфейс хранит пару (тип, данные). Если положить в него 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

  1. Если target сравним, проверить err == target.
  2. Если у err есть метод Is(error) bool, спросить его.
  3. Спуститься: Unwrap() error - на следующее звено; Unwrap() []error - рекурсивно во все ветки (обход в глубину).
  4. Нет 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; в логе это не ошибка.

Минусы

  1. Связность. Чтобы сравнить с sql.ErrNoRows, HTTP-хендлер обязан импортировать database/sql. Инфраструктура протекает через все слои.
  2. Нет данных. Только факт. «Какого поля не хватило», «какой лимит», «через сколько повторить» - не выразить.
  3. Публичный API навсегда. Смена условия возврата ломает пользователей молча: компилятор ничего не скажет, errors.Is просто станет false.
  4. Хрупкость к обёрткам. Одна библиотека по дороге обернула через %v - все проверки выше перестали работать.
  5. Глобальное изменяемое состояние. 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 буквально говорит: «пропусти проверку дальше, вглубь».

Три грабли, на которые наступают
  1. Смешанные ресиверы. Если Error() объявлен на *MyErr, то MyErr не реализует error, и errors.As(err, &e) с var e MyErr упадёт с паникой (go vet поймает это заранее). Выбирай один вариант (обычно указательный) и создавай ошибку только через &MyErr{...}.
  2. Typed nil. Конструктор должен возвращать error, а не *MyErr, иначе err != nil станет истиной при пустой ошибке.
  3. Error(), который паникует на нулевых полях. Метод вызывается из логгера и из fmt в самый неудачный момент - он должен быть безопасен всегда.
Методы-расширения помимо Unwrap
  • 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"

Где применяется

  1. Валидация целиком. Пользователь получает список всех проблем формы, а не первую.
  2. Закрытие ресурсов. errors.Join(srv.Shutdown(ctx), db.Close(), mq.Close()) закроет всё и не потеряет ни одной ошибки.
  3. Не потерять ошибку Close в defer через именованный возврат: defer func(){ err = errors.Join(err, f.Close()) }(). Раньше эту ошибку почти всегда молча теряли.
  4. Параллельные задачи, когда важны все сбои, а не первый (в отличие от 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 1.20

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. Если нужна диагностика без логгера в этой точке, передай причину во внутренний слой, который владеет логированием.