Ошибки и отказоустойчивость кода
Секция, которая отделяет «умею писать на 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, спускаясь по цепочке слой за слоем:
«этот слой — не он? а этот?»
«Error — это значение»: что за этим стоит
Формулировка Роба Пайка «errors are values» звучит как лозунг, но у неё есть точный смысл:
ошибка в Go ведёт себя как любое значение интерфейсного типа. Её можно
положить в переменную, передать в функцию, сохранить в поле структуры, сложить в слайс, сравнить,
обернуть и вернуть. Отдельного механизма языка под ошибки нет. Весь «фреймворк обработки ошибок»
сводится к одному интерфейсу с одним методом и пакету errors на полторы сотни строк.
// Тип целиком, объявлен в builtin, живёт в universe scope:
type error interface {
Error() string
}
Из этого вытекает всё остальное. Ошибка попадает в сигнатуру функции, а значит видна
на ревью и в godoc. Компилятор знает про множественный возврат, поэтому
(T, error) ничего не стоит. Управление никогда не «улетает» в неизвестное место:
из функции уходят только через return или панику, причём паника в идиоматичном
Go считается исключительной ситуацией, а не рабочим путём.
Почему не исключения
| Аргумент | Как это в Go | Как в языках с исключениями |
|---|---|---|
| Видимость в сигнатуре | func Do() (Result, error) — контракт написан прямо в типе |
Что бросает метод, из сигнатуры не видно (кроме checked exceptions в Java, от которых все убежали в RuntimeException) |
| Пути выхода из функции | Один: return. Читая тело, ты видишь весь поток управления |
Любая строка — потенциальный выход. Инварианты приходится защищать try/finally вокруг всего |
| Стоимость | Возврат значения. Ноль накладных расходов в happy path | Раскрутка стека, сбор стектрейса. Бросок исключения в горячем цикле — заметная просадка |
| Композиция | Ошибка — значение: её можно положить в []error, вернуть из горутины через канал, накопить, сравнить |
Исключение вне catch жить не умеет: чтобы «сохранить на потом», его надо поймать и превратить в объект |
| Что по умолчанию | По умолчанию ошибка на виду: чтобы взять результат, её придётся присвоить, хотя бы в _. Молча её выбрасывает только вызов без присваивания, это ловит errcheck |
По умолчанию исключение летит наверх. Удобно, пока не окажется, что «наверху» — это цикл воркера, который молча съедает всё |
Обратную сторону лучше назвать самому, не дожидаясь вопроса: код становится
многословным. Легендарные три строки if err != nil { return nil, err } занимают
заметную долю файла. Команда Go несколько раз пыталась это починить: предложения
check/handle (2018) и try() (2019) отклонили, а в 2025 году
официально объявили, что синтаксического сахара для ошибок не будет. Сообщество так и не
сошлось ни на одном варианте, а платить пришлось бы той самой явностью.
«Go меняет многословность на предсказуемость. В каждом if err != nil я обязан
решить: обернуть, обработать, деградировать или пробросить. В языке с
исключениями это решение можно не принимать, и тогда его принимают за тебя, обычно неудачно.»
Что физически лежит в переменной типа error
Раз error интерфейс, в переменной лежат два машинных слова: указатель на
itab (пара «тип + таблица методов») и указатель на данные. Отсюда два практических
следствия, которые проверяют почти всегда.
Первое. errors.New возвращает указатель на структуру, а не значение:
// src/errors/errors.go
func New(text string) error { return &errorString{text} }
type errorString struct {
s string
}
func (e *errorString) Error() string { return e.s }
Указатель выбран сознательно. Если бы возвращалось значение, две ошибки с одинаковым текстом
сравнивались бы как равные, и errors.Is путал бы разные ошибки из разных пакетов
с совпавшим сообщением. С указателем каждый вызов errors.New даёт уникальную
идентичность — на этом и стоят sentinel-ошибки.
a := errors.New("EOF")
b := errors.New("EOF")
fmt.Println(a == b) // false: разные аллокации, разные указатели
fmt.Println(a == io.EOF) // false, хотя текст совпадает
Второе, и это классическая ловушка: интерфейс с ненулевым типом и нулевыми данными
не равен nil. Функция, которая возвращает конкретный тип ошибки вместо error,
ломает err != nil.
type MyErr struct {
Code int
}
func (e *MyErr) Error() string { return "code " + strconv.Itoa(e.Code) }
func broken() error {
var e *MyErr = nil // конкретный тип, значение nil
return e // упаковали в интерфейс: itab = (*MyErr, error), data = nil
}
func main() {
err := broken()
fmt.Println(err == nil) // false
fmt.Printf("%T %v\n", err, err) // *main.MyErr <nil>
fmt.Println(err.Error()) // паника, точный текст ниже
}
// stdout:
// false
// *main.MyErr <nil>
// stderr, exit status 2:
// panic: runtime error: invalid memory address or nil pointer dereference
// [signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x10461cd10]
//
// goroutine 1 [running]:
// main.(*MyErr).Error(...)
// main.main()
// Печатать «panic: nil pointer dereference» рантайм не умеет: это всегда
// «runtime error: invalid memory address or nil pointer dereference» плюс строка про SIGSEGV.
Никогда не объявляй переменную конкретного типа ошибки, если собираешься её вернуть как
error. Возвращай либо nil явным литералом, либо результат конструктора,
типизированный как error. И не объявляй у функции возврат
*MyErr, только error. Ловит это staticcheck (проверка SA4023):
она замечает сравнение с nil, которое из-за typed nil всегда истинно.
Четыре способа создать ошибку
| Способ | Что получаешь | Когда |
|---|---|---|
errors.New("...") | *errorString, без обёртки и без данных | Sentinel на уровне пакета. Или простая локальная ошибка без контекста |
fmt.Errorf("...: %v", x) | Тоже *errorString — форматирование есть, цепочки нет | Когда причину надо показать в тексте, но не отдавать наружу для errors.Is |
fmt.Errorf("...: %w", err) | *fmt.wrapError с методом Unwrap() error | Основной рабочий способ добавить контекст, сохранив причину |
| Собственный тип | Структура с полями + Error(), опционально Unwrap(), Is(), As() | Когда вызывающему нужны данные ошибки, а не только факт |
Есть и пятый, errors.Join(a, b) из Go 1.20, но он не создаёт ошибку, а
склеивает несколько. О нём ниже отдельно.
Оборачивание: %w против %v
До Go 1.13 обёрток в стандартной библиотеке не было — все пользовались
github.com/pkg/errors. В 1.13 в язык (точнее, в fmt и errors)
приехали три вещи: глагол %w, соглашение о методе Unwrap() error и функции
errors.Is / errors.As / errors.Unwrap.
Внутри fmt.Errorf считает, сколько раз встретился %w, и в зависимости
от этого возвращает разные типы:
// упрощённая логика fmt.Errorf (Go 1.20+)
switch len(wrappedErrs) {
case 0:
err = errors.New(s) // обычная строка, цепочки нет
case 1:
err = &wrapError{msg: s, err: w} // есть Unwrap() error
default:
err = &wrapErrors{msg: s, errs: ws} // есть Unwrap() []error, с Go 1.20
}
Отсюда вывод, который редко проговаривают: %w меняет публичный
контракт. Обернув чужую ошибку, ты разрешил всем вызывающим писать
errors.Is(err, sql.ErrNoRows) на твой результат. Заменишь потом
database/sql на что-то другое — сломаешь их код, и компилятор про это не скажет.
%v, наоборот, ошибку инкапсулирует: текст в логе останется, а программная
зависимость не возникнет.
// %w: причина остаётся доступной
err := fmt.Errorf("get user %d: %w", id, sql.ErrNoRows)
errors.Is(err, sql.ErrNoRows) // true
errors.Unwrap(err) // sql.ErrNoRows
err.Error()
// "get user 42: sql: no rows in result set"
// %v: только текст, связи нет
err := fmt.Errorf("get user %d: %v", id, sql.ErrNoRows)
errors.Is(err, sql.ErrNoRows) // false
errors.Unwrap(err) // nil
err.Error()
// "get user 42: sql: no rows in result set"
%wставь внутри одного модуля/сервиса и там, где причину действительно нужно различать программно. И всегда дляcontext.Canceledиcontext.DeadlineExceeded: их проверяют выше по стеку.%vнужен на границе публичного API пакета, чтобы ошибка чужой библиотеки не протекла в контракт. И там, где причина остаётся деталью реализации (какой конкретно драйвер БД, какой парсер).- Оборачивая, добавляй операцию и идентификатор, а не «ошибка!»:
"repo.GetUser(id=42): %w". Обёртка нужна, чтобы по логу восстановить путь.
Сообщение ошибки в Go пишется с маленькой буквы и без точки в конце
(ST1005 в staticcheck): его почти всегда встраивают в другое сообщение,
и «Failed To Load Config.: unexpected EOF» читается плохо. Слова
failed to, error, could not тоже лишние — на выходе получится
«failed to get user: failed to query: failed to connect». Идиоматичный формат:
"операция: причина", где каждое звено добавляет ровно одно слово о себе.
Цепочка обёрток и как по ней ходят Is/As
Обёртки складываются в односвязный список. Каждое звено хранит указатель на предыдущее и умеет отдать его
методом Unwrap() error. Стектрейса, как уже говорилось, нет: цепочка
восстанавливает логический путь ошибки по слоям, а не физический путь по кадрам стека.
errors.Is
дошёл до конца и нашёл sentinel; errors.As остановился раньше — на первом звене
нужного типа, и заполнил переменную.Алгоритм errors.Is и errors.As — точно
Внутри обеих функций цикл по цепочке и две точки расширения. Разобрать их
по шагам стоит: вопрос «а как именно Is ходит по цепочке» задают почти всегда,
и почти всегда забывают про метод 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 промахнётся.
errors.Is отвечает на вопрос «это оно?» и сравнивает с конкретным значением.
errors.As отвечает на вопрос «есть ли в цепочке ошибка такого типа, и если да —
дай её мне», сравнивает по типу и заполняет переменную. Первое годится для sentinel-ошибок,
второе для ошибок с данными.
Sentinel errors
Sentinel, как уже говорилось, это заранее объявленная на уровне пакета ошибка, с которой сравнивают результат. Техника в Go самая старая, стандартная библиотека ею пронизана.
| Sentinel | Пакет | Что означает |
|---|---|---|
io.EOF | io | Данные кончились. Это не сбой, а нормальное завершение чтения |
io.ErrUnexpectedEOF | io | Поток кончился посреди структуры — вот это уже сбой |
sql.ErrNoRows | database/sql | QueryRow().Scan() не нашёл строк |
sql.ErrTxDone | database/sql | Транзакция уже закоммичена или откачена |
fs.ErrNotExist / os.ErrNotExist | io/fs | Файла нет. Проверять надо через errors.Is, не через == |
context.Canceled | context | Отменили сверху — клиент ушёл, вызывающий передумал |
context.DeadlineExceeded | context | Истёк дедлайн. Реализует Timeout() bool |
http.ErrServerClosed | net/http | ListenAndServe вернулся из-за Shutdown — штатное завершение |
redis.Nil, pgx.ErrNoRows | драйверы | Те же «пусто» в чужих экосистемах |
// Объявляем всегда через var: const не может быть интерфейсом
var ErrInsufficientFunds = errors.New("insufficient funds")
// Используем
if err := acc.Withdraw(100); errors.Is(err, ErrInsufficientFunds) {
return c.JSON(422, "not enough money")
}
- Жёсткая связность. Чтобы сравниться с
sql.ErrNoRows, слой транспорта обязан импортироватьdatabase/sql. Ошибка инфраструктуры протекает в домен и дальше — до HTTP-хендлера. - Нет данных. Sentinel несёт ровно один бит информации — «случилось». Ни какого поля не хватило, ни какой лимит превышен, ни сколько ждать. Всё это приходится доклеивать текстом, а текст не разбирают программно.
- Часть публичного API навсегда. Переименовать её, удалить или поменять условие, при
котором она возвращается, значит внести ломающее изменение. Компилятор про него не скажет:
errors.Isпросто начнёт возвращатьfalse, и код у пользователей тихо сломается в проде. - Плохо переживают обёртки без
%w. Одна библиотека в середине стека обернула через%v— и всеerrors.Isвыше по стеку молча перестают срабатывать.
Обычно просят описать компромисс: sentinel-ы объявляет доменный слой
(domain.ErrNotFound, domain.ErrConflict, domain.ErrForbidden).
Их немного, они стабильны, и весь верхний код зависит только от домена. Инфраструктурные ошибки
репозиторий на своей границе переводит в доменные, а не пускает наверх как есть.
Кастомные типы ошибок
Правило простое: если вызывающему нужны данные, а не факт, заводи тип. Валидация с именем поля, HTTP-ошибка со статусом и телом, ошибка ретрая с «подожди столько-то», ошибка лимита с текущим и максимальным значением — всё это sentinel-ом не выразить.
type ValidationError struct {
Field string
Value any
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %q: %s", e.Field, e.Reason)
}
// RetryableError подсказывает, когда повторить
type RetryableError struct {
After time.Duration
Err error
}
func (e *RetryableError) Error() string { return "retry after " + e.After.String() + ": " + e.Err.Error() }
func (e *RetryableError) Unwrap() error { return e.Err } // цепочка не рвётся
func (e *RetryableError) Timeout() bool { return true } // видна проверкам на Timeout()
// Метод Is: ошибки равны по коду, а не по указателю
type APIError struct {
Code int
}
func (e *APIError) Error() string { return "api error " + strconv.Itoa(e.Code) }
func (e *APIError) Is(target error) bool {
t, ok := target.(*APIError)
return ok && t.Code == e.Code
}
// теперь работает так:
errors.Is(err, &APIError{Code: 429}) // true для любой 429-ошибки
Error() stringобъявляй на указательном ресивере, если структура не совсем крошечная. Смешивать value- и pointer-ресиверы нельзя:errors.As(err, &e)с*MyErrне найдётMyErr, и наоборот. Выбери одно и держись.Unwrap() errorобязателен, если внутри есть причина. Без него цепочка обрывается на тебе, иerrors.Is(err, context.Canceled)выше по стеку перестанет работать.Is(error) boolнужен, только когда сравнивают по значению полей, а не по идентичности.- Конструктор возвращает
error, а не*MyErr, иначе получишь typed nil. - Экспортировать ли сам тип или только конструктор + предикат
IsValidation(err) bool, решает стабильность API: предикат менять проще, чем структуру.
errors.Join — агрегация (Go 1.20)
До 1.20 «несколько ошибок сразу» приходилось собирать руками: []error плюс свой тип
с Error(), который склеивает сообщения. В 1.20 добавили errors.Join
и вторую форму Unwrap (Unwrap() []error). С этого момента цепочка
ошибок формально стала деревом, а errors.Is и errors.As
обходят его в глубину.
func Join(errs ...error) error // nil-ы отбрасываются; если все nil, вернётся nil
err := errors.Join(err1, nil, err2)
// Error() = "err1\nerr2", сообщения через перевод строки
// Unwrap() []error = [err1, err2]
// errors.Unwrap(err) == nil: одноуровневый Unwrap с деревом не работает
errors.Unwrap — нет: у join-узла просто нет метода
Unwrap() error.Join реально экономит код в трёх типичных местах:
// 1) Валидация: собрать все ошибки, а не первую
func (r CreateUserReq) Validate() error {
var errs []error
if r.Name == "" {
errs = append(errs, fmt.Errorf("name: %w", ErrRequired))
}
if r.Age < 0 || r.Age > 150 {
errs = append(errs, fmt.Errorf("age %d: %w", r.Age, ErrRange))
}
if !strings.Contains(r.Email, "@") {
errs = append(errs, &ValidationError{Field: "email", Reason: "no @"})
}
return errors.Join(errs...) // если срез пуст, вернётся ровно nil
}
// 2) Закрытие нескольких ресурсов в defer: ни одну ошибку не потерять
func (s *Server) Close() error {
return errors.Join(
s.httpSrv.Shutdown(context.Background()),
s.db.Close(),
s.broker.Close(),
)
}
// 3) Классический паттерн "не потерять ошибку Close" через именованный возврат
func writeFile(name string, data []byte) (err error) {
f, err := os.Create(name)
if err != nil { return err }
defer func() { err = errors.Join(err, f.Close()) }() // раньше здесь молча терялось
_, err = f.Write(data)
return err
}
То же самое, что Join, умеет и fmt.Errorf с несколькими
%w — тоже с Go 1.20. Разница в том, что Errorf даёт человекочитаемое
сообщение с контекстом, а Join просто склеивает через \n:
err := fmt.Errorf("создание пользователя: %w, а также %w", ErrRequired, ErrRange)
errors.Is(err, ErrRequired) // true
errors.Is(err, ErrRange) // true
errors.Join()без аргументов иerrors.Join(nil, nil)возвращают ровно nil — не «пустую ошибку». Поэтому можно писатьreturn errors.Join(errs...)без предварительной проверки длины.- Возвращённое значение не равно ни одной из исходных через
==— толькоerrors.Is. - Join-ошибка не реализует
Unwrap() error, поэтому библиотеки, написанные до 1.20 и вручную вызывающиеUnwrapв цикле, её не развернут. Если пишешь обход цепочки сам, обрабатывай обе формыUnwrap. - Достать все листья дерева стандартная библиотека не умеет — своя рекурсия на 12 строк:
func Flatten(err error) []error {
switch x := err.(type) {
case interface{ Unwrap() []error }:
var out []error
for _, e := range x.Unwrap() { out = append(out, Flatten(e)...) }
return out
case interface{ Unwrap() error }:
if inner := x.Unwrap(); inner != nil { return Flatten(inner) }
}
if err == nil { return nil }
return []error{err}
}
Ошибки в слоистом приложении
Здесь заканчивается синтаксис и начинается инженерия. У ошибки в трёхслойном сервисе есть жизненный цикл: она рождается в инфраструктуре, получает контекст при подъёме, ровно один раз логируется и превращается в код ответа на границе. За каждый шаг отвечает свой слой, и на собесе это надо уметь проговорить.
// ── domain/errors.go: словарь ошибок, от которого зависят все слои ──
package domain
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrForbidden = errors.New("forbidden")
ErrInvalidInput = errors.New("invalid input")
ErrUnavailable = errors.New("dependency unavailable")
)
// ── repository: переводим инфраструктуру в домен, наружу sql не течёт ──
func (r *UserRepo) Get(ctx context.Context, id int64) (domain.User, error) {
var u domain.User
err := r.db.QueryRowContext(ctx, qGet, id).Scan(&u.ID, &u.Email)
switch {
case errors.Is(err, sql.ErrNoRows):
return u, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
case err != nil:
return u, fmt.Errorf("userRepo.Get(%d): %w", id, err) // техническую не переводим
}
return u, nil
}
// ── service: добавляет операцию, не логирует ──
func (s *Profile) Show(ctx context.Context, id int64) (View, error) {
u, err := s.users.Get(ctx, id)
if err != nil {
return View{}, fmt.Errorf("profile.Show: %w", err)
}
return render(u), nil
}
// ── transport: только здесь логируем и маппим ──
func (h *Handler) Show(w http.ResponseWriter, r *http.Request) {
v, err := h.svc.Show(r.Context(), chi.URLParamInt64(r, "id"))
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, v)
}
func writeError(w http.ResponseWriter, r *http.Request, err error) {
status, code := httpStatus(err)
if status >= 500 {
// полный контекст — только во внутренний лог
slog.ErrorContext(r.Context(), "request failed",
"err", err, "path", r.URL.Path, "status", status)
} else {
slog.InfoContext(r.Context(), "request rejected", "code", code, "err", err)
}
// наружу — только код и trace_id, никакого err.Error()
writeJSON(w, status, map[string]any{
"code": code,
"trace_id": trace.SpanContextFromContext(r.Context()).TraceID().String(),
})
}
func httpStatus(err error) (int, string) {
switch {
case errors.Is(err, context.Canceled):
return 499, "client_closed_request" // nginx-код: клиент ушёл сам
case errors.Is(err, context.DeadlineExceeded):
return http.StatusGatewayTimeout, "timeout"
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound, "not_found"
case errors.Is(err, domain.ErrConflict):
return http.StatusConflict, "conflict"
case errors.Is(err, domain.ErrForbidden):
return http.StatusForbidden, "forbidden"
case errors.Is(err, domain.ErrInvalidInput):
return http.StatusUnprocessableEntity, "invalid_input"
case errors.Is(err, domain.ErrUnavailable):
return http.StatusServiceUnavailable, "unavailable"
default:
return http.StatusInternalServerError, "internal"
}
}
| Доменная ошибка | HTTP | gRPC code | Ретраить клиенту? |
|---|---|---|---|
ErrInvalidInput | 400 / 422 | InvalidArgument | нет — детерминированно упадёт снова |
| нет авторизации | 401 | Unauthenticated | нет (можно после обновления токена) |
ErrForbidden | 403 | PermissionDenied | нет |
ErrNotFound | 404 | NotFound | нет |
ErrConflict (версия/дубль) | 409 | AlreadyExists / Aborted | Aborted — да, после разрешения конфликта |
| rate limit | 429 | ResourceExhausted | да, по Retry-After |
| всё остальное | 500 | Internal | осторожно: операция могла пройти |
ErrUnavailable | 503 | Unavailable | да — это транзиентная ошибка по определению |
DeadlineExceeded | 504 | DeadlineExceeded | да, но не бесконечно |
context.Canceled | 499 (nginx) | Canceled | нет — клиент ушёл сам |
- Логируй или возвращай — не оба. Ошибка логируется ровно там, где перестаёт двигаться
наверх: в HTTP-хендлере, в consumer'е Kafka, в теле воркера. Везде ниже — только
%w. - Оборачивай ровно один раз на слой, добавляя имя операции и основные идентификаторы. Без этого лог выглядит как «not found» без единой подсказки, где именно.
- Наружу не должно вытекать ничего внутреннего. Ни текста SQL, ни имени таблицы, ни
хоста БД, ни стектрейса. Клиент получает код и
trace_id, а остальное узнает у саппорта по этомуtrace_id.
Антипаттерны
// плохо
_ = json.Unmarshal(b, &v) // молча
f, _ := os.Open(path) // молча
if strings.Contains(err.Error(), "no rows") { // по тексту
return nil
}
func Load() *Config { // паника вместо ошибки
b, err := os.ReadFile(p)
if err != nil { panic(err) }
...
}
if err != nil {
log.Println(err) // и логируем,
return err // и возвращаем
}
if err != nil {
return err // контекст потерян
}
// хорошо
if err := json.Unmarshal(b, &v); err != nil {
return fmt.Errorf("decode payload: %w", err)
}
if errors.Is(err, sql.ErrNoRows) { // по значению
return domain.ErrNotFound
}
func Load(p string) (*Config, error) {
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", p, err)
}
...
}
if err != nil {
return fmt.Errorf("charge order %s: %w", id, err)
}
// а если игнорируем осознанно, пишем почему:
//nolint:errcheck // best-effort: метрику потерять не страшно
_ = metrics.Push(ctx, m)
Текст ошибки не входит в контракт ни одного пакета. Он меняется в патч-релизах, зависит
от ОС (на Windows тексты syscall-ошибок ещё и переведены на язык системы), от версии
драйвера БД и от того, обернул ли кто-то ошибку по дороге.
strings.Contains(err.Error(), "connection refused") прекрасно
работает у тебя на ноутбуке и разваливается на проде под другой ОС. Правильные инструменты:
errors.Is для sentinel, errors.As для типа, а для системных ошибок
errors.Is(err, syscall.ECONNREFUSED).
Ещё три антипаттерна встречаются реже, но назвать их на собесе выигрышно:
возврат конкретного типа вместо error (typed nil, разбирали выше);
panic в библиотечном коде вместо возврата ошибки (вызывающий лишается выбора
политики); и обёртка всего подряд через %w — ошибка сторонней
библиотеки становится частью твоего публичного контракта, и без мажорной версии её оттуда
уже не убрать.
Вопросы
9
Никакого «фреймворка ошибок» в языке нет: есть интерфейс error с единственным
методом Error() string и пакет errors. Ошибку, как любое обычное
значение, можно положить в переменную, в поле структуры, в слайс, передать по каналу,
сравнить, обернуть.
Аргументы против исключений
- Контракт виден в сигнатуре.
func Do() (Result, error)сообщает, что операция может не получиться. Из сигнатуры Java-метода безthrowsэто не видно, а checked exceptions в живом коде почти все обходят. - Один путь выхода. В Go функция заканчивается
return. В языке с исключениями выйти можно из любой строки, и чтобы удержать инварианты, нуженtry/finallyвокруг всего. В Go для этого естьdefer, и он привязан к функции, а не к блоку. - Стоимость. Возврат значения ничего не стоит. Бросок исключения раскручивает стек и собирает трейс, в горячем цикле разница измерима.
- Композиция. Исключение вне
catchне живёт. Значение живёт: можно собрать[]error, вернуть ошибку из горутины через канал, отложить решение. - Умолчание. В Go ошибка по умолчанию на виду: чтобы взять результат, её придётся
присвоить, хотя бы в
_, а вызов без присваивания ловитerrcheck. В языках с исключениями по умолчанию она летит наверх, и «наверху» часто оказывается цикл, который всё съедает.
Честная критика — назови её сам
Код многословен: if err != nil { return nil, err } занимает заметную часть
файла, и легко получить лестницу из десяти проверок. Команда Go дважды пыталась это
исправить: check/handle (2018) и try() (2019) отклонили. В 2025 году
объявили, что синтаксического сахара не будет вовсе: любой вариант
прятал место, где принимается решение, а ценность как раз в нём.
«Panic в Go не аналог исключения. Это механизм для невозможных состояний, а не для управления потоком. Как только panic начинают ловить в бизнес-логике, возвращается ровно та проблема, от которой уходили: невидимый в сигнатуре путь выхода.»
error — интерфейс с одним методом
Error() string. Ошибкой является всё, что этот метод реализует.type error interface {
Error() string
}
Четыре способа
errors.New("текст")возвращает*errorString, внутри просто строка. Основное применение: sentinel-переменные уровня пакета.fmt.Errorf("...%v...", x)форматирует без цепочки. Внутри всё тот жеerrors.Newот готовой строки.fmt.Errorf("...%w...", err)оборачивает и возвращает*fmt.wrapErrorс методомUnwrap() error. С Go 1.20 можно несколько%w— тогда будетUnwrap() []error.- Свой тип: структура с полями и методом
Error(). Нужен, когда вызывающему требуются данные ошибки, а не только факт.
Почему errors.New возвращает указатель
Чтобы каждая созданная ошибка имела уникальную идентичность. Если бы возвращалось значение
структуры, две ошибки с одинаковым текстом из разных пакетов сравнивались бы как равные, и
errors.Is путал бы их.
func New(text string) error { return &errorString{text} }
a, b := errors.New("boom"), errors.New("boom")
fmt.Println(a == b) // false
Что ещё стоит упомянуть
- Sentinel объявляют через
var, а неconst: константа не может иметь интерфейсный тип. errors.Newаллоцирует. В горячем пути ошибку создают один раз при старте пакета, а не на каждый вызов.- Строку ошибки пишут с маленькой буквы и без точки — она почти всегда попадёт внутрь
другого сообщения (правило
ST1005изstaticcheck).
Интерфейс хранит пару (тип, данные). Если положить в него nil-указатель
конкретного типа, интерфейс станет ненулевым: err != nil будет
true, а вызов err.Error() закончится паникой. Поэтому функции всегда
объявляют возврат как error, а не как *MyErr.
func f() error { var e *MyErr; return e }
fmt.Println(f() == nil) // false
%w строит цепочку
и делает причину видимой для errors.Is/As; %v вклеивает только
текст и разрывает связь.
fmt.Errorf считает количество %w в формате. Ноль даёт
errors.New(готовая_строка), один — *fmt.wrapError с
Unwrap() error, два и больше (с Go 1.20) — *fmt.wrapErrors с
Unwrap() []error.
base := sql.ErrNoRows
w := fmt.Errorf("get user %d: %w", 42, base)
v := fmt.Errorf("get user %d: %v", 42, base)
w.Error() == v.Error() // true: сообщения совпадают
errors.Is(w, sql.ErrNoRows) // true
errors.Is(v, sql.ErrNoRows) // false
errors.Unwrap(w) // sql.ErrNoRows
errors.Unwrap(v) // nil
Как выбирать
%wбери по умолчанию внутри своего сервиса. Дляcontext.Canceled,context.DeadlineExceededи доменных sentinel-ов он обязателен: их проверяют выше по стеку.%vставят на публичной границе пакета, чтобы ошибка чужой библиотеки не стала частью твоего контракта. И там, где причина остаётся деталью реализации, которую ты собираешься менять.
«%w меняет публичный API. Обернув sql.ErrNoRows,
я разрешил всем вызывающим делать errors.Is(err, sql.ErrNoRows) на мой
результат. Если завтра я перееду с database/sql на pgx, их код
сломается, и компилятор об этом не предупредит — Is просто начнёт возвращать
false. Поэтому наружу я обычно отдаю свою доменную ошибку, а чужую оборачиваю
через %v или перевожу.»
Как оформлять текст обёртки
Формат "операция: причина", каждый слой добавляет одно короткое слово о себе
плюс идентификаторы. Слова failed to, error while не нужны:
после трёх обёрток получится «failed to X: failed to Y: failed to Z». Хороший результат
выглядит так: "profile.Show: userRepo.Get(42): dial tcp 10.0.0.5:5432: i/o timeout".
По одной строке видно и путь, и причину.
- «Стектрейс же появляется?» — нет. Стандартная библиотека кадры стека не
сохраняет. Цепочка
%wописывает логический путь по слоям, а не физический по стеку. Трейсы даётpkg/errors(устарел) или свой тип сruntime.Callers. %wс не-error аргументом даёт%!w(...)в тексте — ошибку формата, а не панику.go vetэто ловит.
Is — «это оно?», сравнение с конкретным значением.
As — «есть ли в цепочке ошибка такого типа, дай её мне», сравнение по типу с
записью в переменную. Оба обходят одну и ту же цепочку Unwrap.Алгоритм Is
- Если
targetсравним, проверитьerr == target. - Если у
errесть методIs(error) bool, спросить его. - Спуститься:
Unwrap() error— на следующее звено;Unwrap() []error— рекурсивно во все ветки (обход в глубину). - Нет
Unwrapили он вернулnil—false.
Алгоритм As
Обход тот же, только на каждом звене рефлексия проверяет, присваивается ли значение
звена типу, на который указывает target. Если совпало, значение записывается,
и функция возвращает true. Расширять поведение можно методом As(any) bool.
type NotFoundError struct {
Kind string
ID int64
}
func (e *NotFoundError) Error() string { return e.Kind + " not found" }
var ErrNoAccess = errors.New("no access")
func find(id int64) error {
inner := &NotFoundError{Kind: "user", ID: id}
return fmt.Errorf("service.Load: %w", fmt.Errorf("repo.Get: %w", inner))
}
err := find(42)
fmt.Println(err) // service.Load: repo.Get: user not found
// Is проверяет факт (здесь false: sentinel в цепочке нет)
fmt.Println(errors.Is(err, ErrNoAccess)) // false
// As достаёт данные с любой глубины
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println(nf.Kind, nf.ID) // user 42
}
// Is умеет и с деревом Join
joined := errors.Join(ErrNoAccess, err)
fmt.Println(errors.Is(joined, ErrNoAccess)) // true
fmt.Println(joined) // две строки: Join склеивает через \n
// no access
// service.Load: repo.Get: user not found
// Unwrap снимает ровно одно звено за раз:
fmt.Println(errors.Unwrap(err)) // repo.Get: user not found
fmt.Println(errors.Unwrap(errors.Unwrap(err))) // user not found
Требования к target у As — это паника, а не ошибка компиляции
var nf *NotFoundError
errors.As(err, &nf) // ок
// Точные тексты паник (go1.27), у обеих префикс "errors: ":
errors.As(err, nf) // если nf == nil:
// panic: errors: target must be a non-nil pointer
// если nf уже заполнен, паника другая, потому что
// *target это NotFoundError, а error реализует *NotFoundError:
// panic: errors: *target must be interface or implement error
errors.As(err, &struct{}{}) // panic: errors: *target must be interface or implement error
// target может указывать на интерфейс, так ловят «любой таймаут»:
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() { /* ретраим */ }
Go 1.26: errors.AsType — то же самое, но дженериком
Сильный ход на собесе: сказать, что с Go 1.26 у As есть типобезопасная
форма, и объяснить, чем она удобнее.
func AsType[E error](err error) (E, bool)
// вместо двух строк с переменной наружу:
if nf, ok := errors.AsType[*NotFoundError](err); ok {
fmt.Println(nf.Kind, nf.ID) // user 42
}
// с интерфейсом работает так же:
if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() { /* ретраим */ }
- Не паникует. Тип задан параметром, констрейнт
errorпроверяет компилятор, поэтому обеих паникAs(«target must be a non-nil pointer», «*target must implement error») здесь просто не может быть. - Быстрее. Внутри обычное
err.(E), безreflectlite, которыйAsдёргает на каждом шаге цепочки. - Не нужна переменная заранее — результат живёт внутри
if. - Обход цепочки, дерево
Unwrap() []errorи методAs(any) boolу звена работают ровно как у старогоAs.
Старый errors.As остаётся и устаревшим не объявлен: он нужен коду, который
собирается на Go 1.25 и старше.
- В
Isесть проверкаreflect.TypeOf(target).Comparable(): иначе сравнение==с несравнимым типом (структура со слайсом) паникнуло бы. - Метод
Is(error) boolу своего типа позволяет сделать «семейство»:errors.Is(err, &APIError{Code: 429})сработает для любой ошибки с этим кодом, а не только для того же указателя. errors.Unwrapделает один шаг и понимает только формуUnwrap() error. Для join-ошибки он вернётnil.- Обе функции появились в Go 1.13, поддержка
Unwrap() []error— в 1.20, дженерик-формаerrors.AsType[E]— в 1.26.
Нужен только ответ «да/нет» и есть заранее известное значение — Is.
Нужны поля (какое поле не прошло, какой код, сколько ждать) — As.
Никогда не == и никогда не по тексту.
errors.Is. Дёшево и понятно, но несёт ровно один бит информации
и намертво связывает слои.var ErrNotFound = errors.New("not found") // var, не const
Примеры из стандартной библиотеки
io.EOF: данные кончились. И это не сбой, а нормальное завершение. Отсюда идиомаif err == io.EOF { break }в цикле чтения. А вотio.ErrUnexpectedEOFуже сбой: поток оборвался посреди структуры.sql.ErrNoRowsозначает, чтоQueryRow().Scan()ничего не нашёл.sql.ErrTxDoneвозвращается, когда транзакция уже завершена.fs.ErrNotExist/os.ErrNotExist,fs.ErrPermissionпроверяют только черезerrors.Is, потому что реально возвращается*fs.PathError, обёрнутый вокругsyscall.ENOENT.context.Canceledиcontext.DeadlineExceeded.http.ErrServerClosed—ListenAndServeзавершился штатно из-заShutdown; в логе это не ошибка.
Минусы
- Связность. Чтобы сравнить с
sql.ErrNoRows, HTTP-хендлер обязан импортироватьdatabase/sql. Инфраструктура протекает через все слои. - Нет данных. Только факт. «Какого поля не хватило», «какой лимит», «через сколько повторить» — не выразить.
- Публичный API навсегда. Смена условия возврата ломает пользователей молча:
компилятор ничего не скажет,
errors.Isпросто станетfalse. - Хрупкость к обёрткам. Одна библиотека по дороге обернула через
%v— все проверки выше перестали работать. - Глобальное изменяемое состояние.
io.EOFобъявлен черезvar, и технически его можно переприсвоить из чужого пакета. В проде не случается, но как аргумент против звучит.
Заводят небольшой стабильный набор доменных sentinel-ов (domain.ErrNotFound,
ErrConflict, ErrForbidden) и на границе репозитория переводят в них
инфраструктурные ошибки. Где нужны данные, берут кастомный тип и errors.As.
Иногда экспортируют предикат func IsNotFound(err error) bool: его
реализацию можно менять, не ломая пользователей.
«Почему errors.Is(err, os.ErrNotExist), а не err == os.ErrNotExist?»
Потому что os.Open возвращает *fs.PathError, внутри которого
лежит syscall.ENOENT, а ENOENT своим методом
Is объявляет себя равным fs.ErrNotExist. Прямое
сравнение всегда даст false.
Unwrap() error подключает твой тип к цепочке, чтобы errors.Is/As
видели то, что лежит под ним.Когда заводить тип
- Валидация: нужно имя поля и причина, иногда список.
- Ошибки внешнего API: код, тело ответа, заголовок
Retry-After. - Ошибки БД: операция, таблица, идентификатор — для лога и метрик.
- Транзиентность: флаг «можно ретраить» и через сколько.
- Когда набор ошибок большой и sentinel-ов стало бы двадцать штук.
type ValidationError struct {
Field string
Reason string
Err error // причина, если есть
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %q: %s", e.Field, e.Reason)
}
func (e *ValidationError) Unwrap() error { return e.Err }
// использование
var ve *ValidationError
if errors.As(err, &ve) {
w.WriteHeader(422)
json.NewEncoder(w).Encode(map[string]string{"field": ve.Field, "reason": ve.Reason})
}
Зачем именно Unwrap
Без него цепочка кончается на твоём типе. Всё, что было под ним, становится невидимым:
errors.Is(err, context.DeadlineExceeded) выше по стеку вернёт
false, ретрай-логика перестанет понимать, что это был таймаут, а маппинг на
HTTP отдаст 500 вместо 504. Unwrap буквально говорит: «пропусти проверку дальше,
вглубь».
- Смешанные ресиверы. Если
Error()объявлен на*MyErr, тоMyErrне реализуетerror, иerrors.As(err, &e)сvar e MyErrупадёт с паникой (go vetпоймает это заранее). Выбирай один вариант (обычно указательный) и создавай ошибку только через&MyErr{...}. - Typed nil. Конструктор должен возвращать
error, а не*MyErr, иначеerr != nilстанет истиной при пустой ошибке. Error(), который паникует на нулевых полях. Метод вызывается из логгера и изfmtв самый неудачный момент — он должен быть безопасен всегда.
Is(target error) boolзадаёт своё правило сравнения. Так реализуют «равенство по коду», а не по указателю.As(target any) boolделает то же для приведения. Нужен редко: например, когда ошибка хочет притворяться типом из другого пакета.Unwrap() []errorпригодится, если твой тип агрегирует несколько причин (Go 1.20+).Timeout() bool/Temporary() boolвходят в интерфейсnet.Error. Реализовав оба, ты попадаешь под чужие проверкиerrors.As(err, &netErr), а одинTimeout()— под проверки черезinterface{ Timeout() bool }.Temporary()при этом объявлен deprecated — на него полагаться не стоит.
errors.Join(errs...) склеивает несколько ошибок в
одну, у которой есть Unwrap() []error. Цепочка превращается в дерево, а
Is/As обходят его в глубину.func Join(errs ...error) error
// nil-аргументы отбрасываются
// если ненулевых не осталось, возвращается ровно nil
// Error() склеивает сообщения через "\n"
Где применяется
- Валидация целиком. Пользователь получает список всех проблем формы, а не первую.
- Закрытие ресурсов.
errors.Join(srv.Shutdown(ctx), db.Close(), mq.Close())закроет всё и не потеряет ни одной ошибки. - Не потерять ошибку
Closeвdeferчерез именованный возврат:defer func(){ err = errors.Join(err, f.Close()) }(). Раньше эту ошибку почти всегда молча теряли. - Параллельные задачи, когда важны все сбои, а не первый (в отличие от
errgroup, который хранит только первую ошибку).
func (r Req) Validate() error {
var errs []error
if r.Name == "" { errs = append(errs, fmt.Errorf("name: %w", ErrRequired)) }
if r.Age < 0 { errs = append(errs, fmt.Errorf("age: %w", ErrRange)) }
return errors.Join(errs...) // пусто → nil, проверять длину не нужно
}
err := Req{}.Validate()
errors.Is(err, ErrRequired) // true
errors.Is(err, ErrRange) // true
errors.Unwrap(err) // nil: одноуровневый Unwrap с деревом не работает
Тот же эффект через fmt.Errorf
С Go 1.20 в одном fmt.Errorf можно использовать несколько %w.
Разница: Errorf даёт осмысленное сообщение с контекстом, Join —
механическую склейку через перевод строки.
err := fmt.Errorf("create user: %w и %w", ErrRequired, ErrRange)
errors.Unwrap(joined)вернётnil: у join-ошибки нетUnwrap() error, только слайс-форма.- Join-ошибка не равна ни одной из исходных через
==. - Код, написанный до 1.20 и разворачивающий цепочку вручную циклом
errors.Unwrap, дерево не обойдёт. Свой обход должен уметь обе формы. - Стандартной функции «достать все листья» нет — пишется рекурсией на десяток строк.
go.uber.org/multierr и hashicorp/go-multierror. Оба живы и
встречаются в проде, но для нового кода лучше стандартный Join:
его понимают errors.Is/As без адаптеров.
%w на каждом слое, логируется
ровно один раз там, где перестаёт подниматься, и там же превращается в код ответа.Кто что делает
- Repository. Переводит
sql.ErrNoRowsи коды драйвера в доменные ошибки. Технические ошибки оборачивает с именем операции и идентификатором. Не логирует. - Service / usecase. Оборачивает с именем сценария, принимает бизнес-решения (можно ли деградировать, нужен ли fallback). Не логирует.
- Transport (HTTP/gRPC/consumer). Единственная точка, где ошибка умирает: пишут
структурированный лог, увеличивают метрику, выбирают статус и собирают тело
ответа. Наружу уходят код и
trace_id, никаких внутренних деталей.
Почему «логируй или возвращай — не оба»
Если каждый слой пишет в лог и пробрасывает ошибку дальше, один сбой БД даёт четыре записи об одном событии. Алерт «ошибок больше N в минуту» срабатывает в четыре раза раньше, дедупликация по сообщению не работает, а при разборе инцидента непонятно, сколько на самом деле было отказов. Вдобавок дублируются PII и растёт объём логов. Исключение из правила ровно одно: если ты ошибку обработал (сходил в кэш, вернул дефолт) и не возвращаешь наверх — вот тогда логируй, потому что иначе о ней никто не узнает.
// repository
if errors.Is(err, sql.ErrNoRows) {
return u, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
}
// service
return View{}, fmt.Errorf("profile.Show: %w", err)
// transport
slog.ErrorContext(ctx, "request failed", "err", err, "path", r.URL.Path)
writeJSON(w, httpStatus(err), errBody{Code: code, TraceID: traceID})
Маппинг
Хватает одного switch с errors.Is в транспортном слое, и проверки
статусов не расползаются по коду. Основные соответствия: ErrNotFound → 404 /
NotFound; ErrConflict → 409 / AlreadyExists;
ErrInvalidInput → 422 / InvalidArgument;
ErrForbidden → 403 / PermissionDenied; rate limit → 429 /
ResourceExhausted; ErrUnavailable → 503 /
Unavailable; DeadlineExceeded → 504; всё непонятное → 500 /
Internal.
В gRPC возвращают status.Error(codes.NotFound, "..."), а детали
кладут в status.WithDetails (типы из google.rpc.ErrorInfo,
BadRequest). В HTTP-мире тем же занимается RFC 9457 (раньше RFC 7807) application/problem+json.
- «Отдельно обрабатываю
context.Canceled: это не ошибка сервиса, клиент ушёл сам. Логирую на уровнеinfoи не считаю в SLI, иначе при каждом всплеске отмен горит алерт.» - «В логе всегда
trace_idиз контекста — по нему саппорт находит запрос за секунды.» - «500-е логирую с полной ошибкой, 4xx — на info: 4xx это норма работы API, а не сбой.»
- «Ошибку в ответ клиенту не пишу никогда: там бывают имена таблиц, хосты и куски запроса.»
1. _ = err и голый f, _ := os.Open(...)
Самая дорогая строка в кодовой базе: она превращает отказ в тихую порчу данных.
json.Unmarshal вернул ошибку — структура осталась нулевой, и дальше по коду
«пользователь с id 0». Классика: не проверили ошибку у tx.Commit(), и
транзакция не применилась, а сервис ответил 200.
Ловится линтером errcheck (входит в golangci-lint). Если
игнорировать действительно надо, пиши явно и с комментарием, почему:
//nolint:errcheck // best-effort метрика. Молчаливый
defer f.Close() для файла, открытого на чтение, допустим; для файла
на запись — уже нет, там теряется ошибка сброса буфера.
2. Сравнение по тексту
// никогда
if strings.Contains(err.Error(), "no rows") { ... }
if err.Error() == "connection refused" { ... }
Текст ошибки не входит в контракт ни одного пакета: он меняется в патч-релизах,
зависит от версии драйвера, от локали ОС для syscall-ошибок и от того, обернул
ли кто-то ошибку по дороге. Такой код работает у тебя и разваливается на проде.
Правильно: errors.Is для sentinel, errors.As для типа,
errors.Is(err, syscall.ECONNREFUSED) для системных.
3. Паника вместо ошибки
Библиотека, которая паникует на невалидном вводе, отнимает у вызывающего право выбрать политику. Ошибка чтения конфига, недоступная БД, битый JSON от клиента относятся к ожидаемым ситуациям, у них есть обработка. Panic оставляют для нарушенных инвариантов программы (подробнее в главе 4.2).
Ещё пять, которые стоит назвать
- Логировать и возвращать одну и ту же ошибку, плодя дубли в логах и в алертах.
return errбез контекста на каждом слое: в логе окажется голое «not found» без единой подсказки, где именно.- Возврат конкретного типа вместо
errorдаёт typed nil, иerr != nilстановится истиной при отсутствии ошибки. - Оборачивать всё через
%wбез разбора. Так ошибка чужой библиотеки становится частью твоего публичного контракта. - Отдавать
err.Error()клиенту — наружу утекут имена таблиц, хосты, куски SQL и внутренняя структура сервиса.
«У нас в CI включены errcheck, govet, staticcheck,
wrapcheck и err113 — последний как раз ругается на динамические
ошибки и сравнение ошибок через ==. Плюс правило на ревью: обёртка
обязана добавлять имя операции, иначе она бесполезна.»
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).
Отложенные вызовы остальных горутин не выполняются — никакого «корректного
завершения» при панике нет.
defer,
и останавливается ровно в том кадре, чей отложенный вызов сделал 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 спасёт? |
|---|---|---|
| Конкурентная запись в map | fatal error: concurrent map writes | нет |
| Конкурентные чтение и запись map | fatal 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 и подобные | нет |
Любая обычная panic | panic: ... + 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 с дублирующимся паттерном |
Глобальное состояние собирается один раз при старте. Конфликт значит, что бинарник собран неверно, и работать он не будет |
Пару «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
выполняется до 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
}
Строка 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 убит. Соединение закрывается, клиент открывает новое, и на всплеске паник это заметная лишняя нагрузка.
// 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)
})
}
}
Ставь его самым внешним, но после middleware, которое кладёт trace_id
в контекст, иначе в логе паники не будет по чему искать. Рабочий порядок снаружи внутрь:
RequestID → Recoverer → Logger →
Metrics → Timeout → Auth → RateLimit
→ хендлер. Если 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,
ключи и чужие персональные данные.
Паника из сторонней библиотеки
Шаги идут по убыванию правильности, а не удобства.
- Локализовать и завести issue. Паника в чужом коде почти всегда означает баг библиотеки либо неверное использование её API. Воспроизведи минимальным тестом.
- Запинить или форкнуть версию. Если баг известен и уже починен, обновись; если нет,
сделай форк с патчем и
replaceвgo.mod. Это честнее, чем прятать. - Обернуть вызов в собственный recover временно, с метрикой и алертом, чтобы «мы затыкаем чужую панику N раз в сутки» было видно.
- Изолировать процессом, если библиотека совсем нестабильна (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) // паникует на некоторых битых входах
}
- Не откатывает состояние. Паника могла случиться посреди обновления внутренней структуры библиотеки: половина полей записана, мьютекс захвачен и уже никогда не будет отпущен. Дальше объект надо считать мёртвым и создавать заново, а не переиспользовать.
- Не ловит fatal error. Гонка по map внутри библиотеки, дедлок, OOM — процесс всё равно упадёт.
- Не ловит панику из горутин, которые библиотека запустила сама. А их ты не контролируешь вообще.
- Не ловит краш из cgo. SIGSEGV в сишном коде приходит как сигнал, а не как паника Go:
рантайм печатает
signal arrived during cgo executionи завершается.
Паника в чужой горутине: почему recover бессилен
На этом вопросе проваливаются чаще всего. recover() читает gp._panic
текущей горутины. У новой горутины свой g, своя цепочка _defer
и свой список паник. Кадры родителя в ней не участвуют, да и «родителя» у горутины
формально нет: go f() не создаёт никакой связи, кроме записи в трейсбеке
created by ....
// Единственная точка запуска горутин в проекте.
// Правило на ревью: голый `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 у 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)
}
Классический прод-инцидент: воркер Kafka или RabbitMQ паникует на «ядовитом» сообщении (poison message) и не подтверждает его — не коммитит офсет или не шлёт ack, — сообщение приходит снова, воркер паникует снова. Пока recover не поставлен, консьюмер целиком падает и перезапускается по кругу; после recover крутится одно и то же сообщение и жрёт CPU. Лечится счётчиком доставок и DLQ: после N попыток сообщение уходит в отдельную очередь, а консьюмер идёт дальше.
Panic и исключения других языков
Формально panic/recover устроены как исключения: та же раскрутка
стека с перехватом. Отличаются они культурой применения
и парой важных ограничений.
| Свойство | Go | Java / C# / Python | Rust |
|---|---|---|---|
| Где можно перехватить | Только внутри 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Разграничение
Разделять надо не по «редкости» и не по «серьёзности», а по вопросу: может ли код
продолжать работать корректно. Битый 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?» Да,
обязательно: условия паники входят в контракт.
Почему это правило
Библиотека не знает, в каком контексте её зовут. Один вызывающий на битом входе вернёт 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
в контекст. Рабочий порядок: RequestID → Recoverer →
Logger → Metrics → Timeout → Auth
→ хендлер. Поставишь 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,
запинить или форкнуть версию; для совсем нестабильных библиотек — изоляция отдельным процессом.Порядок действий
- Воспроизвести минимальным тестом. Половина «паник библиотеки» оказывается неправильным использованием её API, например конкурентным доступом к объекту, который по документации не потокобезопасен.
- Обновиться / запинить / форкнуть.
replaceвgo.modна свой форк с патчем честнее, чем глушить симптом. - Обернуть в recover, причём точечно: вокруг одного вызова, а не «на всякий случай»
вокруг всего слоя. И с метрикой
lib_panics_total{call="..."}, чтобы на дашборде было видно: «мы затыкаем чужую панику 400 раз в сутки». - Изолировать процессом 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)
}
Классический инцидент на at-least-once: воркер паникует на «ядовитом» сообщении, офсет не коммитится, сообщение приходит снова. Без recover падает и перезапускается весь консьюмер по кругу; с recover крутится один и тот же офсет и жжёт CPU. Лечится только счётчиком доставок и DLQ: после N попыток сообщение уходит в отдельный топик, а обработка идёт дальше. Плюс нужна метрика на размер DLQ, иначе туда молча утечёт бизнес.
runtime.gopanic, раскручивает
стек, выполняет defer и может быть перехвачена. Fatal error печатает рантайм
напрямую через throw: раскрутки нет, defer не выполняются,
recover не сработает никогда.Обычные паники рантайма
| Причина | Сообщение |
|---|---|
| Запись в nil map | panic: 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 |
| Разыменование nil | panic: runtime error: invalid memory address or nil pointer dereference |
| Деление на ноль | panic: runtime error: integer divide by zero |
| Type assertion | panic: interface conversion: interface {} is string, not int |
| Каналы | panic: close of closed channel, close of nil channel, send on closed channel |
| WaitGroup | panic: sync: negative WaitGroup counter |
Fatal error — не перехватывается
fatal error: concurrent map writesиconcurrent map read and map writefatal error: all goroutines are asleep - deadlock!fatal error: sync: unlock of unlocked mutexfatal 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 заходят с другой стороны и защищают тебя от чужой медленности и от собственной перегрузки.
Ретраить или нет: классификация ошибок
Ретраить стоит, только когда одновременно выполнены два условия: ошибка транзиентная (повтор той же операции может дать другой результат) и операция идемпотентна (повтор безопасен, даже если первая попытка на самом деле прошла, а ответ потерялся). Нарушишь любое — и ретрай станет либо бесполезным, либо вредным.
Коварнее всего таймаут. Запрос мог дойти и выполниться, а ответ застрять на обратном пути, и клиент этого не узнает. Ретрай после таймаута и есть тот случай, ради которого нужна идемпотентность.
Что ретраить: конкретика по транспортам
| Слой | Ретраить | Не ретраить |
|---|---|---|
| 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 |
23505unique_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 снаружи, ровно один эффект внутри |
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
| Стратегия | Формула | Когда брать |
|---|---|---|
| Без 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
}
Первое: тело ответа. Перед повтором его надо дочитать и закрыть
(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 делает только хуже.
Разбор 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, из-за чего под нагрузкой
соединения постоянно пересоздаются, а не переиспользуются.
// Клиент: таймауты на каждой фазе, а не один общий.
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)
Таймаут ставят исходя из наблюдаемой латентности: примерно 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 | Дедлайн запроса | Самый внешний. Задаёт общий бюджет, за который не может выйти ничего внутри |
| 2 | Retry | Снаружи брейкера, чтобы серия попыток укладывалась в один логический вызов.
Обязан считать ErrOpenState неретраибельной ошибкой |
| 3 | Circuit breaker | Внутри retry — тогда он видит каждую попытку и открывается по реальной статистике, а не по одной «серии» |
| 4 | Bulkhead | Ограничивает параллелизм к зависимости. Работает раньше брейкера: ловит замедление, а не отказ |
| 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 это класс
40xxx—40001 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 мало
Зависимость упала или перегрузилась — значит, все клиенты получили ошибку почти
одновременно. Дальше каждый честно ждёт base * 2^attempt. Формула
детерминированная, стартовая точка у всех общая, поэтому вторая попытка у всей тысячи
придётся на один и тот же момент. Получается thundering herd: ровно в секунду
восстановления зависимость получает пиковый залп и падает снова. Такой цикл
держится часами — это классический metastable failure: исходной
причины давно нет, а система из ямы не выходит, потому что её держит собственный
ретрай-трафик.
Jitter добавляет в паузу случайность и размазывает попытки по интервалу: вместо пика зависимость получает ровный поток, который способна переварить. Коротко: backoff уменьшает частоту повторов, jitter уменьшает их корреляцию. Нужны оба.
| Вид | Формула | Свойства |
|---|---|---|
| Без jitter | min(cap, base*2^n) |
Максимальная синхронизация клиентов. Годится только для одного клиента в системе |
| Full jitter | rand(0, min(cap, base*2^n)) |
Лучшее разнесение и наименьшее суммарное число запросов. Дефолт. Минус — иногда спит почти ноль |
| Equal jitter | h + rand(0, h), где h = min(cap,base*2^n)/2 |
Гарантирует минимальную паузу. Хорош, когда мгновенный повтор вреден сам по себе |
| Decorrelated | min(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 |
Без него брейкер вырождается в «подождали и снова уронили»: после 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 и часовой недоступности набегают сотни тысяч горутин: сервис умирает не от ошибок зависимости, а от собственной памяти. Таймаут превращает неопределённое ожидание в предсказуемую ошибку, а предсказуемую ошибку уже можно обработать: ретраем, брейкером, деградацией.
- У
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)
}
Таймаут берут из наблюдаемой латентности: примерно 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 или нет»: уровней несколько, и выбирают их осознанно. На собесе полезно перечислить уровни по убыванию качества ответа.
- Протухший кэш — отдаём последнее известное значение и помечаем его
stale(stale-while-revalidate). Годится для медленно меняющихся данных: каталог, курсы, настройки, фичефлаги. - Дефолт, то есть заранее посчитанная константа или неперсональный вариант. В рекомендациях, баннерах, ранжировании «топ популярного» лучше пустого блока.
- Урезанный ответ: 200 без части полей плюс признак
partial: true. Карточка товара без блока «с этим покупают» вполне годится, а 500 нет. - Можно отложить работу: положить в очередь или outbox и ответить
202 Accepted. Так делают там, где операция допускает асинхронность: письма, бонусы, индексация. - Read-only режим — запись отключена, чтение идёт с реплики. При отказе мастера это заметно лучше полного 503.
- И последний уровень, честный отказ: явная ошибка с понятным текстом и
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 в безопасности и почти всегда уязвимость). Хорошая формулировка для собеса: деградировать можно полноту ответа, но не его корректность.
Почему они не противоречат друг другу
Медленный отказ собирает худшее из обоих миров: пользователь всё равно не получит ответа,
но ресурсы будут заняты до конца таймаута, а ретраи придут уже на исчерпанный пул.
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 брокер. Поэтому сначала делаем операцию идемпотентной и только потом включаем ретраи. В обратном порядке мы, по сути, уже проектируем двойные списания.»