Тема 04

Ошибки и отказоустойчивость кода

Секция, которая отделяет «умею писать на Go» от «умею держать сервис живым». Синтаксис здесь на десять минут — if err != nil знают все. Дальше начинается настоящее: чья это ошибка, кто её оборачивает, кто логирует, во что она превращается на границе сервиса и что делает система, когда зависимость легла.

Что реально проверяют этой секцией

Есть ли у тебя дисциплина обработки отказов. Кандидат, который говорит «оборачиваю через %w», и кандидат, который говорит «оборачиваю через %w, потому что %w — это часть публичного контракта пакета: с этого момента вызывающий вправе делать errors.Is на мою внутреннюю ошибку, и я не смогу её сменить, не сломав его» — разные уровни. Дальше почти всегда идёт продолжение про прод: ретраи, таймауты, circuit breaker. Здесь хорошо звучат конкретные числа и конкретные инциденты, а не названия паттернов.

4.1Работа с ошибками

На собесе эта часть темы самая «разговорная». Заучить сигнатуры errors.Is и errors.As мало: спрашивают про политику — что пакет обещает наружу, где ошибка получает контекст, где она умирает в логе и во что превращается на HTTP.

Сначала — четыре слова, без которых дальше будет каша

Ошибка родилась глубоко внизу и всплыла наверх. Как наверху понять, что именно там случилось, и что с этим делать? Чтобы отвечать не наугад, сначала договоримся о четырёх словах. Теории тут нет, но дальше эти слова пойдут без пояснений.

1. Обёртка — это новая ошибка, внутри которой лежит старая

Драйвер базы вернул «нет строк». Отдавать это наверх как есть репозиторий не хочет: ему надо дописать «...и случилось это, когда я искал пользователя 42». Но и потерять исходную причину нельзя — выше по стеку кто-то захочет отличить «нет строк» от «база недоступна».

Выход: сделать новую ошибку, у которой есть свой текст и поле со ссылкой на старую. Это и называется обёрткой (wrapping, «заворачивание»), а пишется одной строкой:

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 ходит по цепочке» задают почти всегда, и почти всегда забывают про метод Is у самой ошибки и про ветвление на Unwrap() []error.

// 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 или он вернул nilfalse.

Алгоритм 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.ErrServerClosedListenAndServe завершился штатно из-за 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 — последний как раз ругается на динамические ошибки и сравнение ошибок через ==. Плюс правило на ревью: обёртка обязана добавлять имя операции, иначе она бесполезна.»

4.2panic vs error

Вопрос «когда panic, а когда error» на собесе почти всегда быстро сворачивает в практику: покажи recover-middleware, объясни, почему recover в хендлере не спасёт горутину, которую этот хендлер запустил, и что делать, если паникует чужая библиотека. Ниже разобраны механика раскрутки и все три сценария.

Сначала — три слова про стек, дальше они идут без пояснений

Что происходит с программой между строкой panic(...) и падением процесса — и где в этот промежуток можно вклиниться? Ответ целиком про устройство стека, поэтому три слова расшифруем заранее.

1. Кадр стека — «рабочий стол» одного вызова функции

Когда функция вызывается, ей выделяется на стеке кусочек памяти под её аргументы, локальные переменные и адрес возврата. Этот кусочек и называется кадром (stack frame). Вызовы вложены друг в друга (main позвал хендлер, хендлер позвал сервис, сервис позвал репозиторий), и кадры лежат стопкой: последний вызванный сверху. Функция вернулась — её кадр сняли.

2. Раскрутка стека — снятие этих кадров подряд, без возвратов

При обычном return снимается один кадр, и управление уходит туда, откуда позвали. При раскрутке (stack unwinding) кадры снимаются один за другим не потому, что функции доработали, а потому, что случилась паника и возвращаться уже некуда. По дороге паника только выполняет отложенные вызовы (defer) каждого снимаемого кадра, и только в них recover() может её остановить.

3. Дескриптор горутины (g) — служебная структура с состоянием горутины

Рантайм видит каждую горутину как структуру runtime.g: в ней лежат границы её стека, текущее состояние, список её defer и — что здесь важно — указатель на текущую панику. Слово дескриптор означает ровно это: не сама горутина, а «карточка учёта» на неё, по которой рантайм ею управляет. Из этого вырастает половина главы: у каждой горутины карточка своя. Паника подвешивается к дескриптору той горутины, где случилась, и раскручивает только её стек — поэтому recover в одной горутине физически не может увидеть панику в другой.

Что происходит внутри рантайма

Компилятор превращает panic в обычный вызов функции runtime.gopanic. Она кладёт в стек текущей горутины структуру runtime._panic (значение, флаги, указатель на предыдущую панику) и цепляет её к полю gp._panic у g, дескриптора горутины. Обычный поток управления на этом заканчивается: дальше работает только раскрутка.

Раскрутка идёт по цепочке отложенных вызовов. У каждой горутины есть односвязный список gp._defer, куда попадают defer. Но большую часть из них компилятор (Go 1.14+) в список не кладёт вовсе: если defer стоит не в цикле и их в функции немного (до восьми), вызов вшивается прямо в эпилог — это open-coded defers. Рантайм при панике находит такие вызовы по funcdata-записи самой функции _FUNCDATA_OpenCodedDeferInfo. Поэтому defer в Go стоит порядка одной наносекунды в happy path и «дорожает» только в момент паники.

Дальше рантайм идёт кадр за кадром, снизу вверх: выполняет все defer текущего кадра в порядке LIFO, снимает кадр, переходит к вызывающему. Если внутри отложенной функции вызван recover() (это runtime.gorecover), рантайм проверяет, что вызов сделан непосредственно из отложенной функции того кадра, который сейчас раскручивается, помечает _panic.recovered = true и через runtime.recovery восстанавливает SP/PC так, будто эта функция просто дошла до return. Именованные результаты функция вернёт такими, какими они остались на момент выхода из defer.

Если ни один defer не сделал recover, раскрутка доходит до вершины горутины, и рантайм вызывает runtime.fatalpanic. Та печатает panic: ..., трейсбек (по умолчанию только паникующей горутины; GOTRACEBACK=all печатает все, GOTRACEBACK=crash ещё и роняет процесс с core dump) и зовёт exit(2). Отложенные вызовы остальных горутин не выполняются — никакого «корректного завершения» при панике нет.

Стек горутины в момент panic("invalid id") и что делает раскрутка кадр стека его defer'ы (LIFO) что происходит раскрутка снизу вверх net/http: conn.serve() кадры самого сервера свой defer с recover внутренний, не твой сюда паника не дошла дошла бы — соединение просто закрылось mw.Recoverer.ServeHTTP() твой middleware defer func(){ recover() }() лог, метрика, 500 recover() вернул "invalid id" раскрутка ОСТАНОВЛЕНА, кадр вернулся штатно api.createOrder() хендлер defer tx.Rollback() выполнился defer отработал, транзакция откачена кадр снят со стека repo.Save() слой данных defer rows.Close() выполнился defer отработал, rows закрыты кадр снят со стека parseID() место аварии отложенных вызовов нет снимаем сразу panic("invalid id") gopanic создал _panic и подвесил к g Порядок работы рантайма 1 - panic(v) вызывает runtime.gopanic: в стеке g создаётся _panic и цепляется к gp._panic. Нормальный поток управления прекращается. 2 - Рантайм идёт по defer'ам текущего кадра в порядке LIFO, выполняет каждый, затем снимает кадр и переходит к вызывающему. 3 - recover() внутри отложенной функции метит _panic.recovered: раскрутка прекращается, кадр этого defer возвращается штатно. 4 - Если recover не случился - fatalpanic: сообщение, трейсбек, exit(2). Отложенные вызовы других горутин НЕ выполняются.
Путь паники. Паника поднимается по кадрам, выполняя их defer, и останавливается ровно в том кадре, чей отложенный вызов сделал recover. Всё, что было ниже, уже снято со стека — «продолжить с места аварии» не выйдет.
recover работает только напрямую из отложенной функции

runtime.gorecover сравнивает адрес кадра, из которого его позвали, с кадром, который сейчас раскручивается. Если recover() вызван на уровень глубже (внутри функции, которую позвала отложенная), он вернёт nil, и паника поедет дальше. На этом спотыкаются чаще всего.

// Работает: recover прямо в отложенной функции
func ok() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()
    panic("boom")
}

// Тоже работает: helper сама и есть отложенная функция
func ok2() {
    defer helper()
    panic("boom")
}
// Не работает: recover на уровень глубже
func bad() {
    defer func() {
        helper()      // recover внутри вернёт nil
    }()
    panic("boom")     // процесс всё равно упадёт
}

func helper() {
    if r := recover(); r != nil {
        fmt.Println("поймал:", r)
    }
}

// Запуск bad() на go1.27: «поймал: boom» не печатается, программа падает.
// stderr, exit status 2:
//   panic: boom
//
//   goroutine 1 [running]:
//   main.bad()
//           /tmp/x/main.go:11 +0x48
//   main.main()
//           /tmp/x/main.go:20 +0x1c
// recover() возвращает не-nil, только если вызван напрямую из отложенной функции
// паникующего кадра. Здесь helper — обычный вызов внутри defer, кадром глубже.

Panic и fatal error — это не одно и то же

Половина «неперехватываемых паник», которые приносят на собес, вовсе не паники. fatal error печатает рантайм напрямую, минуя механизм _panic: никакой раскрутки нет, defer не выполняются, recover физически не может сработать, потому что его никто не позовёт. Команда Go сделала так сознательно: после гонки по map её внутренности уже могли быть испорчены, и продолжать работу опаснее, чем упасть, а при дедлоке продолжать просто некому — спят все горутины.

Что случилосьЧто печатает рантаймrecover спасёт?
Конкурентная запись в mapfatal error: concurrent map writesнет
Конкурентные чтение и запись mapfatal error: concurrent map read and map writeнет
Все горутины заблокированыfatal error: all goroutines are asleep - deadlock!нет
Unlock незалоченного мьютексаfatal error: sync: unlock of unlocked mutexнет
Переполнение стека горутины (1 ГБ)fatal error: stack overflowнет
Кончилась памятьfatal error: out of memoryнет
Ошибка при исполнении самих defer после паникиfatal error: panic holding locks и подобныенет
Любая обычная panicpanic: ... + goroutine N [running]:да, в своей горутине
os.Exit(1)ничегонет — и defer тоже не выполнятся
Чем добить

«concurrent map writes даёт fatal error, а не панику: рантайм ставит флаг hashWriting в заголовке map и, увидев его у второго писателя, зовёт throw, а не gopanic. Поэтому от гонки по map не спасёт никакой recover-middleware — спасают только sync.Map, мьютекс или шардирование. И детект этот вероятностный, гонку он может и пропустить. Гарантированно её ловит только -race.» С Go 1.24 map переехали на Swiss Tables (internal/runtime/maps), но проверка на конкурентную запись осталась ровно та же.

Когда panic допустим

Рабочее правило: error возвращают в ожидаемой ситуации, panic зовут при сломанном инварианте. Речь не о «редкой ошибке»: дальше выполнять код бессмысленно, потому что предположения, на которых он написан, уже нарушены. На практике таких случаев четыре.

КатегорияПримерПочему panic, а не error
1. Невозможное состояние default: в switch по своему же enum: panic("unknown state: " + s.String()) Ветка недостижима, если код корректен. Вернуть error — значит заставить вызывающего обрабатывать невозможное
2. Ошибка программиста Передали nil туда, где по контракту обязателен объект; отрицательный размер буфера Это баг вызывающего, а не отказ среды. Ретрай не поможет, обработка не имеет смысла — надо править код
3. MustXxx на константах regexp.MustCompile, template.Must, netip.MustParseAddr, sqlx.MustExec Аргумент — литерал в исходнике. Если он битый, это не отказ рантайма, а опечатка, и падение при старте — лучший исход
4. Регистрация в пакете и init sql.Register (дубль драйвера), prometheus.MustRegister, http.HandleFunc с дублирующимся паттерном Глобальное состояние собирается один раз при старте. Конфликт значит, что бинарник собран неверно, и работать он не будет
Правило Must-функций

Пару «Compile возвращает error / MustCompile паникует» завели ровно затем, чтобы константы обрабатывались на этапе инициализации, а данные — на этапе выполнения. regexp.MustCompile(pattern) с pattern из конфига или от пользователя превращается в готовый DoS: любой кривой конфиг роняет процесс. Если строка не литерал, бери версию с error.

// Хорошо: константа, компилируется один раз при старте пакета.
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[a-z]{2,}$`)

// Плохо: паттерн из конфига. Одна опечатка в regex в YAML - и сервис не стартует
// или падает на первом же запросе.
func NewFilter(cfg Config) *Filter {
    return &Filter{re: regexp.MustCompile(cfg.Pattern)}   // NO
}

// Хорошо: ошибка конфига - это обычная ошибка.
func NewFilter(cfg Config) (*Filter, error) {
    re, err := regexp.Compile(cfg.Pattern)
    if err != nil {
        return nil, fmt.Errorf("bad filter pattern %q: %w", cfg.Pattern, err)
    }
    return &Filter{re: re}, nil
}
Про init отдельно

Паника в init() легальна для того, что не зависит от внешнего мира: компиляция шаблонов, парсинг встроенных ресурсов, регистрация метрик. А вот для подключения к БД, чтения переменных окружения и походов в сеть она не годится: init выполняется до main, до настройки логгера и до чтения флагов, и ты получишь голый трейсбек в stderr без единого поля контекста и без возможности повторить попытку. Поэтому в init держи только чистые константы, а всё остальное собирай явным New(ctx, cfg) (*App, error) из main: там падение можно залогировать по-человечески и вернуть внятный exit code.

Почему panic не должен пересекать границу пакета

Как только твоя библиотека паникует на данных вызывающего, ты отнял у него право выбрать политику. Он хотел вернуть 400, залогировать и жить дальше — а получил падение процесса. Отсюда правило: panic остаётся внутри пакета, а наружу из экспортированной функции выходит только error.

Внутри пакета паника при этом бывает очень удобна: ею быстро выходят из глубокой рекурсии, где протаскивать error через двадцать уровней противно. Так делает сама стандартная библиотека: encoding/json, text/template, go/parser (там это буквально называется bailout) кидают панику собственного приватного типа и ловят её на границе экспортированной функции.

// Приватный тип - маркер «это наша паника, а не чужая».
type bailout struct {
    err error
}

func (p *parser) fail(format string, a ...any) {
    panic(bailout{fmt.Errorf(format, a...)})
}

// Экспортированная функция: наружу - только error.
func Parse(src []byte) (ast *AST, err error) {   // именованный результат обязателен
    defer func() {
        rec := recover()
        if rec == nil {
            return
        }
        b, ok := rec.(bailout)
        if !ok {
            panic(rec)      // чужая паника - пробрасываем как есть, не глотаем
        }
        err = fmt.Errorf("parse: %w", b.err)
    }()

    p := &parser{src: src}
    return p.parseFile(), nil
}
Re-panic для чужих паник

Строка if !ok { panic(rec) } отличает грамотный recover от «глушилки». Если глотать все паники подряд, ты спрячешь nil pointer dereference в собственном коде: сервис будет молча возвращать «ошибку парсинга» на каждый запрос, а настоящий баг никто не увидит. Ловим только свой тип, всё остальное летит дальше к recover-middleware, который его залогирует с полным стеком.

recover-middleware в HTTP-сервере

Свой recover у net/http уже есть. В conn.serve стоит defer, который ловит панику из хендлера, печатает http: panic serving 10.0.0.5:52341: ... со стеком в Server.ErrorLog и закрывает соединение. Процесс не падает — и на этом хорошие новости заканчиваются.

  • Клиент не получает ответа. Соединение обрывается: на той стороне EOF, connection reset by peer или пустой ответ. Балансировщик посчитает это 502, а может и вообще не посчитает.
  • В твоих метриках ничего нет. Хендлер не вернулся, значит, middleware метрик тоже не досчитало: http_requests_total{code="500"} не вырос, алерт не сработал.
  • В логе нет контекста. За Server.ErrorLog стоит обычный log.Logger, который пишет в stderr без trace_id, без пользователя, без пути, без структурированных полей. Найти этот стек в Loki по trace_id ты не сможешь.
  • Keep-alive убит. Соединение закрывается, клиент открывает новое, и на всплеске паник это заметная лишняя нагрузка.
Один и тот же баг в хендлере — два разных инцидента Без своего recover-middleware panic в хендлере conn.serve() делает свой внутренний recover стек в stderr без trace_id и полей соединение закрыто, ответа нет Клиент видит EOF или connection reset: ни статуса, ни тела, ни trace_id. Счётчик 5xx не вырос — алерт молчит. С middleware Recoverer panic в хендлере recover + debug.Stack() slog с trace_id, метрика 500 + JSON-тело без внутренностей keep-alive жив ответ доставлен Клиент получает 500 и trace_id, стек лежит рядом с логом запроса, panics_total растёт, дежурный получает алерт.
Зачем свой recover. Сервер и так не упадёт. Своё middleware нужно ради ответа клиенту, структурированного лога со стеком и метрики, по которой видно, что паника вообще была.
// wrapWriter нужен, чтобы знать, начали ли уже писать тело:
// после первого Write статус ответа изменить уже нельзя.
type wrapWriter struct {
    http.ResponseWriter
    status int
    wrote  bool
}

func (w *wrapWriter) WriteHeader(code int) {
    if w.wrote {
        return
    }
    w.status, w.wrote = code, true
    w.ResponseWriter.WriteHeader(code)
}

func (w *wrapWriter) Write(b []byte) (int, error) {
    if !w.wrote {
        w.WriteHeader(http.StatusOK)
    }
    return w.ResponseWriter.Write(b)
}

func Recoverer(log *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ww := &wrapWriter{ResponseWriter: w}

            defer func() {
                rec := recover()
                if rec == nil {
                    return
                }
                // http.ErrAbortHandler - служебная паника самого net/http:
                // так хендлер сообщает серверу «оборви ответ молча».
                // Её нельзя глотать, иначе сломаем контракт сервера.
                if rec == http.ErrAbortHandler {
                    panic(rec)
                }

                log.ErrorContext(r.Context(), "panic in http handler",
                    slog.Any("panic", rec),
                    slog.String("method", r.Method),
                    slog.String("path", r.URL.Path),
                    slog.String("trace_id", traceID(r.Context())),
                    slog.String("stack", string(debug.Stack())),   // стек снимаем здесь,
                )                                                  // выше по стеку он уже потерян
                panicsTotal.WithLabelValues(r.Method, routePattern(r)).Inc()

                // Если тело уже пошло клиенту, статус не переписать -
                // остаётся только оборвать ответ, чтобы клиент увидел битый payload,
                // а не «успешный» усечённый JSON.
                if ww.wrote {
                    panic(http.ErrAbortHandler)
                }
                ww.Header().Set("Content-Type", "application/json; charset=utf-8")
                ww.WriteHeader(http.StatusInternalServerError)
                _, _ = ww.Write([]byte(`{"error":"internal","trace_id":"` + traceID(r.Context()) + `"}`))
            }()

            next.ServeHTTP(ww, r)
        })
    }
}
Где именно ставить Recoverer в цепочке

Ставь его самым внешним, но после middleware, которое кладёт trace_id в контекст, иначе в логе паники не будет по чему искать. Рабочий порядок снаружи внутрь: RequestIDRecovererLoggerMetricsTimeoutAuthRateLimit → хендлер. Если Recoverer поставить внутрь Metrics, паника в самих метриках пройдёт мимо него, и её поймает уже net/http — без ответа клиенту и без trace_id. А снаружи RequestID ты потеряешь корреляцию.

gRPC: здесь recover обязателен, а не желателен

У grpc-go нет встроенного recover. Паника в хендлере поднимается по горутине, обслуживающей стрим, доходит до вершины и роняет весь процесс — вместе со всеми остальными активными RPC. Один кривой запрос кладёт инстанс целиком. Поэтому recovery-интерцептор в бутстрапе обязателен, без вариантов.

func RecoveryUnary(log *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
        handler grpc.UnaryHandler) (resp any, err error) {

        defer func() {
            rec := recover()
            if rec == nil {
                return
            }
            log.ErrorContext(ctx, "panic in grpc handler",
                slog.String("method", info.FullMethod),
                slog.Any("panic", rec),
                slog.String("stack", string(debug.Stack())))
            panicsTotal.WithLabelValues("grpc", info.FullMethod).Inc()

            resp = nil
            // Наружу - только codes.Internal без деталей: текст паники
            // может содержать данные другого пользователя.
            err = status.Error(codes.Internal, "internal error")
        }()

        return handler(ctx, req)
    }
}

// Для стримов нужен отдельный интерцептор - StreamServerInterceptor,
// unary его не покрывает.
На что ловят

Три классических добивания. Первое: «а если паника случится в самом Recoverer?» Тогда её поймает внешний recover net/http, поэтому в middleware не должно быть ничего, что может паниковать (никакого разыменования полей запроса без проверки). Второе: «где снимать стек?» Только внутри defer, до выхода из него: debug.Stack() возвращает стек текущей горутины, и после завершения раскрутки он уже другой. Третье: «что отдавать клиенту?» — фиксированное тело, никогда fmt.Sprint(rec): в паниках регулярно оказываются куски SQL, ключи и чужие персональные данные.

Паника из сторонней библиотеки

Шаги идут по убыванию правильности, а не удобства.

  1. Локализовать и завести issue. Паника в чужом коде почти всегда означает баг библиотеки либо неверное использование её API. Воспроизведи минимальным тестом.
  2. Запинить или форкнуть версию. Если баг известен и уже починен, обновись; если нет, сделай форк с патчем и replace в go.mod. Это честнее, чем прятать.
  3. Обернуть вызов в собственный recover временно, с метрикой и алертом, чтобы «мы затыкаем чужую панику N раз в сутки» было видно.
  4. Изолировать процессом, если библиотека совсем нестабильна (cgo-обёртки, парсеры чужих форматов, ffmpeg-биндинги): вынести её в отдельный бинарь и общаться через unix-сокет. Только так можно пережить и fatal error, и SIGSEGV из cgo.
// Обёртка-изолятор для одного вызова.
// Возвращает ошибку вместо паники и не глотает признак того, что паника была.
var ErrLibPanic = errors.New("third-party library panicked")

func safeDecode(b []byte, v any) (err error) {
    defer func() {
        rec := recover()
        if rec == nil {
            return
        }
        libPanics.WithLabelValues("badcodec.Decode").Inc()

        // Сохраняем и текст, и стек: без стека такую панику не найти,
        // потому что она пришла из чужого кода.
        err = fmt.Errorf("%w: badcodec.Decode: %v\n%s", ErrLibPanic, rec, debug.Stack())
    }()

    return badcodec.Decode(b, v)   // паникует на некоторых битых входах
}
Чего recover вокруг чужого кода НЕ делает
  • Не откатывает состояние. Паника могла случиться посреди обновления внутренней структуры библиотеки: половина полей записана, мьютекс захвачен и уже никогда не будет отпущен. Дальше объект надо считать мёртвым и создавать заново, а не переиспользовать.
  • Не ловит fatal error. Гонка по map внутри библиотеки, дедлок, OOM — процесс всё равно упадёт.
  • Не ловит панику из горутин, которые библиотека запустила сама. А их ты не контролируешь вообще.
  • Не ловит краш из cgo. SIGSEGV в сишном коде приходит как сигнал, а не как паника Go: рантайм печатает signal arrived during cgo execution и завершается.

Паника в чужой горутине: почему recover бессилен

На этом вопросе проваливаются чаще всего. recover() читает gp._panic текущей горутины. У новой горутины свой g, своя цепочка _defer и свой список паник. Кадры родителя в ней не участвуют, да и «родителя» у горутины формально нет: go f() не создаёт никакой связи, кроме записи в трейсбеке created by ....

Цепочка defer принадлежит горутине, а не стеку процесса G1 — горутина HTTP-хендлера defer func(){ recover() }() лежит в G1._defer go sendReceipt(order) хендлер спокойно дописывает ответ 200 OK уже улетел клиенту G2 — фоновая отправка чека m[orderID] = receipt // m == nil panic: assignment to entry in nil map в G2._defer пусто — recover звать некому fatalpanic: exit status 2, весь процесс go нет Почему так recover() смотрит на _panic ТЕКУЩЕЙ горутины. У G2 свой g, свой список _defer и свой _panic — кадры G1 туда не входят. Непойманная паника в любой горутине означает fatalpanic: рантайм валит весь процесс. «Тихо умершего потока», как в Java, в Go нет. Вывод: recover обязан стоять в первой же функции каждой запускаемой горутины. Пиши safeGo вместо голого go f().
Границы recover. Хендлер уже ответил 200 и считает, что всё хорошо; через мгновение процесс падает из-за горутины, которую он запустил. В логе балансировщика это будет выглядеть как «пачка 502 без причины».
// Единственная точка запуска горутин в проекте.
// Правило на ревью: голый `go` разрешён только внутри этого файла.
func Go(ctx context.Context, log *slog.Logger, name string, fn func(context.Context)) {
    go func() {
        defer func() {
            rec := recover()
            if rec == nil {
                return
            }
            log.ErrorContext(ctx, "panic in goroutine",
                slog.String("goroutine", name),
                slog.Any("panic", rec),
                slog.String("stack", string(debug.Stack())))
            goroutinePanics.WithLabelValues(name).Inc()
        }()
        fn(ctx)
    }()
}

// errgroup.Group.Go панику не перехватывает. Она только считает
// горутины и запоминает первую ошибку - паника внутри f() уходит наверх
// по своей горутине и роняет процесс. Хочешь пережить - ставь recover сам.
Go 1.25: WaitGroup.Go

В Go 1.25 у sync.WaitGroup появился метод Go(f func()), который сам делает Add(1) / go / defer Done(). Он убирает классическую ошибку «забыл Add», но recover в нём тоже нет: паника внутри wg.Go(f) роняет процесс ровно так же. Донести панику воркера до того, кто ждёт группу, из популярных библиотек умеет sourcegraph/conc (conc.WaitGroup, pool.Pool): он ловит панику, сохраняет её вместе со стеком и перебрасывает в горутину, вызвавшую Wait(), а WaitAndRecover() отдаёт её значением.

Паника в воркере пула

Даже те, кто помнит про safeGo, валятся на вопросе, где именно поставить recover. Если он стоит в самой функции воркера, воркер после первой же паники завершится, и пул тихо усохнет с 16 горутин до нуля, продолжая при этом «работать». Очередь будет расти, на метрику живых горутин никто не смотрит, инцидент найдут через сутки.

// Плохо: recover на уровне воркера.
// Одна паника - минус один воркер навсегда.
func (p *Pool) worker(id int) {
    defer func() {
        if r := recover(); r != nil {
            p.log.Error("worker died", "id", id)
        }
    }()
    for job := range p.jobs {
        job.Run()
    }
}
// Хорошо: recover на уровне одной задачи.
// Воркер переживает любую задачу.
func (p *Pool) worker(ctx context.Context, id int) {
    for job := range p.jobs {
        p.runOne(ctx, id, job)
    }
}

func (p *Pool) runOne(ctx context.Context, id int, job Job) {
    defer func() { ... recover, лог, job.Fail(...) ... }()
    job.Run(ctx)
}
func (p *Pool) runOne(ctx context.Context, workerID int, job Job) {
    defer func() {
        rec := recover()
        if rec == nil {
            return
        }
        p.log.ErrorContext(ctx, "panic while processing job",
            slog.String("job_id", job.ID),
            slog.Int("worker", workerID),
            slog.Any("panic", rec),
            slog.String("stack", string(debug.Stack())))
        jobPanics.WithLabelValues(job.Type).Inc()

        // Что делать с задачей, решай явно:
        //  - брокер: Nack без requeue -> DLQ (паника детерминирована, requeue = вечный цикл);
        //  - своя очередь: пометить failed с причиной, не возвращать в работу;
        //  - при at-least-once обязательно ограничить delivery_count.
        job.Fail(ctx, fmt.Errorf("panic: %v", rec))
    }()

    job.Run(ctx)
}
Паника + at-least-once = бесконечный цикл

Классический прод-инцидент: воркер Kafka или RabbitMQ паникует на «ядовитом» сообщении (poison message) и не подтверждает его — не коммитит офсет или не шлёт ack, — сообщение приходит снова, воркер паникует снова. Пока recover не поставлен, консьюмер целиком падает и перезапускается по кругу; после recover крутится одно и то же сообщение и жрёт CPU. Лечится счётчиком доставок и DLQ: после N попыток сообщение уходит в отдельную очередь, а консьюмер идёт дальше.

Panic и исключения других языков

Формально panic/recover устроены как исключения: та же раскрутка стека с перехватом. Отличаются они культурой применения и парой важных ограничений.

СвойствоGoJava / C# / PythonRust
Где можно перехватить Только внутри defer. Отдельного catch посреди функции нет Любой try/catch в любом месте catch_unwind, и то с оговорками (UnwindSafe)
Перехват по типу Нет. recover() отдаёт any, тип разбираешь сам через type switch Есть, с иерархией классов и множественными catch Нет, Box<dyn Any>
Обычный поток управления Нельзя. Паника вместо ошибки — антипаттерн, ловится линтерами Норма: StopIteration, FileNotFoundException, EAFP-стиль в Python Нельзя, для этого Result
Кто отвечает за очистку defer — явно, руками finally / try-with-resources / using / with RAII: Drop вызывается автоматически
Изоляция «потока» Нет. Непойманная паника в горутине роняет процесс Есть: непойманное исключение убивает только поток (UncaughtExceptionHandler) Есть: thread::spawn вернёт Err в join()
Отключаемость Нет Нет Есть: panic = "abort" в профиле сборки
Формулировка, которая закрывает вопрос

«Технически panic и есть исключение, но с двумя отличиями. Первое: перехват возможен только в defer, поэтому написать try/catch вокруг каждой сомнительной строки физически неудобно, и язык подталкивает возвращать ошибки. Второе, куда более практичное: в Go нет изоляции по горутинам. В Java непойманное исключение убивает один поток, а в Go — весь процесс. Поэтому recover в Go нужен чаще, чем catch в Java, а не реже.»

Стандартные паники Go: как звучат сообщения

Формулировки полезно помнить дословно: на собесе часто дают текст паники из прода и просят по нему назвать причину. Все эти паники перехватываются (в отличие от fatal error из таблицы выше).

ПричинаСообщениеЧто важно помнить
Запись в nil map panic: assignment to entry in nil map Чтение из nil map легально и возвращает нулевое значение; len, range и даже delete — тоже (последний просто no-op). Паникует ровно одна операция: запись
Индекс за границей panic: runtime error: index out of range [5] with length 3 В сообщении видно и индекс, и длину (с Go 1.12) — этого обычно хватает, чтобы найти место без отладчика
Срез за границей panic: runtime error: slice bounds out of range [:7] with capacity 5 Слайс можно расширять до cap, а не до len: s[:cap(s)] легально
Разыменование nil-указателя panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]
Рантайм ловит SIGSEGV и превращает в панику — только для адресов в первой странице памяти. Настоящая порча памяти из cgo так не перехватывается
Вызов метода на nil-приёмнике интерфейса то же nil pointer dereference Метод с value-receiver на nil-указателе паникует; с pointer-receiver — может отработать, если не трогает поля
Целочисленное деление на ноль panic: runtime error: integer divide by zero Только для целых. У float результат — +Inf/NaN, паники нет. x % 0 паникует так же
Type assertion без ok panic: interface conversion: interface {} is string, not int Форма v, ok := x.(T) не паникует никогда — вот почему её и просят использовать
Type assertion на nil-интерфейсе panic: interface conversion: interface is nil, not *main.User Отдельная формулировка: интерфейс пуст, а не «не тот тип»
Assertion на непокрытый интерфейс panic: interface conversion: *main.T is not io.Reader: missing method Read Рантайм прямо называет недостающий метод
Закрытие закрытого канала panic: close of closed channel Отсюда правило «закрывает тот, кто пишет, и ровно один раз»; при нескольких писателях — sync.Once или отдельный канал-сигнал
Закрытие nil-канала panic: close of nil channel А вот чтение и запись в nil-канал не паникуют — блокируются навсегда, это используют для отключения ветки select
Запись в закрытый канал panic: send on closed channel Чтение из закрытого канала легально: отдаёт нулевое значение и ok == false
Отрицательный счётчик WaitGroup panic: sync: negative WaitGroup counter Обычно значит лишний Done() или Add после Wait
Повторное использование WaitGroup panic: sync: WaitGroup is reused before previous Wait has returned WaitGroup нельзя переиспользовать, пока предыдущий Wait не вернулся
Копирование залоченного мьютекса не паникует, но go vet ругается: passes lock by value Тихий баг: копия мьютекса не защищает оригинал. Ловится только vet и -race
Явная паника с ошибкой panic: sql: no rows in result set Если в panic() передан error, рантайм печатает его Error(). Это не паника рантайма, а чей-то panic(err)
Паника во время раскрутки panic: первая [recovered] ... panic: вторая Метка [recovered] означает, что панику поймали, но в том же defer случилась новая. Частый источник — паника внутри самого логгера

Что выведет код?

func f() (n int) {
    defer func() {
        if r := recover(); r != nil {
            n = -1
        }
    }()
    defer fmt.Println("A")

    n = 1
    panic("boom")
}

func main() {
    fmt.Println(f())
    fmt.Println("done")
}

// вывод (проверено запуском на go1.27):
// A
// -1
// done
//
// Без имени у результата (func f() int) то же самое, но вместо -1 будет 0.
Разбор

Выведет A, затем -1, затем done. Отложенные вызовы идут в порядке LIFO, поэтому первым отрабатывает fmt.Println("A") — его зарегистрировали позже. Следом выполняется отложенная функция с recover: она гасит панику и присваивает -1 именованному результату n. После этого f возвращается так, будто дошла до return, и main продолжает работу.

Что изменится, если убрать имя у результата (func f() int)? Паника всё равно будет перехвачена и процесс выживет, но вернётся 0: штатного return не случилось, слот результата остался нулевым, а дописать в него из defer без имени нечем. Присваивание n = 1 до паники по той же причине роли не играет: пока результат безымянный, это просто локальная переменная.

Вопросы

6
Суть: error — для ожидаемых ситуаций внешнего мира, panic — для сломанных инвариантов собственной программы. Критерий простой: если у вызывающего есть осмысленная реакция на эту ситуацию — это error.

Разграничение

Разделять надо не по «редкости» и не по «серьёзности», а по вопросу: может ли код продолжать работать корректно. Битый JSON от клиента, недоступная БД, файл не найден, таймаут — нормальная жизнь распределённой системы, и на всё это есть обработка: вернуть 400, ретраить, отдать кэш. А вот если сработал default в switch по своему же перечислению или в поле, которое по контракту обязано быть заполнено, лежит nil, дальше считать бессмысленно: любой ответ будет неправильным.

Четыре легитимные категории

  • Невозможное состояние. panic(fmt.Sprintf("unknown order state: %v", s)) в недостижимой ветке. Если вернуть вместо этого ошибку, вызывающему придётся писать обработку того, чего быть не может, а баг окажется замаскирован.
  • Ошибка программиста. Нарушен контракт функции: отрицательная длина, nil обязательной зависимости в конструкторе. Ретрай не поможет, обрабатывать тут нечего. Код надо править, и падение гарантирует, что его поправят.
  • MustXxx на константах. regexp.MustCompile, template.Must, netip.MustParseAddr. Аргумент берётся литералом из исходника, так что битый аргумент означает опечатку, а не отказ. Падение при старте лучше, чем 500 через два часа.
  • Регистрация глобального состояния и init. sql.Register при дубле драйвера, prometheus.MustRegister при конфликте метрик, http.HandleFunc с уже занятым паттерном. Это происходит один раз при сборке процесса; конфликт значит, что бинарник собран неверно.

Про init — отдельно

Паника в init уместна только для того, что не зависит от внешнего мира: компиляция шаблонов и регулярок, парсинг встроенных через go:embed ресурсов. Подключение к БД, чтение переменных окружения и походы в сеть туда не годятся: init выполняется до main, до настройки логгера и до разбора флагов, поэтому в проде ты получишь голый трейсбек без единого поля контекста. Всё, что может отказать по внешним причинам, должно жить в явном New(ctx, cfg) (*App, error), вызванном из main.

Обязательная оговорка

Даже когда panic внутри пакета уместен, наружу он выходить не должен: экспортированная функция возвращает error. Паника допустима как внутренний механизм (например, для быстрого выхода из рекурсии), но на границе её ловят через recover и превращают в ошибку. Ровно так устроены encoding/json, text/template и go/parser.

На чём ловят

Просят пример «плохого» panic и ждут именно regexp.MustCompile(cfg.SomePattern), то есть Must-функцию от неконстантных данных. Один кривой символ в YAML — и сервис не поднимается или падает на первом запросе. Второй частый вопрос: «а если функция паникует, надо ли это писать в godoc?» Да, обязательно: условия паники входят в контракт.

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

Почему это правило

Библиотека не знает, в каком контексте её зовут. Один вызывающий на битом входе вернёт 400 и продолжит работу, другой переключится на резервный источник. Паника отнимает этот выбор и превращает локальную проблему в отказ всего процесса: одна кривая строка от одного пользователя кладёт инстанс, обслуживающий тысячу других. К тому же паника не видна в сигнатуре: из godoc-заголовка вызывающий не узнает, что функция способна уронить его сервис.

Как это делают в стандартной библиотеке

Внутри пакета паника бывает удобна: рекурсивный парсер или энкодер уходит на десятки уровней вглубь, и протаскивать error через каждый значит засорять код шумом, который прячет логику. Поэтому encoding/json, text/template и go/parser кидают панику приватного типа и ловят её на границе. В go/parser этот тип буквально называется bailout.

type bailout struct {
    err error
}

func Parse(src []byte) (ast *AST, err error) {   // именованный результат обязателен
    defer func() {
        rec := recover()
        if rec == nil {
            return
        }
        b, ok := rec.(bailout)
        if !ok {
            panic(rec)     // чужая паника - пробрасываем, не глотаем
        }
        err = fmt.Errorf("parse: %w", b.err)
    }()
    p := &parser{src: src}
    return p.parseFile(), nil
}

Три обязательные детали

  • Приватный тип-маркер. Ловим только свою панику и узнаём её по типу, а не по факту «что-то прилетело».
  • panic(rec) для чужого. Без этой строки ты спрячешь собственный nil pointer dereference: сервис будет отвечать «ошибка парсинга» на каждый запрос, а настоящий баг не увидит никто.
  • Именованный результат. Присвоить возвращаемое значение из defer можно только ему.
Глубже, чем спросят

В encoding/json этот приём со временем сократили: сейчас через панику выходят в основном ошибки самого энкодера (jsonError), а пользовательские ошибки идут обычным путём. Причина: recover ломает инлайнинг, мешает оптимизациям и делает профили менее читаемыми. Для собеса вывод такой: приём легальный, но не бесплатный, и нужен он там, где рекурсия действительно глубокая, а не «чтобы не писать if err != nil».

Суть: net/http и так не даст процессу упасть, но клиент получит оборванное соединение без статуса, в метриках не будет 5xx, а стек уедет в stderr без trace_id. Своё middleware нужно ради ответа, лога с контекстом и метрики. В gRPC recover обязателен: там паника роняет весь процесс.

Что делает сервер сам

В conn.serve стоит defer с recover: он печатает http: panic serving 10.0.0.5:52341: ... со стеком в Server.ErrorLog и закрывает соединение. Процесс выживает — и всё. Клиент видит EOF или connection reset, балансировщик записывает это как 502, твоё middleware метрик до defer не дошло, счётчик http_requests_total{code="500"} не вырос, алерт не сработал, а стек придётся искать в stderr глазами, потому что trace_id там нет.

Что должно делать своё middleware

  • Снять стек прямо в defer: debug.Stack() отдаёт стек текущей горутины, а после выхода из отложенной функции он уже другой.
  • Залогировать структурно: trace_id, метод, путь-паттерн (не сырой URL — иначе взорвётся кардинальность метрик, то есть число уникальных комбинаций меток: каждый новый id в пути заводит в системе мониторинга отдельный временной ряд), сам объект паники, стек.
  • Инкрементнуть метрику panics_total{method,route}. На ней и висит алерт «в проде появилась паника».
  • Ответить 500 с фиксированным телом. Никогда не отдавать fmt.Sprint(rec): в паниках регулярно оказываются куски SQL, ключи и чужие персональные данные.
  • Пробросить http.ErrAbortHandler. Это служебная паника самого net/http: ею хендлер сообщает «оборви ответ молча». Проглотишь — сломаешь контракт сервера (и, например, обработку разорванных прокси-соединений).
  • Проверить, писали ли уже тело. После первого Write статус не изменить — WriteHeader просто ничего не сделает, и клиент получит усечённый JSON со статусом 200. В этом случае честнее оборвать ответ.

Где ставить в цепочке

Почти самым внешним, но после middleware, которое кладёт trace_id в контекст. Рабочий порядок: RequestIDRecovererLoggerMetricsTimeoutAuth → хендлер. Поставишь Recoverer внутрь Metrics — паника в самих метриках пройдёт мимо; поставишь снаружи RequestID — потеряешь корреляцию.

gRPC

У grpc-go встроенного recover нет. Паника в хендлере поднимается по горутине стрима до вершины и роняет процесс вместе со всеми остальными активными RPC. Поэтому recovery-интерцептор обязательно входит в бутстрап, причём нужны оба: UnaryServerInterceptor и StreamServerInterceptor. Наружу отдаём status.Error(codes.Internal, "internal error") без деталей.

Чем добить и на чём валятся

Обязательно скажи вслух: recover-middleware не спасёт от паники в горутине, которую запустил хендлер — там нужен свой recover. И не спасёт от fatal error (concurrent map writes, дедлок, OOM). И ещё: recover не значит «теперь всё хорошо». Паника случилась посреди операции, состояние может быть неконсистентным: транзакция откатится по defer, а вот неотпущенный мьютекс или полуобновлённый кэш в памяти останутся как есть. Каждая паника в проде — инцидент, а не строчка в логе.

Суть: обёртка с recover вокруг конкретного вызова — временная мера с метрикой и алертом. Настоящее решение — воспроизвести, завести issue, запинить или форкнуть версию; для совсем нестабильных библиотек — изоляция отдельным процессом.

Порядок действий

  1. Воспроизвести минимальным тестом. Половина «паник библиотеки» оказывается неправильным использованием её API, например конкурентным доступом к объекту, который по документации не потокобезопасен.
  2. Обновиться / запинить / форкнуть. replace в go.mod на свой форк с патчем честнее, чем глушить симптом.
  3. Обернуть в recover, причём точечно: вокруг одного вызова, а не «на всякий случай» вокруг всего слоя. И с метрикой lib_panics_total{call="..."}, чтобы на дашборде было видно: «мы затыкаем чужую панику 400 раз в сутки».
  4. Изолировать процессом cgo-обёртки, парсеры чужих бинарных форматов, медиа-библиотеки. Только отдельный бинарь с unix-сокетом позволяет пережить и fatal error, и SIGSEGV из сишного кода.
var ErrLibPanic = errors.New("third-party library panicked")

func safeDecode(b []byte, v any) (err error) {
    defer func() {
        rec := recover()
        if rec == nil {
            return
        }
        libPanics.WithLabelValues("badcodec.Decode").Inc()
        // Стек обязателен: без него чужую панику не найти.
        err = fmt.Errorf("%w: badcodec.Decode: %v\n%s", ErrLibPanic, rec, debug.Stack())
    }()
    return badcodec.Decode(b, v)
}

Чего эта обёртка НЕ делает

  • Не откатывает состояние библиотеки. Паника могла случиться посреди обновления внутренней структуры: часть полей записана, мьютекс захвачен и не отпущен. Объект после такого надо считать мёртвым и пересоздавать, а не переиспользовать. Если библиотека держит пул или соединение, пересоздавать придётся весь клиент.
  • Не ловит fatal error. Гонка по map внутри библиотеки, дедлок, OOM — процесс упадёт, и никакой recover этого не изменит.
  • Не ловит паники из её собственных горутин. Если библиотека запускает фоновые воркеры, ты до них не дотянешься вообще, остаются только issue или форк.
  • Не ловит краши cgo. SIGSEGV в C-коде рантайм видит как сигнал, а не панику: signal arrived during cgo execution и завершение процесса.
Как это звучит на собесе

«Мы ловили панику в парсере XLSX на файлах с битым zip-заголовком. Сначала завернули вызов в recover с метрикой, повесили алерт и отдавали пользователю 422 вместо 500. Параллельно сделали репро, отправили issue, а до фикса держали форк с проверкой длины заголовка. Recover тут был не решением, а способом дожить до решения — и по метрике мы явно видели, сколько он ещё нужен.»

Суть: recover() читает _panic текущей горутины, а у новой горутины свой g со своей цепочкой defer — кадры родителя туда не входят. Непойманная паника в любой горутине роняет весь процесс. В пуле recover ставят на уровне одной задачи, а не воркера.

Механика

panic подвешивает runtime._panic к дескриптору текущей горутины, а раскрутка идёт по её списку gp._defer. У горутины, созданной через go f(), всё своё: стек, список отложенных вызовов, список паник. Никакой связи с создателем нет: go не образует иерархии, и о родстве напоминает только строчка created by ... в трейсбеке. Поэтому defer/recover в хендлере физически не может увидеть панику фоновой горутины.

Второй момент: в Go нет изоляции по горутинам. В Java непойманное исключение убивает один поток, остальные живут; в Go непойманная паника вызывает fatalpanic и завершает процесс с кодом 2. Причём хендлер к этому моменту мог уже успешно ответить 200, и в логах балансировщика это выглядит как внезапная пачка 502 без причины.

Что делать

// Единственная точка запуска горутин. На ревью правило:
// голый `go` разрешён только внутри этого файла.
func Go(ctx context.Context, log *slog.Logger, name string, fn func(context.Context)) {
    go func() {
        defer func() {
            rec := recover()
            if rec == nil {
                return
            }
            log.ErrorContext(ctx, "panic in goroutine",
                slog.String("goroutine", name),
                slog.Any("panic", rec),
                slog.String("stack", string(debug.Stack())))
            goroutinePanics.WithLabelValues(name).Inc()
        }()
        fn(ctx)
    }()
}

Ни errgroup.Group.Go, ни появившийся в Go 1.25 sync.WaitGroup.Go панику не перехватывают, у них другая задача (учёт горутин и первая ошибка). Из популярных библиотек донести панику воркера до ожидающего умеет sourcegraph/conc: она ловит её со стеком и перебрасывает в горутину, вызвавшую Wait().

Воркер пула: где именно ставить recover

На этом валятся даже те, кто помнит про safeGo. Если recover стоит в самой функции воркера, то после первой же паники воркер завершится — и пул тихо усохнет с 16 горутин до нуля, формально продолжая «работать». Очередь растёт, ошибок нет, инцидент находят через сутки. Поэтому recover ставят в функцию, которая обрабатывает одну задачу: паника съедает задачу, а не воркер.

func (p *Pool) worker(ctx context.Context, id int) {
    for job := range p.jobs {
        p.runOne(ctx, id, job)     // воркер переживает любую задачу
    }
}

func (p *Pool) runOne(ctx context.Context, workerID int, job Job) {
    defer func() {
        rec := recover()
        if rec == nil {
            return
        }
        p.log.ErrorContext(ctx, "panic while processing job",
            slog.String("job_id", job.ID), slog.Int("worker", workerID),
            slog.Any("panic", rec), slog.String("stack", string(debug.Stack())))
        jobPanics.WithLabelValues(job.Type).Inc()

        // Судьбу задачи выбираем явно: Nack без requeue -> DLQ.
        // Возвращать в очередь нельзя: паника детерминирована, будет вечный цикл.
        job.Fail(ctx, fmt.Errorf("panic: %v", rec))
    }()
    job.Run(ctx)
}
Poison message

Классический инцидент на at-least-once: воркер паникует на «ядовитом» сообщении, офсет не коммитится, сообщение приходит снова. Без recover падает и перезапускается весь консьюмер по кругу; с recover крутится один и тот же офсет и жжёт CPU. Лечится только счётчиком доставок и DLQ: после N попыток сообщение уходит в отдельный топик, а обработка идёт дальше. Плюс нужна метрика на размер DLQ, иначе туда молча утечёт бизнес.

Суть: panic идёт через runtime.gopanic, раскручивает стек, выполняет defer и может быть перехвачена. Fatal error печатает рантайм напрямую через throw: раскрутки нет, defer не выполняются, recover не сработает никогда.

Обычные паники рантайма

ПричинаСообщение
Запись в nil mappanic: assignment to entry in nil map
Индекс за границейpanic: runtime error: index out of range [5] with length 3
Срез за границейpanic: runtime error: slice bounds out of range [:7] with capacity 5
Разыменование nilpanic: runtime error: invalid memory address or nil pointer dereference
Деление на нольpanic: runtime error: integer divide by zero
Type assertionpanic: interface conversion: interface {} is string, not int
Каналыpanic: close of closed channel, close of nil channel, send on closed channel
WaitGrouppanic: sync: negative WaitGroup counter

Fatal error — не перехватывается

  • fatal error: concurrent map writes и concurrent map read and map write
  • fatal error: all goroutines are asleep - deadlock!
  • fatal error: sync: unlock of unlocked mutex
  • fatal error: stack overflow (на 64 битах стек горутины ограничен 1 ГБ)
  • fatal error: out of memory

Так сделано намеренно: перечисленные состояния означают, что инварианты рантайма или памяти уже могли быть нарушены. Продолжать работу опаснее, чем упасть, поэтому рантайм вызывает throw, а не gopanic, и механизм _panic в этом вообще не участвует. Сюда же примыкает os.Exit: он завершает процесс немедленно, defer тоже не выполняются, только это не ошибка, а явное намерение.

Полезные мелочи, которыми добивают

  • Чтение из nil map легально и отдаёт нулевое значение; len, range, delete тоже работают. Паникует только запись.
  • Чтение и запись в nil-канал не паникуют, а блокируются навсегда. Этим пользуются, чтобы «выключить» ветку select, обнулив канал.
  • Чтение из закрытого канала легально: нулевое значение и ok == false. Паникует только запись и повторное закрытие.
  • Форма с ok не паникует никогда: v, ok := x.(T), v, ok := m[k], v, ok := <-ch.
  • Float на ноль не паникует, а даёт +Inf или NaN. Паникует только целочисленное деление и остаток.
  • Метка [recovered] в выводе означает, что панику поймали, но во время того же defer случилась новая. Частый источник — паника внутри самого логгера в recover-middleware.
  • Проверка на конкурентную запись map вероятностная: рантайм смотрит флаг hashWriting и может гонку не заметить. Гарантированно ловит только -race. В Go 1.24 map переехали на Swiss Tables, но проверка осталась ровно та же.

4.3Паттерны надёжности

Здесь заканчивается язык и начинается инженерия. Retry, backoff, circuit breaker, таймауты, деградация — все паттерны этой главы нужны, чтобы отказ одной зависимости не превращался в отказ всей системы. Назвать их легко, настроить правильно трудно. При неаккуратной настройке почти каждый делает ровно наоборот и добивает то, что чинил.

Сначала — семь слов, которые дальше идут без перевода

Зависимость сломалась или затормозила. Что должен сделать твой сервис, чтобы не упасть следом и заодно не добить её окончательно? Ответ собирается из нескольких приёмов, и почти все они называются английскими словами, которые в русской речи так и не перевели. Договоримся о них сразу: дальше они идут на каждой странице без пояснений.

1. Транзиентная ошибка — та, которая может пройти сама

Транзиентной (transient: «преходящая», «временная») называют ошибку, причина которой лежит снаружи и меняется со временем: сеть моргнула, соединение оборвали, сервер перегружен и ответил 503, база поймала deadlock. Повтори ту же операцию через секунду — и она вполне может пройти. На другом полюсе стоит детерминированная ошибка: невалидный JSON, нет прав, нет такого пользователя. Сколько её ни повторяй, ответ будет тот же.

Отсюда первое правило: ретраить (повторять) имеет смысл только транзиентные ошибки. Повторяя детерминированную, ты гарантированно впустую тратишь свои и чужие ресурсы.

2. Идемпотентность — повтор не меняет результат

Операция идемпотентна, если состояние системы не зависит от того, выполнил ты её один раз или пять раз подряд. «Поставить статус = banned» идемпотентно: сколько ни ставь, статус один. А вот «списать 100 рублей» — нет, пять повторов спишут 500.

Критерий строгий: операция не меняет состояние второй раз. Если она всего лишь не падает при повторе, этого мало. Причём это свойство самой операции, а не кода, который её повторяет. Ретрай безопасен только для идемпотентной операции, потому что таймаут не говорит «не сработало», он говорит «я не знаю». Запрос мог дойти, выполниться и потеряться уже на обратном пути.

3. Backoff — растущая пауза между попытками

При backoff (буквально «отступить, сдать назад») клиент ждёт перед следующей попыткой, и пауза растёт с каждым разом. Обычно рост экспоненциальный: 100 мс, 200, 400, 800 — и так до потолка. Упавшую зависимость не долбят в полную силу, ей дают время подняться. Ретрай без backoff, сразу и в лоб, мгновенно удваивает нагрузку на то, что и так не справляется.

4. Jitter — случайный разброс этих пауз

Jitter («дрожание», слово пришло из электроники) подмешивает к паузе backoff случайность: вместо ровно 400 мс ждём случайное время где-то от нуля до 400 мс. Backoff разносит по времени попытки одного клиента, а клиентов между собой никак не разводит. Тысяча клиентов получила отказ в одну и ту же секунду и поставила одинаковые таймеры — через 400 мс на зависимость придёт ровно тысяча одновременных запросов и снова её уронит. Jitter размазывает эту волну.

5. Bulkhead — отдельная квота ресурсов на каждую зависимость

Bulkhead переводится как «переборка», глухая перегородка между отсеками корабля: пробили один отсек — затопило только его, а не весь корпус. В коде это лимит на число одновременных вызовов, свой для каждой зависимости: платёжный API получает не больше 50 горутин, поиск не больше 200. Тогда одна медленная зависимость не съест все горутины и соединения сервиса и не утащит за собой всё остальное, что вообще-то работает.

6. Load shedding — сознательный отказ части запросов при перегрузке

Load shedding, «сброс нагрузки», пришёл из энергетики: там так называют веерные отключения. Идея контринтуитивная: если входящих запросов больше, чем сервис способен переварить, правильнее честно отказать части из них сразу, чем принять все и всех обслужить плохо. Приняв всё, ты получишь растущую очередь, растущие задержки и в итоге таймаут у всех — то есть 100 % отказов вместо 10 %. И отказывать надо быстро: 429 или 503 в первую миллисекунду, а не после тридцати секунд ожидания в очереди.

7. Circuit breaker — предохранитель, который сам отключает вызовы к упавшей зависимости

Circuit breaker дословно значит «автоматический выключатель», тот самый автомат в электрощитке: пошёл ток выше нормы — он щёлкнул и разорвал цепь, чтобы не сгорела проводка. По-русски говорят «брейкер» или «предохранитель», реже «размыкатель цепи».

В коде это обёртка вокруг вызова зависимости, которая считает отказы. Пока всё хорошо, она просто пропускает вызовы насквозь. Когда доля отказов переваливает порог, она перестаёт пускать вызовы вообще и на какое-то время отвечает ошибкой мгновенно, никуда не ходя. Потом пробует пропустить один-два пробных запроса: ответили нормально — трафик возвращается, снова отказ — пауза продлевается.

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

Как эти семь связаны между собой

Транзиентность и идемпотентность задают условия допуска: можно ли вообще повторять. Backoff и jitter определяют, как повторять, чтобы повтор помогал, а не добивал. Circuit breaker подсказывает, когда перестать повторять совсем, и следит при этом не за одним запросом, а за потоком. Bulkhead и load shedding заходят с другой стороны и защищают тебя от чужой медленности и от собственной перегрузки.

Ретраить или нет: классификация ошибок

Ретраить стоит, только когда одновременно выполнены два условия: ошибка транзиентная (повтор той же операции может дать другой результат) и операция идемпотентна (повтор безопасен, даже если первая попытка на самом деле прошла, а ответ потерялся). Нарушишь любое — и ретрай станет либо бесполезным, либо вредным.

Коварнее всего таймаут. Запрос мог дойти и выполниться, а ответ застрять на обратном пути, и клиент этого не узнает. Ретрай после таймаута и есть тот случай, ради которого нужна идемпотентность.

Ретраить или нет — порядок проверок вызов вернул ошибку 1. Контекст ещё жив? ctx.Err() == nil и до дедлайна осталось больше, чем длится попытка нет СТОП вернуть ctx.Err(), в сеть не ходить да 2. Ошибка транзиентная? timeout, connection reset, 502/503/504, 429, gRPC Unavailable, Postgres 40001/40P01, «нет свободных соединений» нет СТОП 400/401/403/404/409/422 — повтор даст то же да 3. Операция идемпотентна? GET/HEAD/PUT/DELETE, SELECT, upsert по ключу, POST с Idempotency-Key нет СТОП до появления ключа повтор спишет деньги дважды — сначала ключ идемпотентности да 4. Бюджет ретраев не исчерпан? attempt < N, суммарное время < бюджета запроса, circuit breaker не в состоянии Open нет СТОП отдать ошибку или уйти в fallback да РЕТРАИТЬ пауза = Retry-After, если сервер его прислал, иначе rand(0, min(cap, base * 2^attempt))
Дерево решений. Проверки идут именно в этом порядке: сначала бюджет времени, потом природа ошибки, потом безопасность повтора, и только затем счётчик попыток. На любом «нет» выходим, к следующей проверке не переходим.

Что ретраить: конкретика по транспортам

СлойРетраитьНе ретраить
HTTP 408, 425, 429 (с уважением Retry-After), 500 (осторожно, 1–2 попытки), 502, 503, 504 400, 401, 403, 404, 405, 409, 410, 422, 501 — детерминированные, повтор даст то же самое
Сеть / syscall ECONNREFUSED, ECONNRESET, EPIPE, ETIMEDOUT, io.ErrUnexpectedEOF, net.Error с Timeout() == true TLS-ошибки (x509: certificate signed by unknown authority), *net.DNSError с IsNotFound — это конфигурация, а не сбой
gRPC Unavailable (14), ResourceExhausted (8), Aborted (10 — конфликт транзакции), DeadlineExceeded (4, только если остался бюджет) InvalidArgument (3), NotFound (5), AlreadyExists (6), PermissionDenied (7), FailedPrecondition (9), Unimplemented (12), Unauthenticated (16)
PostgreSQL 40001 serialization_failure, 40P01 deadlock_detected, 55P03 lock_not_available, 53300 too_many_connections, 08006/08003 обрыв соединения 23505 unique_violation, 23503 foreign_key_violation, 22P02 invalid_text_representation, 42601 syntax_error
Kafka NotLeaderForPartition, LeaderNotAvailable, RequestTimedOut, NotEnoughReplicas — брокер сам помечает ошибки как retriable RecordTooLarge, InvalidTopic, TopicAuthorizationFailed
Три ловушки классификации
  • 23505 unique_violation после ретрая часто означает не «дубль», а «предыдущая попытка на самом деле прошла». Если у операции есть естественный ключ, такую ошибку надо считать успехом, а не отказом.
  • context.DeadlineExceeded бывает двух видов: истёк твой таймаут на конкретный вызов (можно ретраить, если общий бюджет остался) или истёк дедлайн всего запроса (ретраить нельзя — клиент уже ушёл). Различают их по ctx.Err() родительского контекста, а не по тексту ошибки.
  • С 500 однозначного ответа нет. Если это NullPointerException в чужом сервисе, ретрай ничего не даст, а нагрузку удвоит. Разумный компромисс: одна повторная попытка и обязательный circuit breaker, который отсечёт серию.

Идемпотентность — фундамент, без которого ретрай опасен

Определение из начала главы: идемпотентная операция не меняет состояние второй раз. Обеспечивать это свойство надо первым, и способов для этого пять.

ТипКак достигаетсяПримеры
Естественная Операция по своей природе присваивает, а не изменяет UPDATE users SET status='banned' WHERE id=42, DELETE ... WHERE id=, SET key value в Redis, любой GET
Через уникальный ключ Уникальный индекс + ON CONFLICT DO NOTHING: вторая вставка не делает ничего INSERT INTO payments(order_id, ...) VALUES(...) ON CONFLICT (order_id) DO NOTHING
Ключ идемпотентности Клиент присылает Idempotency-Key; сервер запоминает ключ вместе с ответом и на повтор возвращает сохранённый ответ, ничего не выполняя Stripe, YooKassa, любые платёжные API. Именно так делают неидемпотентный POST безопасным
Условное обновление Версия/ревизия в условии: повтор не пройдёт, потому что версия уже другая UPDATE ... SET v = v+1 WHERE id=? AND v=?, ETag + If-Match, CAS в etcd
Дедупликация на приёме Таблица обработанных message_id в той же транзакции, что и бизнес-эффект Inbox-паттерн для Kafka/RabbitMQ: at-least-once снаружи, ровно один эффект внутри
Осторожно с «идемпотентно по HTTP-спеке»

RFC 9110 объявляет GET, HEAD, PUT, DELETE, OPTIONS, TRACE идемпотентными. Это требование к разработчику, а не гарантия транспорта. PUT /counter, который внутри делает counter = counter + 1, идемпотентным не станет от того, что метод называется PUT. Проверять надо реализацию, а не глагол.

Ключ идемпотентности: рабочая схема

CREATE TABLE idempotency_keys (
    key          text        PRIMARY KEY,      -- то, что прислал клиент
    endpoint     text        NOT NULL,         -- один ключ на один эндпоинт
    request_hash bytea       NOT NULL,         -- защита от «тот же ключ, другое тело»
    state        text        NOT NULL,         -- in_progress | done
    status_code  int,
    response     jsonb,
    created_at   timestamptz NOT NULL DEFAULT now()
);
-- Ключи живут ограниченное время: сутки для платежей - разумный дефолт.
CREATE INDEX idempotency_keys_created_at_idx ON idempotency_keys (created_at);
func (s *Service) CreatePayment(ctx context.Context, key string, req Request) (Response, error) {
    hash := sha256.Sum256(req.Canonical())

    // 1. Атомарно пытаемся занять ключ. Гонку решает сама БД:
    //    выигрывает ровно один INSERT, остальным вернётся 0 строк.
    claimed, err := s.repo.ClaimKey(ctx, key, "POST /payments", hash[:])
    if err != nil {
        return Response{}, err
    }

    if !claimed {
        rec, err := s.repo.LoadKey(ctx, key)
        if err != nil {
            return Response{}, err
        }
        // 2. Тот же ключ с другим телом - это ошибка клиента, а не повтор.
        if !bytes.Equal(rec.RequestHash, hash[:]) {
            return Response{}, ErrIdempotencyKeyReuse      // 422
        }
        // 3. Первый запрос ещё в работе: не выполняем параллельно.
        //    Клиенту честно говорим «повтори позже».
        if rec.State == StateInProgress {
            return Response{}, ErrInProgress               // 409 + Retry-After: 1
        }
        return rec.Response, nil                           // 4. Отдаём готовый ответ
    }

    resp, err := s.charge(ctx, req)
    if err != nil {
        // Деньги точно не списаны — отпускаем ключ, и повтор пройдёт заново:
        // иначе ключ навсегда залипнет в in_progress. Исход неизвестен
        // (таймаут после отправки) — не отпускаем: повтор может списать
        // второй раз, такой ключ ждёт сверки с платёжкой.
        if !errors.Is(err, ErrOutcomeUnknown) {
            _ = s.repo.ReleaseKey(ctx, key)
        }
        return Response{}, err
    }

    // 5. Сохраняем ответ под ключом: повтор получит его, а не второе списание.
    if err := s.repo.CompleteKey(ctx, key, resp); err != nil {
        return Response{}, err
    }
    return resp, nil
}
Детали, которые отличают продуманную схему
  • Ключ генерирует клиент, а не сервер: только так повтор после потерянного ответа придёт с тем же ключом. Один UUIDv4 на попытку операции, а не на каждый HTTP-запрос.
  • Хеш тела обязателен. Иначе клиент, который по ошибке переиспользовал ключ, молча получит чужой ответ.
  • Состояние in_progress закрывает гонку двух одновременных повторов: второй не выполняет операцию, а получает 409.
  • Ключ и бизнес-эффект пишутся одной транзакцией, если эффект в той же базе. Иначе возможен разрыв: деньги списаны, ключ не сохранён, и повтор спишет снова. Когда эффект во внешнем API, как в коде выше, одной транзакции не выйдет: разрыв закрывает in_progress, а ключ с неизвестным исходом ждёт сверки.
  • TTL и чистка. Таблица растёт линейно вместе с трафиком и без ретеншена станет самой большой в базе. Партиции по дате и DROP PARTITION обходятся дешевле, чем DELETE.

Экспоненциальный backoff и jitter

Хуже всего ретраить сразу: зависимость упала под нагрузкой, а ты мгновенно удваиваешь ей нагрузку. Экспоненциальный backoff растягивает попытки: base * 2^attempt с потолком cap. Но одного backoff мало, потому что все клиенты падают одновременно — а значит, одновременно и просыпаются.

Retry storm (он же thundering herd) выглядит так. В 12:00:00 сервис зависимости отвечает 503 всей тысяче клиентов. Все ставят таймер на 1 секунду. В 12:00:01 приходит ровно тысяча одновременных запросов — сервис, который только начал вставать, снова ложится. Все ставят таймер на 2 секунды. В 12:00:03 — снова тысяча. Система входит в устойчивый автоколебательный режим и сама из него не выходит: каждая волна валит зависимость заново. Ретрай тут не помогает восстановиться, а не даёт восстановиться.

Без jitter: все клиенты синхронизированы отказом и просыпаются вместе ёмкость зависимости 0 с 1 с 2 с 4 с 8 с Каждая волна — вся популяция клиентов сразу. Зависимость не успевает встать: каждая волна валит её заново. Full jitter: sleep = rand(0, min(cap, base * 2^attempt)) ёмкость зависимости 0 с 2 с 4 с 6 с 8 с Та же суммарная работа размазана по окну. Пик падает в разы, зависимость встаёт, и клиенты дотягиваются без новой волны. Стоимость: средняя задержка отдельного клиента чуть выше. Цена, за которую покупается способность системы вообще восстановиться.
Зачем jitter. Backoff разносит попытки одного клиента, jitter разносит попытки разных клиентов. Без второго первый только меняет период колебаний, но не убирает их.

Четыре стратегии jitter

СтратегияФормулаКогда брать
Без jitter sleep = min(cap, base * 2^n) Только когда клиент один (миграции, CLI). В сервисе — источник retry storm
Full jitter sleep = rand(0, min(cap, base * 2^n)) Дефолт. По замерам AWS даёт наименьшую суммарную нагрузку и наименьшее число попыток. Разброс максимальный
Equal jitter t = min(cap, base * 2^n);
sleep = t/2 + rand(0, t/2)
Когда важно, чтобы попытка гарантированно не случилась «слишком рано» — например, известно время рестарта зависимости
Decorrelated jitter sleep = min(cap, rand(base, prev * 3)) Долгие серии попыток: растёт быстрее full jitter, но не залипает на потолке. Её предложили в блоге AWS о backoff; в самих AWS SDK по умолчанию full jitter
Настройки, которые не стыдно назвать

Внутренний RPC: base = 20..50 мс, cap = 1 с, 3 попытки (то есть 2 ретрая), full jitter. Внешний API: base = 200 мс, cap = 10 с, 4–5 попыток. Фоновая обработка без клиента на проводе: cap до минут и десятки попыток. Смотреть надо не на количество попыток, а на бюджет времени: все ретраи вместе не должны выходить за дедлайн запроса.

Retry, который уважает context и Retry-After

type Policy struct {
    MaxAttempts int           // всего попыток, включая первую
    Base        time.Duration // 100ms
    Cap         time.Duration // 5s
    Budget      time.Duration // потолок на всю серию; 0 - ограничивает только ctx
}

// Classifier отвечает на два вопроса сразу: повторять ли и сколько ждать,
// если сервер сам назвал паузу (Retry-After, gRPC RetryInfo).
type Classifier func(error) (retry bool, after time.Duration)

func Do(ctx context.Context, p Policy, cls Classifier, op func(context.Context) error) error {
    if p.MaxAttempts <= 0 { p.MaxAttempts = 3 }
    if p.Base <= 0        { p.Base = 100 * time.Millisecond }
    if p.Cap <= 0         { p.Cap = 5 * time.Second }

    if p.Budget > 0 {
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, p.Budget)
        defer cancel()
    }

    var last error
    for attempt := 0; attempt < p.MaxAttempts; attempt++ {
        // Не ходим в сеть с уже истёкшим дедлайном: это чистая трата
        // соединения и лишняя нагрузка на зависимость.
        if err := ctx.Err(); err != nil {
            return errors.Join(last, err)
        }

        last = op(ctx)
        if last == nil {
            return nil
        }

        // Отмена самого контекста - не транзиентная ошибка. Клиент ушёл.
        if ctx.Err() != nil {
            return errors.Join(last, ctx.Err())
        }

        retry, after := cls(last)
        if !retry || attempt == p.MaxAttempts-1 {
            return last
        }

        delay := after                                  // сервер назвал паузу - уважаем
        if delay <= 0 {
            delay = fullJitter(p.Base, p.Cap, attempt)
        }

        // Спать дольше, чем осталось до дедлайна, бессмысленно:
        // лучше вернуть ошибку сразу, чем разбудить таймер в пустоту.
        if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= delay {
            return errors.Join(last, context.DeadlineExceeded)
        }

        t := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            t.Stop()                                    // освобождаем таймер сразу, не ждём GC
            return errors.Join(last, ctx.Err())
        case <-t.C:
        }
    }
    return last
}

// math/rand/v2 (Go 1.22+): глобальный генератор без блокировок и без ручного Seed.
func fullJitter(base, cap time.Duration, attempt int) time.Duration {
    backoff := base << attempt              // base * 2^attempt
    if backoff > cap || backoff <= 0 {      // <=0 - защита от переполнения int64
        backoff = cap
    }
    return time.Duration(rand.Int64N(int64(backoff)) + 1)
}
// Классификатор для HTTP. Заодно решает вопрос с Retry-After.
func HTTPClassifier(err error) (bool, time.Duration) {
    if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
        return false, 0
    }

    var he *HTTPError
    if errors.As(err, &he) {
        switch he.Code {
        case 408, 425, 429, 500, 502, 503, 504:
            return true, he.RetryAfter          // распарсен из заголовка
        default:
            return false, 0
        }
    }

    var ne net.Error
    if errors.As(err, &ne) && ne.Timeout() {
        return true, 0
    }
    return errors.Is(err, syscall.ECONNRESET) ||
        errors.Is(err, syscall.ECONNREFUSED) ||
        errors.Is(err, io.ErrUnexpectedEOF), 0
}

func parseRetryAfter(h http.Header) time.Duration {
    v := h.Get("Retry-After")
    if v == "" {
        return 0
    }
    if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
        return time.Duration(secs) * time.Second
    }
    if t, err := http.ParseTime(v); err == nil {      // HTTP-date - тоже валидная форма
        if d := time.Until(t); d > 0 {
            return d
        }
    }
    return 0
}
Две вещи, которые ломают ретрай HTTP-запроса

Первое: тело ответа. Перед повтором его надо дочитать и закрыть (io.Copy(io.Discard, resp.Body); resp.Body.Close()), иначе соединение не вернётся в пул keep-alive — и на серии ретраев ты откроешь тысячи сокетов и упрёшься в too many open files. Второе: тело запроса. Если оно io.Reader, второй раз его не прочитать: Read уже вернул EOF. Нужен либо буфер в памяти, либо Request.GetBody (его http.NewRequest заполняет автоматически для bytes.Reader, strings.Reader и bytes.Buffer).

Ретрай на одном слое, а не на всех

Если ретраит клиент (3), потом сервис-прокси (3), потом бэкенд (3) — на дальнюю зависимость прилетит 27 запросов вместо одного. Это retry amplification, и он убивает системы надёжнее, чем исходный сбой. Правило: ретраит один слой, обычно самый близкий к зависимости; остальные пробрасывают ошибку. Сверху страхует retry budget в духе gRPC: ретраи разрешены, только пока их доля не превышает 10–20% от общего трафика на эту зависимость, а при превышении выключаются целиком.

Circuit breaker

Ретраи с backoff вытаскивают один запрос. Circuit breaker имеет дело с потоком запросов к упавшей зависимости. Пока сервис лежит, каждый вызов к нему занимает горутину и соединение из пула на весь таймаут. Тысяча запросов в секунду × 3 секунды таймаута дают три тысячи горутин, висящих в ожидании, и исчерпанный пул соединений — после чего перестают проходить вызовы и к здоровым зависимостям. Так локальный отказ становится общим.

Брейкер даёт две вещи. Вызывающий получает fail fast: ошибку за 0,1 мс вместо таймаута на 3 секунды, ресурсы свободны, можно сразу уходить в fallback. Зависимость получает передышку: трафик срезан почти в ноль, и у неё появляется шанс встать. Поэтому брейкер помогает там, где голый retry делает только хуже.

Конечный автомат circuit breaker CLOSED вызовы идут к зависимости считаем успехи и отказы OPEN вызовов нет вообще сразу ErrOpenState, 0 мс HALF-OPEN пропускаем K пробных остальным — отказ 1 2 4 3 Переходы и пороги 1 - CLOSED в OPEN: сработал порог. Либо N отказов подряд (просто, но шумит), либо доля отказов в скользящем окне выше 50% при минимум 20 запросах — второе устойчивее, потому что не открывается от трёх неудач на пустом трафике. 2 - OPEN в HALF-OPEN: истёк cooldown (5–60 с). Переход ленивый: его делает первый же запрос, пришедший после таймаута. 3 - HALF-OPEN в CLOSED: прошло K успешных пробных подряд (обычно 1–5). Счётчики обнуляются, трафик восстановлен полностью. 4 - HALF-OPEN в OPEN: упал хотя бы один пробный. Cooldown часто удваивают до потолка, чтобы не долбить лежащую зависимость.
Три состояния. Вся тонкость в HALF-OPEN: он пропускает ограниченное число пробных запросов одновременно и не открывает шлюз целиком. Иначе восстановление само превратится в retry storm.

Разбор sony/gobreaker

// gobreaker v2 - дженерик по типу результата.
var billingCB = gobreaker.NewCircuitBreaker[*Receipt](gobreaker.Settings[*Receipt]{
    Name: "billing",

    // Сколько пробных запросов пропустить в half-open одновременно.
    // 0 означает 1. Это и есть ограничение пробного трафика.
    MaxRequests: 3,

    // Как часто обнулять счётчики, пока состояние CLOSED.
    // Без этого одиночные отказы за час накопятся и откроют брейкер на ровном месте.
    Interval: 60 * time.Second,

    // Сколько держать OPEN до перехода в HALF-OPEN.
    Timeout: 30 * time.Second,

    ReadyToTrip: func(c gobreaker.Counts) bool {
        // Порог по доле, а не по числу: защищает от ложных срабатываний
        // на низком трафике, когда 3 отказа - это статистический шум.
        return c.Requests >= 20 &&
            float64(c.TotalFailures)/float64(c.Requests) >= 0.5
    },

    // Главная настройка, и её чаще всего забывают.
    IsSuccessful: func(err error) bool {
        if err == nil {
            return true
        }
        // Клиент ушёл - это не отказ зависимости.
        if errors.Is(err, context.Canceled) {
            return true
        }
        // 4xx, кроме 429, - вина запроса, а не сервиса. Иначе один бот с кривыми
        // запросами откроет брейкер всем остальным. 429 - зависимость сама
        // просит отступить, это отказ.
        var he *HTTPError
        if errors.As(err, &he) && he.Code < 500 && he.Code != 429 {
            return true
        }
        return false
    },

    OnStateChange: func(name string, from, to gobreaker.State) {
        log.Warn("circuit breaker state changed",
            "name", name, "from", from.String(), "to", to.String())
        breakerState.WithLabelValues(name).Set(float64(to))
    },
})

func chargeWithBreaker(ctx context.Context, req Request) (*Receipt, error) {
    r, err := billingCB.Execute(func() (*Receipt, error) {
        return billing.Charge(ctx, req)
    })
    if errors.Is(err, gobreaker.ErrOpenState) ||
        errors.Is(err, gobreaker.ErrTooManyRequests) {
        // Брейкер отказал мгновенно - самое время для fallback,
        // а не для ретрая: ретрай получит ровно тот же отказ.
        return nil, ErrBillingUnavailable
    }
    return r, err
}

Минимальная своя реализация — чтобы понимать, что внутри

type State int

const (
    Closed State = iota
    Open
    HalfOpen
)

var (
    ErrOpen      = errors.New("circuit breaker is open")
    ErrTooManyProbes = errors.New("circuit breaker: probe limit reached")
)

type Breaker struct {
    mu        sync.Mutex
    state     State
    fails     int
    successes int
    openedAt  time.Time
    probes    int           // пробных сейчас в полёте

    threshold int           // отказов подряд до открытия
    cooldown  time.Duration // сколько держать Open
    maxProbes int           // сколько пробных пускать в HalfOpen
    needSucc  int           // успехов подряд для закрытия
}

// allow решает, пускать ли вызов, и переводит состояние по времени.
func (b *Breaker) allow() error {
    b.mu.Lock()
    defer b.mu.Unlock()

    switch b.state {
    case Closed:
        return nil

    case Open:
        if time.Since(b.openedAt) < b.cooldown {
            return ErrOpen
        }
        // Переход ленивый: его делает первый запрос после cooldown.
        b.state, b.probes, b.successes = HalfOpen, 0, 0
        fallthrough

    case HalfOpen:
        if b.probes >= b.maxProbes {
            return ErrTooManyProbes      // пробный трафик ограничен
        }
        b.probes++
        return nil
    }
    return nil
}

func (b *Breaker) report(err error, success bool) {
    b.mu.Lock()
    defer b.mu.Unlock()

    switch b.state {
    case Closed:
        if success {
            b.fails = 0
            return
        }
        b.fails++
        if b.fails >= b.threshold {
            b.state, b.openedAt = Open, time.Now()
        }

    case HalfOpen:
        b.probes--
        if !success {
            // Один упавший пробный возвращает нас в Open.
            b.state, b.openedAt, b.successes = Open, time.Now(), 0
            return
        }
        b.successes++
        if b.successes >= b.needSucc {
            b.state, b.fails, b.successes = Closed, 0, 0
        }
    }
}

func (b *Breaker) Do(ctx context.Context, fn func(context.Context) error) error {
    if err := b.allow(); err != nil {
        return err
    }
    err := fn(ctx)
    // context.Canceled - уход клиента, а не отказ зависимости.
    b.report(err, err == nil || errors.Is(err, context.Canceled))
    return err
}
Четыре способа сделать брейкер вредным
  • Считать отказом 4xx. Один клиент с кривыми запросами открывает брейкер всем остальным. Отказом считаются 5xx, 429, таймауты и сетевые ошибки.
  • Один брейкер на всё приложение. Заводить его нужно на пару «зависимость + операция», а лучше ещё и на инстанс/шард. Медленный поиск не должен рубить быстрый профиль того же сервиса.
  • Порог по абсолютному числу отказов на низком трафике. Ночью три запроса в минуту, три упали — брейкер открыт на полчаса. Нужен минимум запросов в окне.
  • HALF-OPEN без ограничения. Если после cooldown пропустить весь трафик, зависимость ляжет ровно в момент восстановления. Пробных должно быть единицы.
Сценарий, который просят рассказать

«Внешний платёжный шлюз начал отвечать 504 с латентностью 30 с. Без брейкера: наши 200 RPS к нему висят по 30 секунд — это 6000 занятых горутин и исчерпанный пул HTTP-соединений; через минуту деградируют и эндпоинты, которые в платёжку не ходят, потому что заняты воркеры. С брейкером: за 20 запросов доля отказов переваливает 50%, брейкер открывается, все последующие вызовы падают за микросекунды, ресурсы свободны. Пользователь видит честное оплата временно недоступна вместо тридцатисекундного спиннера, остальной сайт работает. Каждые 30 секунд уходят 3 пробных запроса; как только шлюз ответил трижды подряд — трафик восстанавливается сам, без нашего вмешательства.»

Таймауты и бюджет запроса

Правило без исключений: у каждого вызова, который уходит за пределы процесса, должен быть таймаут. Сеть, БД, кэш, файловая система на NFS, вызов внешнего API — всё. Страшно не то, что вызов «зависнет надолго», а то, что всё это время он держит ресурсы: горутину, соединение из пула, память запроса, слот в семафоре. При 1000 RPS хватит временной деградации зависимости, чтобы вызов без таймаута за считанные секунды исчерпал пул соединений. Дальше падает всё, включая то, что к этой зависимости отношения не имеет.

Дефолты, о которых надо помнить

У http.Client{} из коробки таймаута нет вообще: ноль означает «ждать бесконечно». У http.Server{} то же самое. Без ReadHeaderTimeout сервер уязвим к Slowloris: соединение, которое шлёт заголовки по байту в минуту, держится вечно. У net.Dialer по умолчанию только системный таймаут TCP (на Linux по умолчанию больше двух минут). И отдельный сюрприз: Transport.MaxIdleConnsPerHost по умолчанию 2, из-за чего под нагрузкой соединения постоянно пересоздаются, а не переиспользуются.

Бюджет запроса 3000 мс, разложенный по слоям 0 500 1000 1500 2000 2500 3000 мс ctx запроса бюджет всего запроса: дальше этой границы не живёт никто auth.Check таймаут 300 мс — быстрая проверка токена в Redis db.GetOrder таймаут 500 мс, продублирован в statement_timeout на стороне Postgres billing.Charge попытка 1 попытка 2 попытка 3 резерв Пунктирные вставки между попытками — паузы backoff. Резерв 100 мс в конце — сериализация ответа и запись в сокет. Как считать бюджет Верхнюю границу задаёт ingress. Каждый слой берёт строго меньше, чем ему осталось: want = min(свой таймаут, дедлайн - now - резерв). context.WithTimeout не добавляет времени: если дедлайн родителя ближе, побеждает он. Глубокий вызов не может пережить запрос. Сумма по ветке - таймаут на попытку, умноженный на число попыток, плюс паузы backoff - обязана влезать в бюджет целиком. Дедлайн едет по сети: gRPC кладёт его в grpc-timeout сам, в HTTP это делают руками - например, x-envoy-expected-rq-timeout-ms.
Каскад таймаутов. Три вызова и три попытки платежа помещаются в бюджет только потому, что каждый слой знает, сколько времени у него осталось. Стоит одному поставить фиксированные «3 секунды на всякий случай» — и внешний дедлайн срежет запрос на середине, а сделанная работа пропадёт.
// Клиент: таймауты на каждой фазе, а не один общий.
tr := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   2 * time.Second,     // установка TCP-соединения
        KeepAlive: 30 * time.Second,
    }).DialContext,
    TLSHandshakeTimeout:   2 * time.Second,
    ResponseHeaderTimeout: 3 * time.Second,   // ждём первый байт заголовков ответа
    ExpectContinueTimeout: 1 * time.Second,
    IdleConnTimeout:       90 * time.Second,
    MaxIdleConns:          100,
    MaxIdleConnsPerHost:   100,               // дефолт 2 - главное узкое место
    MaxConnsPerHost:       200,               // потолок параллелизма = грубый bulkhead
}
client := &http.Client{
    Transport: tr,
    Timeout:   5 * time.Second,   // весь цикл, включая чтение тела
}

// Сервер: без этих четырёх полей сервис уязвим к медленным клиентам.
srv := &http.Server{
    Addr:              ":8080",
    ReadHeaderTimeout: 5 * time.Second,    // защита от Slowloris
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      20 * time.Second,
    IdleTimeout:       60 * time.Second,
    Handler:           http.TimeoutHandler(mux, 10*time.Second, `{"error":"timeout"}`),
}
// Слой берёт себе меньше, чем ему осталось, и оставляет резерв
// на обработку результата и запись ответа.
func childCtx(ctx context.Context, want, reserve time.Duration) (context.Context, context.CancelFunc) {
    dl, ok := ctx.Deadline()
    if !ok {
        return context.WithTimeout(ctx, want)      // бюджета сверху нет - берём свой
    }

    left := time.Until(dl) - reserve
    if left <= 0 {
        // Времени не осталось: идти в сеть бессмысленно, только зря
        // займём соединение у зависимости, которой и так тяжело.
        cancelled, cancel := context.WithDeadline(ctx, time.Now())
        return cancelled, cancel
    }
    if left < want {
        want = left
    }
    return context.WithTimeout(ctx, want)
}

// Использование: у платежа свой потолок 1800 мс, но если запросу
// осталось 600 - возьмём 600 минус резерв.
ctxPay, cancel := childCtx(ctx, 1800*time.Millisecond, 100*time.Millisecond)
defer cancel()
receipt, err := billing.Charge(ctxPay, req)
Как выбирать число, а не брать «30 секунд»

Таймаут ставят исходя из наблюдаемой латентности: примерно p99.9 нормального времени ответа плюс запас. Перцентиль отвечает на вопрос «в какое время укладывается такая-то доля запросов»: p99.9 = 300 мс означает, что 99,9 % запросов ответили быстрее 300 мс и только один из тысячи — медленнее. Среднее для таймаута не годится: его перекашивают как раз те редкие медленные запросы, ради которых таймаут и ставится. Слишком большой таймаут ни от чего не защищает (вызов всё равно держит ресурсы). Со слишком маленьким ты сам себе генерируешь отказы и ретраи там, где зависимость просто чуть медленнее обычного. Дальше проверяй арифметику: если у запроса бюджет 3 с, а внутри стоит вызов с таймаутом 5 с и тремя ретраями — таймаут вызова не сработает никогда. Первым всегда сработает внешний дедлайн, и все внутренние настройки теряют смысл.

Дублирование таймаута на стороне сервера

При отмене контекста драйвер Postgres шлёт серверу CancelRequest, то есть открывает отдельное соединение с запросом на отмену. Это работает, но не мгновенно и не гарантированно: если сеть до БД деградировала, отмена может не дойти, и тяжёлый запрос продолжит жечь CPU базы, хотя клиент уже ушёл. Поэтому клиентский таймаут дублируют серверным: SET LOCAL statement_timeout = '500ms' в транзакции или statement_timeout на уровне роли. То же с idle_in_transaction_session_timeout: он ловит транзакции, брошенные упавшим приложением.

Graceful degradation и fallback

Деградация нужна, когда зависимость всё-таки недоступна, а ответить надо. Но допустима она только для некритичных зависимостей. Вместо рекомендаций разумно отдать топ популярного. С проверкой баланса перед списанием так нельзя: там корректен только отказ.

УровеньЧто делаемКогда подходит
1. Протухший кэш Отдаём последнее известное значение, помечая ответ как stale (stale-while-revalidate) Данные меняются медленно: каталог, курсы, настройки, фичефлаги
2. Дефолт Заранее посчитанная константа или общий вариант Рекомендации, персональные баннеры, ранжирование — «неперсонально» лучше, чем «пусто»
3. Урезанный ответ 200 без части полей + признак partial: true и метрика Композитный ответ, где отвалился один из источников: карточка товара без блока «с этим покупают»
4. Отложить Положить в очередь/outbox, ответить 202 Accepted Операция допускает асинхронность: отправка письма, начисление бонусов, индексация
5. Read-only Запись отключена, чтение работает с реплики Отказ мастера БД. Лучше, чем полный 503 на весь сервис
6. Честный отказ Явная ошибка с понятным текстом и Retry-After Деньги, права доступа, всё, где выдумать ответ означает соврать
func (s *Service) Recommendations(ctx context.Context, userID int64) (Recs, bool) {
    // Некритичная зависимость - жёсткий короткий таймаут,
    // чтобы она не съедала бюджет основного ответа.
    ctx, cancel := childCtx(ctx, 150*time.Millisecond, 20*time.Millisecond)
    defer cancel()

    recs, err := s.mlCB.Do(ctx, func(ctx context.Context) (Recs, error) {
        return s.ml.Get(ctx, userID)
    })
    if err == nil {
        s.cache.Set(userID, recs, 10*time.Minute)
        return recs, false
    }

    // Каждая деградация обязана быть видна: без метрики сервис будет
    // месяцами отдавать дефолты, и никто об этом не узнает.
    degraded.WithLabelValues("recs", reasonOf(err)).Inc()
    s.log.WarnContext(ctx, "recommendations degraded", "err", err)

    if stale, ok := s.cache.GetStale(userID); ok {   // допускаем протухшее
        return stale, true
    }
    return s.topPopular(), true                       // последний рубеж
}
Правила деградации
  • Деградировать или нет, решает продукт, а не код на месте. Для каждой зависимости заранее записано: критичная или нет и что отдавать при отказе.
  • Деградация должна быть видимой: метрика, поле в ответе, заголовок Warning. Иначе она превращается в тихую поломку.
  • Некритичной зависимости нужны свой короткий таймаут и свой брейкер. Блок рекомендаций не имеет права занимать 3 секунды бюджета основного ответа.
  • Fallback тоже надо тестировать. Иначе в момент инцидента выяснится, что путь деградации сам падает. Классика: в обычной жизни этот код не выполняется никогда. Лечится chaos-тестами и явными тестами на путь с отказом.
  • Кэш для деградации и кэш для скорости держат раздельно. У первого TTL длинный и можно отдавать просроченное, у второго TTL короткий.

Fail-fast vs fault-tolerant

Fail-fast и fault-tolerance живут на разных уровнях системы и друг другу не противоречат. Внутри одной операции почти всегда правильнее fail-fast: не тянуть, а быстро освободить ресурсы. На уровне системы нужна fault-tolerance: отказ одного компонента не должен ронять целое. Вопрос закрывает одна формулировка: «fail-fast на уровне вызова, fault-tolerance на уровне архитектуры».

СитуацияПодходПочему
Невалидная конфигурация при стартеfail-fast Упасть на деплое дешевле, чем работать неправильно. Заодно откат сделает сам оркестратор
Деньги, права, целостность данныхfail-fast Выдуманный ответ хуже отказа. «Наверное, оплачено» — это инцидент, а не деградация
Детерминированная ошибка (400, невалидный вход)fail-fast Повтор даст тот же результат: ретрай только жжёт ресурсы
Зависимость недоступна, есть разумный дефолтfault-tolerant Частичный ответ полезнее пустого. Пользователь чаще всего разницы не заметит
Транзиентная сетевая ошибкаfault-tolerant Ретрай с backoff решает 90% таких случаев, и это дешевле, чем отказ пользователю
Фоновая обработка, лента, аналитикаfault-tolerant Никто не ждёт ответа на проводе: можно ретраить долго и настойчиво
Перегрузка, очередь растётfail-fast Load shedding: лучше честно отказать 10% сразу, чем деградировать всем и всё равно не успеть

Отдельно стоит самому упомянуть bulkhead (те самые «переборки» из начала главы): каждой зависимости свой семафор, который ограничивает параллелизм, чтобы одна медленная не съела все горутины и все соединения. Он дешевле брейкера и срабатывает раньше него — в момент замедления, а не в момент отказа.

type Bulkhead struct{ sem chan struct{} }

var ErrOverloaded = errors.New("dependency at capacity")

func (b *Bulkhead) Do(ctx context.Context, fn func(context.Context) error) error {
    select {
    case b.sem <- struct{}{}:
        // Место есть - работаем.
    case <-ctx.Done():
        return ctx.Err()
    default:
        // Очередь не растим: копить горутины в ожидании слота -
        // это отложенный отказ вместо мгновенного. Лучше отказать сразу.
        return ErrOverloaded
    }
    defer func() { <-b.sem }()
    return fn(ctx)
}

Как всё это складывается в одну цепочку

По отдельности паттерны выглядят безобидно, а вместе легко вредят друг другу: ретрай внутри ретрая даёт умножение, брейкер снаружи ретрая не видит отдельных попыток, таймаут внутри backoff не срабатывает никогда. Рабочий порядок обёрток снаружи внутрь (retry снаружи брейкера — так же по умолчанию и в resilience4j):

#СлойЧто делает и почему именно здесь
1Дедлайн запроса Самый внешний. Задаёт общий бюджет, за который не может выйти ничего внутри
2Retry Снаружи брейкера, чтобы серия попыток укладывалась в один логический вызов. Обязан считать ErrOpenState неретраибельной ошибкой
3Circuit breaker Внутри retry — тогда он видит каждую попытку и открывается по реальной статистике, а не по одной «серии»
4Bulkhead Ограничивает параллелизм к зависимости. Работает раньше брейкера: ловит замедление, а не отказ
5Таймаут вызова Самый внутренний: он должен ограничивать одну попытку, иначе брейкер и ретрай считают неправильные вещи
// Читается снаружи внутрь: бюджет -> ретраи -> брейкер -> семафор -> таймаут попытки.
func (c *BillingClient) Charge(ctx context.Context, req Request) (*Receipt, error) {
    ctx, cancel := childCtx(ctx, 2*time.Second, 100*time.Millisecond)   // 1. бюджет
    defer cancel()

    var receipt *Receipt
    err := retry.Do(ctx, c.policy, billingClassifier, func(ctx context.Context) error {   // 2
        return c.breaker.Do(ctx, func(ctx context.Context) error {                        // 3
            return c.bulkhead.Do(ctx, func(ctx context.Context) error {                   // 4
                attemptCtx, cancel := context.WithTimeout(ctx, 600*time.Millisecond)      // 5
                defer cancel()

                r, err := c.raw.Charge(attemptCtx, req)   // req несёт Idempotency-Key
                receipt = r
                return err
            })
        })
    })
    return receipt, err
}

func billingClassifier(err error) (bool, time.Duration) {
    // Брейкер открыт - ретраить бессмысленно: получим тот же мгновенный отказ,
    // только сожжём попытки и время.
    if errors.Is(err, ErrOpen) || errors.Is(err, ErrOverloaded) {
        return false, 0
    }
    return HTTPClassifier(err)
}
Три ошибки композиции, которые встречаются чаще всего
  • Ретрай на каждом слое. 3 попытки в клиенте × 3 в gateway × 3 в сервисе = 27 запросов на дальнюю зависимость. Ретраит ровно один слой.
  • Ретрай на ErrOpenState. Брейкер отказывает мгновенно, поэтому ретрай мгновенно сожжёт все попытки и вернёт ту же ошибку — только шумнее. Открытый брейкер сигналит, что пора уходить в fallback.
  • Таймаут снаружи ретраев вместо таймаута на попытку. Тогда одна зависшая попытка съедает весь бюджет, и ретраев фактически не будет никогда.

Вопросы

7
Суть: ретрай допустим, когда выполнены оба условия сразу: ошибка транзиентная (повтор может дать другой результат) и операция идемпотентна (повтор безопасен, даже если первая попытка на самом деле прошла). Нарушено любое из двух — не ретраим.

Формулировка «ретраим 5xx, не ретраим 4xx» закрывает только половину ответа, и на ней ловят. Вторая половина касается операции, а не ошибки. Ошибка говорит «может, повезёт со второй попытки», идемпотентность говорит «второй попыткой я ничего не сломаю». Без второго условия ретрай на таймауте списания превращается в двойное списание, и никакой backoff тут не поможет.

Ретраить можно

  • Сетевые сбои до отправки запроса: ECONNREFUSED, EHOSTUNREACH, ошибка DNS, отказ на этапе dial. Исход здесь однозначный: сервер запрос точно не видел, так что повтор безопасен даже для неидемпотентной операции.
  • Явные сигналы «попробуй позже»: HTTP 429, 503, 502, 504, gRPC UNAVAILABLE, RESOURCE_EXHAUSTED. Если пришёл Retry-After или RetryInfo — паузу назначает сервер, а не твой backoff.
  • Транзиентные ошибки БД: в Postgres это класс 40xxx40001 serialization_failure и 40P01 deadlock_detected. Транзакция откатилась целиком, побочных эффектов нет, и для SERIALIZABLE и REPEATABLE READ повтор считается штатным сценарием.
  • Гонка на выборе лидера / смена мастера: read-only transaction после failover, MOVED в Redis Cluster, NotLeader в Kafka. Повтор попадёт уже на нового лидера.
  • Оптимистические конфликты: UPDATE ... WHERE version = ? вернул 0 строк. Здесь ретрай обязан перечитать данные и пересчитать, а не повторить тот же UPDATE.

Ретраить нельзя

  • Детерминированные ошибки: 400, 401, 403, 404, 422, невалидный JSON, нарушение бизнес-правила. Повтор даст ровно тот же ответ и впустую потратит время пользователя и ресурсы зависимости.
  • Неидемпотентная запись с неопределённым исходом. Таймаут на POST /payments без ключа идемпотентности: ты не знаешь, прошло списание или нет. Ретраить тут значит играть в лотерею деньгами клиента.
  • Отменённый контекст. context.Canceled означает, что клиент ушёл и ответ уже некому получать; DeadlineExceeded — что бюджет исчерпан. Обе ошибки говорят: прекращай, а не повторяй.
  • Открытый circuit breaker. Брейкер отказывает мгновенно, значит серия ретраев сгорит за микросекунды и вернёт ту же ошибку. Открытый брейкер означает одно: уходи в fallback.
  • Ошибка после частичного эффекта. Типичный случай: batch-вставка, где половина строк прошла, а потом соединение отвалилось. Пока не сделаешь операцию атомарной или идемпотентной, ретраить нечего.

Серая зона: ambiguous outcome

Интереснее всего класс с неопределённым исходом: таймаут или разрыв соединения уже после отправки запроса. Клиент не знает, дошёл ли запрос, выполнился ли он и просто потерялся ответ. На собесе правильный разбор звучит так: «такая ошибка транзиентная, но ретраить её можно только при идемпотентной операции; если операция неидемпотентна — вариантов два: сделать её идемпотентной ключом идемпотентности либо не ретраить и вернуть пользователю честное статус неизвестен, проверьте позже».

// Классификатор - единственное место, где живёт знание «что ретраибельно».
// Разносить эти проверки по коду нельзя: они разъедутся в первый же месяц.
type Outcome int

const (
    Fatal      Outcome = iota // повтор бессмыслен
    Transient                 // запрос точно не выполнился - повтор безопасен всегда
    Ambiguous                 // исход неизвестен - повтор, только если операция идемпотентна
)

func classify(err error) Outcome {
    switch {
    case err == nil:
        return Fatal
    // Уход клиента и исчерпанный бюджет - это не сбой зависимости.
    case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
        return Fatal
    // Брейкер открыт: ретрай только сожжёт попытки.
    case errors.Is(err, ErrOpen), errors.Is(err, ErrOverloaded):
        return Fatal
    }

    // Не доехали до сервера - исход однозначный.
    var de *net.DNSError
    if errors.As(err, &de) || errors.Is(err, syscall.ECONNREFUSED) ||
        errors.Is(err, syscall.EHOSTUNREACH) {
        return Transient
    }

    var pg *pgconn.PgError
    if errors.As(err, &pg) {
        switch pg.Code {
        case "40001", "40P01":     // serialization_failure, deadlock_detected
            return Transient       // транзакция откатилась целиком
        case "23505":              // unique_violation - это уже дедупликация, а не сбой
            return Fatal
        }
        return Fatal
    }

    var he *HTTPError
    if errors.As(err, &he) {
        switch he.Code {
        case 408, 425, 429, 503:
            return Transient       // запрос не обработан — повтор безопасен
        case 500, 502, 504:
            return Ambiguous       // сервер мог успеть выполнить операцию
        }
        return Fatal               // 4xx - ошибка запроса, повтор не поможет
    }

    // Таймаут или обрыв на середине - самый опасный случай.
    var ne net.Error
    if errors.As(err, &ne) && ne.Timeout() {
        return Ambiguous
    }
    if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, io.ErrUnexpectedEOF) {
        return Ambiguous
    }
    return Fatal                   // неизвестное по умолчанию не ретраим
}

// Решение зависит от пары «класс ошибки + свойство операции».
func shouldRetry(err error, idempotent bool) bool {
    switch classify(err) {
    case Transient:
        return true
    case Ambiguous:
        return idempotent
    default:
        return false
    }
}
На чём ловят
  • «Ретраим всё, что не 200». Тогда сервис сам себе устраивает трёхкратную нагрузку на каждой волне клиентских ошибок валидации.
  • Ретрай по умолчанию в SDK. У многих клиентов (AWS SDK, gRPC с retryPolicy в service config) ретраи включены из коробки. Твой собственный ретрай сверху даёт умножение, о котором ты не знаешь. Первым делом надо посмотреть, что уже ретраит библиотека.
  • Ретрай без ограничения по времени. MaxAttempts без общего бюджета: три попытки по 30 секунд дают 90 секунд, а пользователь ушёл ещё на первой.
  • Забыть про Request.GetBody. Тело запроса из io.Reader второй раз не читается — повтор уйдёт с пустым телом и получит 400, а в логах это будет выглядеть как «сервер сломался».
Чем добить ответ

«Ретраит ровно один слой — тот, что ближе всего к зависимости; остальные пробрасывают ошибку наверх. Иначе 3 попытки на трёх слоях дают 27 запросов. И сверху я бы поставил retry budget в духе gRPC: ретраи разрешены, пока их доля не превышает 10–20% от трафика на эту зависимость. Как только зависимость лежит целиком, ретраи сами отключаются, а ведь именно они обычно и мешают ей встать.»

Суть: backoff разносит попытки одного клиента во времени, jitter разносит разных клиентов между собой. Без jitter экспонента не убирает синхронизацию: тысяча клиентов, упавших в одну секунду, ретраит тоже в одну секунду — просто реже.

Почему одного backoff мало

Зависимость упала или перегрузилась — значит, все клиенты получили ошибку почти одновременно. Дальше каждый честно ждёт base * 2^attempt. Формула детерминированная, стартовая точка у всех общая, поэтому вторая попытка у всей тысячи придётся на один и тот же момент. Получается thundering herd: ровно в секунду восстановления зависимость получает пиковый залп и падает снова. Такой цикл держится часами — это классический metastable failure: исходной причины давно нет, а система из ямы не выходит, потому что её держит собственный ретрай-трафик.

Jitter добавляет в паузу случайность и размазывает попытки по интервалу: вместо пика зависимость получает ровный поток, который способна переварить. Коротко: backoff уменьшает частоту повторов, jitter уменьшает их корреляцию. Нужны оба.

ВидФормулаСвойства
Без jittermin(cap, base*2^n) Максимальная синхронизация клиентов. Годится только для одного клиента в системе
Full jitterrand(0, min(cap, base*2^n)) Лучшее разнесение и наименьшее суммарное число запросов. Дефолт. Минус — иногда спит почти ноль
Equal jitterh + rand(0, h), где h = min(cap,base*2^n)/2 Гарантирует минимальную паузу. Хорош, когда мгновенный повтор вреден сам по себе
Decorrelatedmin(cap, rand(base, prev*3)) Разброс шире, сходимость быстрее. Рекомендация AWS, если нужен агрессивный поиск момента восстановления
Число, которое стоит назвать

В замерах AWS (статья Exponential Backoff and Jitter) при равном времени восстановления full jitter даёт примерно втрое меньше суммарных вызовов, чем экспонента без jitter, и заметно меньший пик конкурентности на сервере. Так что jitter экономит вполне реальный ресурс зависимости, а ровный график здесь только следствие.

Retry с уважением к context

Рабочий ретрай прерывает паузу по отмене, не спит дольше оставшегося дедлайна, закрывает таймер и даёт каждой попытке свой таймаут, иначе первая зависшая съест весь бюджет.

package retry

import (
    "context"
    "errors"
    "math/rand/v2"   // Go 1.22+: глобальный генератор без блокировок и без ручного Seed
    "time"
)

type Policy struct {
    MaxAttempts    int           // всего попыток, включая первую
    Base           time.Duration // 100ms
    Cap            time.Duration // 5s - потолок одной паузы
    PerAttempt     time.Duration // таймаут одной попытки; 0 - без своего таймаута
    Budget         time.Duration // потолок на всю серию; 0 - ограничивает только ctx
}

// Классификатор отвечает сразу на два вопроса: повторять ли,
// и не назвал ли сервер паузу сам (Retry-After, gRPC RetryInfo).
type Classifier func(err error) (retry bool, after time.Duration)

func Do(ctx context.Context, p Policy, cls Classifier, op func(context.Context) error) error {
    if p.MaxAttempts <= 0 { p.MaxAttempts = 3 }
    if p.Base <= 0        { p.Base = 100 * time.Millisecond }
    if p.Cap <= 0         { p.Cap = 5 * time.Second }

    if p.Budget > 0 {
        var cancel context.CancelFunc
        ctx, cancel = context.WithTimeout(ctx, p.Budget)
        defer cancel()   // обязателен: иначе течёт таймер до конца родительского ctx
    }

    var last error
    for attempt := 0; attempt < p.MaxAttempts; attempt++ {
        // Не ходим в сеть с уже истёкшим дедлайном: соединение зависимости
        // дороже, чем наша попытка «а вдруг успеем».
        if err := ctx.Err(); err != nil {
            return errors.Join(last, err)
        }

        last = attemptOnce(ctx, p.PerAttempt, op)
        if last == nil {
            return nil
        }

        // Отмена родительского контекста - не транзиентная ошибка: клиент ушёл.
        // Проверяем до классификатора, иначе таймаут попытки спутаем с отменой запроса.
        if ctx.Err() != nil {
            return errors.Join(last, ctx.Err())
        }

        retry, after := cls(last)
        if !retry || attempt == p.MaxAttempts-1 {
            return last
        }

        delay := after                                   // сервер назвал паузу - уважаем
        if delay <= 0 {
            delay = fullJitter(p.Base, p.Cap, attempt)
        }
        if err := sleepCtx(ctx, delay); err != nil {
            return errors.Join(last, err)
        }
    }
    return last
}

// Своя граница времени на каждую попытку. Без неё одна зависшая
// попытка съедает весь бюджет, и ретраев фактически не происходит.
func attemptOnce(ctx context.Context, per time.Duration, op func(context.Context) error) error {
    if per <= 0 {
        return op(ctx)
    }
    actx, cancel := context.WithTimeout(ctx, per)
    defer cancel()
    return op(actx)
}

func sleepCtx(ctx context.Context, d time.Duration) error {
    // Спать дольше, чем осталось до дедлайна, незачем: лучше вернуть ошибку
    // сразу, чем разбудить таймер в уже мёртвый запрос.
    if dl, ok := ctx.Deadline(); ok && time.Until(dl) <= d {
        return context.DeadlineExceeded
    }
    t := time.NewTimer(d)
    defer t.Stop()          // до Go 1.23 неостановленный таймер живёт до срабатывания
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-t.C:
        return nil
    }
}

func fullJitter(base, capD time.Duration, attempt int) time.Duration {
    backoff := base << attempt                  // base * 2^attempt
    if backoff > capD || backoff <= 0 {         // <=0 - защита от переполнения int64
        backoff = capD
    }
    return time.Duration(rand.Int64N(int64(backoff)) + 1)
}

Как это вызывается

policy := retry.Policy{
    MaxAttempts: 4,
    Base:        50 * time.Millisecond,
    Cap:         1 * time.Second,
    PerAttempt:  400 * time.Millisecond,   // одна попытка
    Budget:      2 * time.Second,          // вся серия
}

var receipt *Receipt
err := retry.Do(ctx, policy, HTTPClassifier, func(ctx context.Context) error {
    req = req.WithIdempotencyKey(key)      // ключ один на всю серию, а не на попытку
    r, err := client.Charge(ctx, req)
    receipt = r
    return err
})
Четыре ошибки в самописном ретрае
  • time.Sleep(delay) вместо select с ctx.Done(). Горутина продолжает спать после ухода клиента, и под нагрузкой таких набираются тысячи, вместе с памятью под них. Ретрай обязан быть прерываемым.
  • Новый ключ идемпотентности на каждую попытку. Тогда идемпотентности нет: сервер видит три разные операции. Ключ генерируют один раз на логическую операцию.
  • Таймаут снаружи вместо таймаута на попытку. Первая попытка виснет на весь бюджет — до второй и третьей дело не доходит, хотя в метриках «настроено 3 ретрая».
  • Классификация до проверки ctx.Err(). Тогда собственный таймаут попытки выглядит как транзиентная ошибка зависимости, и цикл честно ретраит уже отменённый запрос.
Что использовать в проде

Писать самому обычно не надо. Есть cenkalti/backoff/v5 и avast/retry-go; gRPC умеет декларативную retryPolicy в service config (там же hedging); в AWS SDK и Google Cloud SDK ретраи встроены. На собесе этот вопрос задают ради другого: понимаешь ли ты отмену, бюджет, таймаут на попытку и ключ идемпотентности. Эти четыре вещи библиотеки за тебя не решат.

Суть: предохранитель, который после серии отказов перестаёт ходить в сломанную зависимость и отвечает мгновенной ошибкой. Он защищает вызывающего — от исчерпания горутин, соединений и бюджета времени, — и заодно даёт зависимости шанс подняться, сняв с неё нагрузку.

От чего он спасает на самом деле

Упавшая зависимость опасна не столько ошибками, сколько ожиданием. Пока вызов висит на таймауте, он держит горутину, соединение из пула, слот в семафоре и кусок бюджета запроса. При 200 RPS и таймауте 30 секунд к моменту первого отказа у тебя уже 6000 горутин в ожидании и пустой пул HTTP-соединений. Дальше начинают отваливаться эндпоинты, которые к этой зависимости вообще не ходят. Так выглядит каскадный отказ. Брейкер разрывает цепочку: вместо 30 секунд ожидания вызов возвращает ошибку за микросекунды, ресурсы остаются свободными.

Второй эффект достаётся самой зависимости. Сервис, который лежит под нагрузкой, не встанет, пока в него продолжают долбить. Открытый брейкер снимает трафик, и у зависимости появляется окно на восстановление. Здесь «перестать пытаться» полезнее для обеих сторон.

Три состояния

СостояниеЧто делаетКогда уходит дальше
CLOSED Пропускает весь трафик, считает отказы в скользящем окне Доля (или число подряд) отказов превысила порог при достаточном числе запросов в окне → OPEN
OPEN Ничего не пропускает, мгновенно возвращает ErrOpen. В сеть не ходит вообще Прошёл cooldown (обычно 5–60 с) → HALF-OPEN. Переход ленивый: его делает первый запрос после паузы
HALF-OPEN Пропускает единицы пробных запросов, остальным отказывает N успехов подряд → CLOSED; хотя бы один отказ → сразу OPEN и новый отсчёт cooldown
HALF-OPEN — главная деталь ответа

Без него брейкер вырождается в «подождали и снова уронили»: после cooldown весь накопленный трафик разом бьёт в едва вставшую зависимость. HALF-OPEN работает как контролируемая разведка: пропускает 1–3 запроса, остальным по-прежнему отказывает и так за минимальную цену проверяет гипотезу «зависимость жива». Считать надо и успехи, и число пробных в полёте, иначе под конкурентной нагрузкой в half-open проскочат сотни запросов, пока первый ещё не ответил.

// Ядро автомата - вход и отчёт. Полная реализация со счётчиками
// разбиралась выше; здесь важна логика переходов.
func (b *Breaker) allow() error {
    b.mu.Lock()
    defer b.mu.Unlock()

    switch b.state {
    case Closed:
        return nil
    case Open:
        if time.Since(b.openedAt) < b.cooldown {
            return ErrOpen                       // отказ за наносекунды, в сеть не идём
        }
        b.state, b.probes, b.successes = HalfOpen, 0, 0
        fallthrough
    case HalfOpen:
        if b.probes >= b.maxProbes {             // пробных - единицы, не весь трафик
            return ErrTooManyProbes
        }
        b.probes++
        return nil
    }
    return nil
}

// Обёртка вызова. Тонкое место - что считать отказом.
func (b *Breaker) Do(ctx context.Context, fn func(context.Context) error) error {
    if err := b.allow(); err != nil {
        return err                               // fallback решает вызывающий
    }
    err := fn(ctx)
    b.report(err, isSuccess(err))
    return err
}

func isSuccess(err error) bool {
    if err == nil {
        return true
    }
    // Уход клиента - не вина зависимости, иначе массовая отмена запросов
    // сама откроет брейкер.
    if errors.Is(err, context.Canceled) {
        return true
    }
    // 4xx - ошибка запроса, а не сбой зависимости.
    var he *HTTPError
    if errors.As(err, &he) && he.Code < 500 && he.Code != 429 {
        return true
    }
    return false
}

Сценарий: внешний платёжный API лёг

Рассказывать его удобнее по шагам: интервьюер ждёт цифр и того, что происходит с ресурсами, а не определения из Википедии.

ВремяБез брейкераС брейкером
T+0Шлюз начинает отвечать 504 с латентностью 30 с То же самое: первые запросы честно уходят и висят
T+10 сВисят уже ~2000 вызовов (200 RPS × 10 с); к T+30 с их будет 6000, и пул HTTP-соединений исчерпан Таймаут попытки (600 мс) режет зависшие вызовы: уже в первую секунду 20 подряд упали, доля отказов > 50% → брейкер OPEN
T+30 сЛатентность растёт на всех эндпоинтах: заняты горутины и соединения. Деградируют ручки, которые в платёжку не ходят Вызовы падают за микросекунды с ErrOpen. Ресурсы свободны, остальной сайт работает штатно
T+60 сБалансировщик выкидывает поды по health-check, оставшиеся получают ещё больше трафика → каскад Пользователь видит честное оплата временно недоступна, заказ сохраняется в статусе pending
ВосстановлениеШлюз встал, но получает весь накопленный трафик разом и падает снова Каждые 30 с уходят 3 пробных запроса. Три успеха подряд → CLOSED, трафик возвращается сам
Чем портят брейкер
  • Считать отказом 4xx. Один клиент с кривыми запросами открывает брейкер всем. В отказы записывают 5xx, 429, таймауты и сетевые ошибки.
  • Один брейкер на всё приложение. Нужна гранулярность «зависимость + операция», а часто ещё и инстанс. Медленный поиск не должен рубить быстрый профиль.
  • Порог по абсолютному числу отказов. Ночью три запроса в минуту, все три упали, и брейкер открыт на полчаса. Нужен минимальный объём запросов в окне (MinRequests), иначе на низком трафике он бесполезен и вреден.
  • Ретрай поверх открытого брейкера. Три попытки сгорают мгновенно, ошибка та же, зато метрики и логи зашумлены. ErrOpen обязана быть неретраибельной.
  • Брейкер без fallback. Он превращает медленный отказ в быстрый, но отказ остаётся отказом. Толк от него появляется в паре с деградацией: кэш, дефолт, очередь.
Глубже, чем спросят

Классический брейкер бинарен и потому груб: он либо пропускает всё, либо ничего. Тоньше работает adaptive throttling (Google SRE, глава Handling Overload): клиент считает requests и accepts за скользящее окно и отбрасывает запрос с вероятностью max(0, (requests - K*accepts) / (requests + 1)), где K около 2. Трафик снижается плавно, без ступеньки, и восстановление проходит мягче. В Go готовые реализации: sony/gobreaker (окно по счётчикам, простой и надёжный) и slok/goresilience. Рядом стоит упомянуть hedged requests: если ответ не пришёл за p95, отправить дублирующий запрос на другую реплику и взять первый ответивший. Работает только для идемпотентных чтений и режет длинный хвост латентности.

Суть: вызов без таймаута — это утечка ресурсов с внешним управлением: сколько горутин, соединений и памяти он займёт, решает не твой сервис, а чужой сломанный сервер. Бюджет запроса — общий потолок времени, который каждый слой делит, а не назначает заново.

Почему нельзя без таймаута

TCP сам по себе не заметит зависшего собеседника: если сервер принял соединение и молчит, клиент будет ждать бесконечно — не «долго», а именно бесконечно. Каждый такой вызов держит горутину (2 КБ стека на старте, обычно больше), соединение из пула, слот в семафоре и открытый файловый дескриптор. При 200 RPS и часовой недоступности набегают сотни тысяч горутин: сервис умирает не от ошибок зависимости, а от собственной памяти. Таймаут превращает неопределённое ожидание в предсказуемую ошибку, а предсказуемую ошибку уже можно обработать: ретраем, брейкером, деградацией.

Дефолты Go, которые ловят
  • У http.DefaultClient и &http.Client{} таймаута нет вообще. Защитить могут только таймауты на уровне Transport, а в дефолтном транспорте их для чтения тела тоже нет. http.Get в проде почти всегда означает баг.
  • http.Server{} без ReadHeaderTimeout уязвим к Slowloris: клиент шлёт заголовки по байту и держит соединение сколько угодно. ReadTimeout, WriteTimeout, IdleTimeout по умолчанию тоже нулевые.
  • MaxIdleConnsPerHost по умолчанию 2. При высокой параллельности соединения не переиспользуются: каждый запрос платит за новый TCP+TLS handshake, и это выглядит как «зависимость тормозит».
  • net.Dialer без Timeout ждёт таймаута ОС — на blackhole-адресе на Linux это больше двух минут.
  • sql.DB: SetConnMaxLifetime и SetConnMaxIdleTime по умолчанию бесконечны, а SetMaxOpenConns не ограничен — при зависшей БД пул растёт, пока не упрётся в лимиты сервера.

Бюджет запроса и каскад

Таймаут на вызов необходим, но его недостаточно. Если каждый слой ставит свой таймаут «на глаз», арифметика ломается. У запроса бюджет 3 секунды, а внутри вызов с таймаутом 5 секунд и тремя ретраями: его таймаут не сработает ни разу, раньше всегда успеет внешний дедлайн, и внутренние настройки ничего не значат. Правильная модель называется бюджет: верхнюю границу задаёт вход (ingress, gateway), а дальше каждый слой берёт строго меньше, чем ему осталось, и оставляет резерв на обработку результата и запись ответа.

// В Go бюджет уже встроен в context: WithTimeout не добавляет времени,
// если дедлайн родителя ближе - побеждает родитель. Глубокий вызов
// физически не может пережить запрос.
func childCtx(ctx context.Context, want, reserve time.Duration) (context.Context, context.CancelFunc) {
    dl, ok := ctx.Deadline()
    if !ok {
        return context.WithTimeout(ctx, want)     // бюджета сверху нет - берём свой
    }
    left := time.Until(dl) - reserve
    if left <= 0 {
        // Времени не осталось: идти в сеть бессмысленно - только займём
        // соединение у зависимости, которой и так тяжело. Отказываем сразу.
        c, cancel := context.WithDeadline(ctx, time.Now())
        return c, cancel
    }
    if left < want {
        want = left
    }
    return context.WithTimeout(ctx, want)
}
// Пример арифметики: ingress даёт 3 с.
//   auth      200 мс
//   профиль   400 мс
//   платёж  3 попытки по 600 мс + backoff ~200 мс = 2000 мс
//   резерв    100 мс на сериализацию и запись ответа
//   итого    2700 мс < 3000 мс - влезает
func Handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()      // дедлайн сюда приносит http.TimeoutHandler или клиент

    aCtx, cancel := childCtx(ctx, 200*time.Millisecond, 0)
    defer cancel()
    user, err := auth.Verify(aCtx, r.Header.Get("Authorization"))
    if err != nil { writeErr(w, err); return }

    pCtx, cancel2 := childCtx(ctx, 2*time.Second, 100*time.Millisecond)
    defer cancel2()
    receipt, err := billing.Charge(pCtx, user.ID)   // внутри ретраи и брейкер
    if err != nil { writeErr(w, err); return }

    writeJSON(w, receipt)
}
Как выбирать число, а не брать «30 секунд»

Таймаут берут из наблюдаемой латентности: примерно p99.9 нормального времени ответа плюс запас, а не «поставлю побольше, чтобы не мешало». С большим таймаутом защиты нет, а маленький сам наплодит отказов там, где зависимость просто чуть медленнее обычного. Потом обязательно проверь арифметику по всей цепочке: для каждой ветки сумма (таймаут на попытку × число попыток + паузы backoff) должна целиком влезать в бюджет.

Как дедлайн едет по сети и почему клиентской отмены мало

context не пересекает границу процесса сам. gRPC передаёт дедлайн в метаданных grpc-timeout автоматически — поэтому в gRPC-цепочке бюджет распространяется бесплатно. В HTTP это делают руками: заголовок вроде x-envoy-expected-rq-timeout-ms или свой X-Request-Deadline, который сервер превращает в context.WithDeadline. Причём клиентский таймаут не останавливает работу на стороне сервера: отмена контекста для Postgres шлёт CancelRequest отдельным соединением, а это не мгновенно и без гарантий. Поэтому клиентский таймаут дублируют серверным: statement_timeout в Postgres, maxTimeMS в MongoDB, http.TimeoutHandler у себя.

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

Лестница деградации

Вопрос не сводится к «есть fallback или нет»: уровней несколько, и выбирают их осознанно. На собесе полезно перечислить уровни по убыванию качества ответа.

  1. Протухший кэш — отдаём последнее известное значение и помечаем его stale (stale-while-revalidate). Годится для медленно меняющихся данных: каталог, курсы, настройки, фичефлаги.
  2. Дефолт, то есть заранее посчитанная константа или неперсональный вариант. В рекомендациях, баннерах, ранжировании «топ популярного» лучше пустого блока.
  3. Урезанный ответ: 200 без части полей плюс признак partial: true. Карточка товара без блока «с этим покупают» вполне годится, а 500 нет.
  4. Можно отложить работу: положить в очередь или outbox и ответить 202 Accepted. Так делают там, где операция допускает асинхронность: письма, бонусы, индексация.
  5. Read-only режим — запись отключена, чтение идёт с реплики. При отказе мастера это заметно лучше полного 503.
  6. И последний уровень, честный отказ: явная ошибка с понятным текстом и Retry-After. Для денег, прав доступа и целостности данных это единственный корректный вариант: выдуманный ответ там означает соврать.
// Второй результат говорит вызывающему, что ответ деградировал.
// Возвращать «как будто всё хорошо» - это тихая поломка.
func (s *Service) Recommendations(ctx context.Context, userID int64) (Recs, bool) {
    // У некритичной зависимости - свой короткий таймаут, чтобы она
    // не съедала бюджет основного ответа.
    ctx, cancel := childCtx(ctx, 150*time.Millisecond, 20*time.Millisecond)
    defer cancel()

    recs, err := s.mlCB.Do(ctx, func(ctx context.Context) (Recs, error) {
        return s.ml.Get(ctx, userID)
    })
    if err == nil {
        s.cache.Set(userID, recs, 10*time.Minute)
        return recs, false
    }

    // Каждая деградация обязана быть видна. Без метрики сервис будет
    // месяцами отдавать дефолты, и никто об этом не узнает.
    degraded.WithLabelValues("recs", reasonOf(err)).Inc()
    s.log.WarnContext(ctx, "recommendations degraded", "err", err, "user", userID)

    if stale, ok := s.cache.GetStale(userID); ok {   // допускаем просроченное
        return stale, true
    }
    return s.topPopular(), true                      // последний рубеж
}

// В HTTP-слое деградация становится видимой снаружи.
func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
    recs, degraded := h.svc.Recommendations(r.Context(), userID(r))
    if degraded {
        w.Header().Set("X-Degraded", "recommendations")
        w.Header().Set("Cache-Control", "no-store")   // не кэшируем ухудшенный ответ
    }
    writeJSON(w, FeedResponse{Recs: recs, Partial: degraded})
}

Композитный ответ: параллельные источники с разной критичностью

func (s *Service) ProductPage(ctx context.Context, id int64) (Page, error) {
    var page Page
    g, gctx := errgroup.WithContext(ctx)

    // Критичный источник: его отказ - отказ всей ручки.
    g.Go(func() error {
        p, err := s.catalog.Get(gctx, id)
        page.Product = p
        return err                     // вернётся из g.Wait; блоки на ctx её не заметят
    })

    // Некритичные: ошибку не возвращаем в группу, иначе один упавший
    // блок отменит контекст и убьёт весь ответ.
    // Флаги деградации — у каждого свой: общий page.Partial из двух горутин —
    // это гонка.
    var recsDeg, reviewsDeg bool
    g.Go(func() error {
        recs, deg := s.Recommendations(ctx, id)   // именно ctx, не gctx
        page.Recs, recsDeg = recs, deg
        return nil
    })
    g.Go(func() error {
        rv, err := s.reviews.Top(ctx, id)
        if err != nil {
            degraded.WithLabelValues("reviews", reasonOf(err)).Inc()
            reviewsDeg = true
            return nil                 // блок отзывов просто не покажем
        }
        page.Reviews = rv
        return nil
    })

    if err := g.Wait(); err != nil {
        return Page{}, err             // упал критичный источник
    }
    page.Partial = recsDeg || reviewsDeg
    return page, nil
}
Правила, без которых деградация вредит
  • Какие зависимости критичны, решают заранее, на уровне продукта. Для каждой записано: критичная или нет и что отдавать при отказе. Если решать это в коде «на месте», однажды гарантированно отдашь нули вместо баланса.
  • Деградация должна быть видимой: метрика, поле в ответе, заголовок. Невидимую деградацию, как любую тихую поломку, находят через месяц по жалобам.
  • У некритичной зависимости свой короткий таймаут и свой брейкер. Блок рекомендаций не имеет права занимать 3 секунды бюджета основного ответа.
  • Fallback надо тестировать. Этот код не выполняется в обычной жизни, поэтому в момент инцидента регулярно выясняется, что сам путь деградации падает. Лечится явными тестами на отказ и chaos-экспериментами.
  • Кэш для скорости и кэш для деградации не смешивают. У первого короткий TTL, у второго длинный, и отдавать просроченное ему явно разрешено.
  • Не кэшировать ухудшенный ответ на CDN. Иначе один инцидент застынет в кэше на часы после восстановления.
Где деградация недопустима

Всё, где «наверное» равно «инцидент»: проверка баланса перед списанием, проверка прав доступа, проверка остатка на складе перед резервом, антифрод. Если сервис прав недоступен, отвечай 503, а не «пропустим на всякий случай» (это fail-open в безопасности и почти всегда уязвимость). Хорошая формулировка для собеса: деградировать можно полноту ответа, но не его корректность.

Суть: это не альтернативы, а разные уровни системы. Fail-fast на уровне вызова — не тянуть, а быстро освободить ресурсы; fault-tolerance на уровне архитектуры — отказ одного компонента не должен ронять целое. Именно быстрые отказы внизу и делают возможной устойчивость наверху.

Почему они не противоречат друг другу

Медленный отказ собирает худшее из обоих миров: пользователь всё равно не получит ответа, но ресурсы будут заняты до конца таймаута, а ретраи придут уже на исчерпанный пул. Fault-tolerance строится ровно из быстрых отказов: брейкер даёт мгновенный ErrOpen, и только поэтому у вызывающего остаются время и ресурсы уйти в fallback. Если внизу «терпеливо ждём», наверху терпеть уже нечем.

СитуацияПодходПочему
Невалидный конфиг или отсутствующий секрет при старте fail-fast Упасть на деплое дешевле, чем работать неправильно: откат сделает оркестратор, пользователи ничего не увидят
Деньги, права доступа, целостность данных fail-fast Выдуманный ответ хуже отказа. «Наверное, оплачено» — это инцидент, а не деградация
Детерминированная ошибка (400, невалидный вход) fail-fast Повтор даст тот же результат — ретрай только жжёт ресурсы и время
Перегрузка: очередь растёт, латентность ползёт вверх fail-fast Load shedding: честно отказать 10% сразу лучше, чем деградировать всем и всё равно не успеть
Транзиентная сетевая ошибка fault-tolerant Ретрай с backoff решает большинство таких случаев дешевле, чем отказ
Некритичная зависимость и есть разумный дефолт fault-tolerant Частичный ответ полезнее пустого — чаще всего пользователь разницы не заметит
Фоновая обработка, очередь, аналитика fault-tolerant Никто не ждёт ответа на проводе: ретраить можно долго, вплоть до DLQ
Инициализация ленивых ресурсов в рантайме зависит При старте — fail-fast; при рантайм-переподключении — терпеливо с backoff, иначе одно сетевое моргание уронит весь под

Fail-fast как защита от перегрузки

Чаще всего недооценивают отказ до начала работы. Если бюджет запроса уже истёк или очередь на обработку длиннее, чем ты успеешь разгрести, полезно только одно: мгновенно отказать. Если копить запросы в ожидании, отказ просто откладывается: ты потратишь ресурсы и всё равно ответишь ошибкой, только позже и всем сразу.

// Приёмный контроль: три быстрых проверки до любой полезной работы.
func (s *Server) admit(ctx context.Context) error {
    // 1. Клиент уже не ждёт - работать не для кого.
    if err := ctx.Err(); err != nil {
        return err
    }
    // 2. Бюджета не хватит даже на нормальный путь - лучше отказать сразу.
    if dl, ok := ctx.Deadline(); ok && time.Until(dl) < s.minWorkTime {
        sheddedTotal.WithLabelValues("no_budget").Inc()
        return ErrNoTimeBudget          // 503 + Retry-After
    }
    // 3. Перегрузка: очередь длиннее, чем мы разгребём за отведённое время.
    if s.inflight.Load() > s.maxInflight {
        sheddedTotal.WithLabelValues("overload").Inc()
        return ErrOverloaded            // 429 или 503
    }
    return nil
}

// Bulkhead: ограничить параллелизм на каждую зависимость отдельно,
// чтобы одна медленная не съела все горутины и соединения.
type Bulkhead struct{ sem chan struct{} }

func (b *Bulkhead) Do(ctx context.Context, fn func(context.Context) error) error {
    select {
    case b.sem <- struct{}{}:
    case <-ctx.Done():
        return ctx.Err()
    default:
        // Очередь не растим: ожидание слота - это отложенный отказ.
        return ErrOverloaded
    }
    defer func() { <-b.sem }()
    return fn(ctx)
}
Формулировка, которая закрывает вопрос

«Fail-fast на уровне вызова, fault-tolerance на уровне архитектуры. Внутри операции я хочу узнать об отказе как можно раньше и освободить ресурсы; на уровне системы я хочу, чтобы пользователь этого отказа не увидел — за счёт ретраев, реплик, деградации и очередей. Медленный отказ не даёт ни того, ни другого.»

На чём ловят
  • Fail-fast на старте применили к рантайму. Паниковать, если БД недоступна при старте, нормально. Паника при обрыве соединения через час работы перезапустит под как раз тогда, когда БД и так тяжело. В рантайме нужен backoff и readiness-пробы, а не log.Fatal.
  • Fault-tolerance там, где нужна корректность. «Антифрод не ответил — пропустим» создаёт дыру, а не устойчивость.
  • Путаница liveness и readiness. Если liveness-проба зависит от внешней БД, её недоступность перезапустит все поды разом и добьёт систему. Внешние зависимости проверяют только в readiness.
  • Ретраить ErrOverloaded и ErrOpen. Быстрый отказ, в который тут же долбят ретраем, перестаёт быть защитой.
Суть: идемпотентная операция при повторе не меняет состояние второй раз. Это свойство самой операции, а не кода ретрая, и обеспечивать его надо первым: без него любой ретрай — потенциальное двойное списание.

Почему без неё ретрай опасен

Сеть не даёт узнать, что произошло на другой стороне. Таймаут на POST /payments означает одно из трёх: запрос не дошёл; запрос выполнился, а ответ потерялся; запрос выполняется прямо сейчас. Различить эти случаи снаружи нельзя в принципе — это и есть ambiguous outcome. С идемпотентностью различать их и не нужно: повтор либо выполнит операцию (если первая не прошла), либо вернёт результат первой, ничего не выполняя.

Отсюда же следует, что «ровно один раз» (exactly-once) в распределённой системе недостижим на уровне доставки. Достижимо другое: at-least-once доставка плюс идемпотентный обработчик дают ровно один эффект. Это стандартный ответ и по ретраям, и по Kafka с RabbitMQ.

Пять способов сделать операцию идемпотентной

СпособКакГде применяется
ЕстественнаяОперация присваивает, а не изменяет UPDATE users SET status='banned' WHERE id=42, SET key value в Redis, любой GET
Уникальный ключ Уникальный индекс + ON CONFLICT DO NOTHING: вторая вставка не делает ничего Заказы, транзакции, любые сущности с естественным бизнес-ключом
Ключ идемпотентности Клиент шлёт Idempotency-Key, сервер хранит ключ вместе с ответом и на повтор возвращает сохранённый ответ Единственный способ сделать безопасным неидемпотентный POST. Так работают Stripe и все платёжные API
Условное обновление Версия в условии: WHERE id=? AND version=? Оптимистические блокировки, ETag + If-Match, CAS в etcd
Дедупликация на приёме Таблица обработанных message_id в той же транзакции, что и бизнес-эффект Inbox-паттерн для Kafka и RabbitMQ: at-least-once снаружи, один эффект внутри
-- Дедупликация вставки: повтор не создаёт вторую запись и не падает.
INSERT INTO payments (order_id, amount, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (order_id) DO NOTHING
RETURNING id;
-- Пустой результат = запись уже была. Это не ошибка, а штатная ветка:
-- читаем существующую строку и возвращаем её как результат.

-- Inbox: факт обработки и бизнес-эффект фиксируются одной транзакцией.
BEGIN;
  INSERT INTO processed_messages (message_id) VALUES ($1);  -- PK, повтор упадёт 23505
  UPDATE accounts SET balance = balance - $2 WHERE id = $3;
COMMIT;
-- Если вторая команда упала - откатится и первая, сообщение приедет снова.
-- Если сообщение дубль - упадёт первая, транзакция откатится, эффекта нет.
// Обработчик с ключом идемпотентности. Порядок шагов важен:
// сначала атомарно занимаем ключ, только потом делаем работу.
func (s *Service) CreatePayment(ctx context.Context, key string, req Request) (Response, error) {
    if key == "" {
        return Response{}, ErrIdempotencyKeyRequired    // 400
    }
    hash := sha256.Sum256(req.Canonical())

    // 1. Гонку решает сама БД: выигрывает ровно один INSERT.
    claimed, err := s.repo.ClaimKey(ctx, key, "POST /payments", hash[:])
    if err != nil {
        return Response{}, err
    }
    if !claimed {
        rec, err := s.repo.LoadKey(ctx, key)
        if err != nil {
            return Response{}, err
        }
        // 2. Тот же ключ с другим телом - баг клиента, а не повтор.
        if !bytes.Equal(rec.RequestHash, hash[:]) {
            return Response{}, ErrIdempotencyKeyReuse   // 422
        }
        // 3. Первый запрос ещё в работе - параллельно не выполняем.
        if rec.State == StateInProgress {
            return Response{}, ErrInProgress            // 409 + Retry-After: 1
        }
        return rec.Response, nil                        // 4. Отдаём сохранённый ответ
    }

    resp, err := s.charge(ctx, req)
    if err != nil {
        // Деньги точно не списаны — отпускаем ключ, и повтор пройдёт заново:
        // иначе ключ навсегда залипнет в in_progress. Исход неизвестен
        // (таймаут после отправки) — не отпускаем: повтор может списать
        // второй раз, такой ключ ждёт сверки с платёжкой.
        if !errors.Is(err, ErrOutcomeUnknown) {
            _ = s.repo.ReleaseKey(ctx, key)
        }
        return Response{}, err
    }

    // 5. Сохраняем ответ под ключом: повтор получит его, а не второе списание.
    if err := s.repo.CompleteKey(ctx, key, resp); err != nil {
        return Response{}, err
    }
    return resp, nil
}
Подвохи, на которых заваливают
  • «PUT идемпотентен по спецификации». RFC 9110 объявляет GET, PUT, DELETE идемпотентными, но это требование к разработчику, а не гарантия транспорта. PUT /counter, который внутри делает counter = counter + 1, идемпотентным не станет. Проверять надо реализацию, а не глагол.
  • Ключ генерирует сервер. Всё ровно наоборот: ключ создаёт клиент, один раз на логическую операцию, и шлёт его во всех повторах. Новый ключ на каждую попытку убивает всю схему.
  • Нет хеша тела. Клиент случайно переиспользовал ключ — и получил чужой сохранённый ответ. По логам такую ошибку почти невозможно найти.
  • Ключ и эффект в разных транзакциях. Деньги списаны, ключ не сохранён, повтор списывает снова — то самое двойное списание, от которого всё и затевалось.
  • Нет TTL и чистки. Таблица ключей растёт линейно по трафику и однажды становится крупнейшей в базе. Партиционирование по дате и DROP PARTITION дешевле, чем DELETE.
  • Идемпотентность на уровне HTTP, но не на уровне бизнес-операции. Повтор не создал второй платёж, но второй раз отправил письмо и второй раз начислил бонусы — потому что побочные эффекты живут вне транзакции. Их место в outbox.
Чем добить ответ

«Идемпотентность нужна для корректности при дублировании, а ретрай лишь частный случай. Дубликаты порождает не один мой ретрай: их создаёт балансировщик, sidecar, нетерпеливый пользователь, дважды нажавший кнопку, и at-least-once брокер. Поэтому сначала делаем операцию идемпотентной и только потом включаем ретраи. В обратном порядке мы, по сути, уже проектируем двойные списания.»