В Go ошибка это значение, которое функция возвращает, а вызывающий обязан проверить. Компилятор следит только за тем, что переменная использована, и всё остальное остаётся на совести автора: можно сравнить не так, завернуть не тем глаголом, потерять в горутине или вовсе не посмотреть. Ниже десять ошибок, которые чаще всего всплывают на ревью и в инцидентах, с тем, как выглядит правильный вариант. Единый обработчик на границе HTTP, в который всё это должно впадать, разобран в отдельной статье.
Проглоченная ошибка: пустой _ и неиспользованный результат
_ = json.Unmarshal(raw, &cfg)
f.Close()
go sendEmail(ctx, order)
Первая строка молча оставляет cfg пустым, вторая теряет ошибку записи на диск (данные в буфере ядра могли не дойти до файла), третья отправляет письмо в никуда: результат sendEmail никто не увидит. Компилятор здесь не поможет: _ = его устраивает, а вызов без присваивания допустим для любой функции.
Что делать: ошибку либо возвращают, либо обрабатывают явно, либо пишут в лог с объяснением, почему её можно пережить. Для редких случаев «ошибка действительно не важна» комментарий заменяет имя: discardErr(f.Close()) читается лучше, чем голый _. Проверку неиспользованных ошибок делает линтер errcheck (входит в golangci-lint), и его включают в CI с первого дня: на зрелом проекте включить его потом это сотни находок.
Потерянная причина: %v вместо %w
if err != nil {
return fmt.Errorf("load order %d: %v", id, err)
}
Текст сохранился, цепочка оборвалась: errors.Is(err, sql.ErrNoRows) этажом выше вернёт false, и репозиторий, который хотел превратить «нет строк» в 404, отдаст 500. То же самое делает errors.New(err.Error()) и fmt.Errorf("...: " + err.Error()).
if err != nil {
return fmt.Errorf("load order %d: %w", id, err)
}
%w оставляет исходную ошибку доступной для errors.Is и errors.As на любой глубине. Обратная крайность тоже бывает: заворачивать с %w ошибку чужого пакета на границе модуля значит сделать pgx.ErrNoRows частью своего контракта, и когда репозиторий переедет с pgx на другой драйвер, сломаются все, кто проверял эту ошибку. На границе слоя чужую ошибку переводят в свою: errors.Is(err, sql.ErrNoRows) внутри репозитория становится ErrNotFound наружу.
Сравнение по тексту и по ==
if err.Error() == "order not found" { ... }
if err == ErrNotFound { ... }
Первое ломается от любой правки сообщения и от любой обёртки. Второе работает ровно до первого %w по пути: сравнение == видит только внешний слой. Единственный надёжный способ это errors.Is для значений-маркеров и errors.As для типов:
var ErrNotFound = errors.New("order not found")
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
var ve *ValidationError
switch {
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.As(err, &ve):
return http.StatusBadRequest
}
Маркер объявляют один раз на уровне пакета: errors.New("order not found") в теле функции создаёт новое значение при каждом вызове, и сравнивать его не с чем. Несколько ошибок разом (валидация формы, остановка нескольких подсистем) собирают через errors.Join: errors.Is проверит каждую из них.
Затенённый err
func save(ctx context.Context, o Order) (err error) {
tx, err := db.Begin(ctx)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback(ctx)
}
}()
if res, err := tx.Exec(ctx, insertSQL, o.ID); err != nil {
return err
}
return tx.Commit(ctx)
}
err внутри if res, err := ... это новая переменная, видимая только в блоке. Внешний err остаётся nil, и отложенная функция решает, что всё хорошо: при ошибке Exec отката не будет, транзакция повиснет до таймаута соединения. go vet по умолчанию этого не видит, анализатор shadow (или govet с включённым shadow в golangci-lint) видит. Правило руками: там, где от значения err зависит defer, внутри функции пишут =, а не :=, и не объявляют новые err в блоках.
Лог и возврат одновременно
if err != nil {
slog.Error("load order failed", "err", err)
return fmt.Errorf("load order: %w", err)
}
Каждый слой, который так делает, добавляет строку в лог, и одна авария базы превращается в четыре записи с одним и тем же текстом на разных уровнях. Правило: ошибку либо обрабатывают (и тогда логируют там, где обработали), либо возвращают выше, добавив контекст через %w. Логирует тот, кто принимает решение: обработчик HTTP, воркер очереди, main. В обработчике при этом разделяют уровни: 4xx это ожидаемое поведение и в лог ошибок не идёт, 5xx идёт с полной цепочкой.
o, err := load(r.Context(), id)
if err != nil {
status, msg := classify(err)
if status >= 500 {
slog.ErrorContext(r.Context(), "load order failed", "id", id, "err", err)
}
http.Error(w, msg, status)
return
}
Стек вызовов стандартная ошибка не несёт, и это нормально: цепочка %w с контекстом на каждом уровне («load order 42: query orders: connection refused») читается лучше стека. Если стек всё же нужен (редкие паники, ошибки из глубины библиотек), его добавляют один раз в месте возникновения, а не на каждом слое.
Ошибка из горутины в никуда
for _, id := range ids {
go func() {
if _, err := fetch(ctx, id); err != nil {
slog.Error("fetch", "err", err)
}
}()
}
Вызывающий получит управление сразу и никогда не узнает, что половина загрузок упала. Для группы задач с общим результатом есть errgroup: первая ошибка отменяет контекст остальных, Wait возвращает её вызывающему.
g, ctx := errgroup.WithContext(ctx)
out := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetch(ctx, id)
if err != nil {
return fmt.Errorf("order %d: %w", id, err)
}
out[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
Паника в горутине ещё хуже ошибки: она роняет весь процесс, и recover в обработчике HTTP её не поймает, потому что это другая горутина. Любая горутина, которая стартует в обработчике запроса, либо живёт под errgroup, либо сама ловит панику в defer.
Паника вместо ошибки
Паника уместна для ошибок программиста: нарушенный инвариант, невозможная ветка switch, nil там, где его быть не может. Для ожидаемых ситуаций (нет строки, неверный ввод, недоступен сосед) паника это потеря контроля: она разматывает стек через все defer, минует логику отката и приходит в middleware, которое знает только «что-то упало». Библиотечный код не паникует вообще и не вызывает log.Fatal: решение остановить процесс принимает main.
Middleware с recover всё равно нужен, потому что ошибки программиста случаются, но он обязан писать стек и не глотать http.ErrAbortHandler, которым net/http сам прерывает обработчик:
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
panic(rec)
}
slog.ErrorContext(r.Context(), "panic", "value", rec, "stack", string(debug.Stack()))
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Ошибка контекста как авария
Клиент закрыл вкладку, обработчик получил context.Canceled от драйвера базы и записал в лог ошибку уровня ERROR с полным стеком. Тысяча таких за минуту на графике неотличима от настоящей аварии. Ошибки контекста это отдельный класс: context.Canceled означает «запрос больше никому не нужен» и заслуживает статуса 499 и лога уровня INFO, context.DeadlineExceeded означает «сосед не уложился в бюджет» и это 504 с предупреждением. Проверяют их через errors.Is, потому что драйверы заворачивают их по-своему.
Вторая сторона той же ошибки: повтор операции, когда контекст уже отменён. Цикл повторов перед каждой попыткой смотрит на ctx.Err() и не повторяет то, что повторять бессмысленно: ошибки валидации, 4xx, «не найдено».
func retry(ctx context.Context, attempts int, fn func() error) error {
var err error
for i := 0; i < attempts; i++ {
err = fn()
if err == nil {
return nil
}
var ve *ValidationError
if errors.As(err, &ve) || errors.Is(err, ErrNotFound) || ctx.Err() != nil {
return err
}
select {
case <-time.After(time.Duration(1<<i) * 100 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
}
return err
}
Ошибка в defer, которой никто не ждал
defer f.Close()
defer rows.Close()
У Close файла, открытого на запись, есть ошибка, и это та самая ошибка, которая говорит, что данные не записались. У rows из database/sql ошибка итерации приходит не из Next, а из rows.Err() после цикла: обрыв соединения на середине выборки без этой проверки выглядит как «просто короткий список». Оба случая решаются одинаково: именованный результат плюс проверка в defer или явный вызов после цикла.
func writeFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil && cerr != nil {
err = cerr
}
}()
_, err = f.Write(data)
return err
}
for rows.Next() {
...
}
return out, rows.Err()
Отдельная разновидность: тело HTTP-ответа, которое закрыли, не дочитав. Соединение в пуле http.Transport переиспользуется, только если тело прочитано до конца; иначе оно закрывается, и каждый запрос открывает новое. io.Copy(io.Discard, resp.Body) перед Close для ответов, тело которых не нужно, решает это.
Ошибка после начала ответа
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(result); err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
}
Заголовки уже ушли, второй WriteHeader даст предупреждение superfluous response.WriteHeader в логе, а клиент получит обрезанный JSON со статусом 200. Если ответ может не собраться (вычисляемые поля, обращение к хранилищу во время кодирования), его сначала кодируют в буфер, потом отправляют. Если же ошибка пришла из записи в сокет (клиент ушёл), с ней ничего не сделать, кроме записи в лог уровня DEBUG.
Глубже: что оставить машинерасширенное
Три линтера закрывают большую часть перечисленного и стоят пять минут настройки: errcheck ловит неиспользованные ошибки, в том числе у Close и Rollback; errorlint находит %v там, где должно быть %w, и сравнения == вместо errors.Is; анализатор shadow находит затенённый err. Все три живут в golangci-lint, и включать их стоит до того, как в репозитории появится вторая тысяча строк. go vet входит в go test и проверяет форматные строки, но ни одну из ошибок этой статьи сам по себе не находит.
Глубже: когда ошибку всё-таки можно не возвращатьрасширенное
Есть три честных случая. Отложенный Close у файла, открытого только на чтение: ошибок закрытия у него не бывает, и defer f.Close() допустим. Запись в лог и метрики: если упал сам логгер, вернуть ошибку некуда. Фоновая задача «по возможности» (прогрев кэша, отправка аналитики), где падение ничего не меняет для пользователя: ошибка идёт в метрику и лог, вызывающий её не ждёт. Во всех трёх случаях решение видно из кода и объяснено рядом с ним; молчаливый _ = на вызове, у которого есть последствия, честным не бывает.
Коротко
- Каждую ошибку возвращают, обрабатывают или осознанно отбрасывают;
errcheckв CI с первого дня. - Заворачивают через
%wс контекстом;%v,errors.New(err.Error())и склейка строк рвут цепочку. - Чужие ошибки не пропускают через границу модуля:
sql.ErrNoRowsвнутри репозитория становитсяErrNotFoundснаружи. - Сравнивают только
errors.Isиerrors.As; маркеры объявляют на уровне пакета, несколько ошибок собираютerrors.Join. :=в блоке создаёт новыйerr, иdeferс откатом его не увидит; анализаторshadowэто находит.- Ошибку либо логируют там, где обработали, либо возвращают выше; не то и другое сразу.
- Горутины живут под
errgroupили ловят панику сами;recoverобработчика чужую горутину не спасёт. - Паника только для ошибок программиста; middleware с
recoverпишет стек и пропускаетhttp.ErrAbortHandler. context.Canceledэто 499 иINFO,DeadlineExceededэто 504; повтор проверяетctx.Err()и не повторяет 4xx.Closeпри записи,rows.Err()после цикла и дочитанное тело ответа: ошибки, которые прячутся вdefer.
Что почитать дальше
- Ошибки в Go — значения, обёртки и соглашения языка, на которых держится всё выше.
- Единый обработчик ошибок в Go — как ошибки сценариев превращаются в статусы и тело ответа в одном месте.
- Модель ошибок API — что клиент должен увидеть в теле ошибки и чем 4xx отличается от 5xx по смыслу.
- Устойчивость на Go — таймауты, повторы и размыкатели, которые решают, какую ошибку повторять.
- Профилирование и утечки в Go — как найти транзакцию, повисшую из-за затенённого
err.