voidlearn
 

4.2panic vs error

Отделим ожидаемые ошибки от нарушений инварианта и проследим, где recover способен восстановить управление.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ставь его самым внешним, но после middleware, которое кладёт trace_id в контекст, иначе в логе паники не будет по чему искать. Рабочий порядок снаружи внутрь: 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, ключи и чужие персональные данные.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    n = 1
    panic("boom")
}

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

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

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

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

Вопросы

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

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

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

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

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

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

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

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

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

На чём ловят

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

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

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

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

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

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

type bailout struct {
    err error
}

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

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

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

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

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

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

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

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

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

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

Почти самым внешним, но после middleware, которое кладёт trace_id в контекст. Рабочий порядок: 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, запинить или форкнуть версию; для совсем нестабильных библиотек - изоляция отдельным процессом.

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

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

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

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

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

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

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

Механика

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

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

Что делать

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

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

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

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

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

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

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

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

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

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

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

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

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

Так сделано намеренно: перечисленные состояния означают, что инварианты рантайма или памяти уже могли быть нарушены. Продолжать работу опаснее, чем упасть, поэтому рантайм вызывает fatal или throw (первый - когда виноват пользовательский код, как с гонкой по map), а не 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 вероятностная: рантайм смотрит флаг writing в заголовке map (до Go 1.24 - hashWriting) и может гонку не заметить. Гарантированно ловит только -race. В Go 1.24 map переехали на Swiss Tables, но проверка осталась ровно та же.

Что стоит запомнить

  • Panic раскручивает стек текущей goroutine.
  • recover срабатывает только внутри deferred функции той же goroutine.
  • Ожидаемый сбой входа выражается через error.

Частые ошибки

  • Main не перехватит panic worker goroutine.
  • Middleware не делает допустимым panic на каждом ожидаемом пути.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи цепочку ошибки

В какой goroutine сработает recover и сможет ли выполнение main продолжиться?

func work() {
  defer func() { fmt.Println("recovered", recover() != nil) }()
  panic("broken invariant")
}
func main() { work() }
Ответ и разбор

В defer вызывается recover в той же горутине, которая паникует. Печатается recovered true, после чего main продолжает выполнение.

Код-ревью

2. Найди дефект и исправь

Автор ожидает, что defer в main перехватит panic worker. Объясни проблему и предложи путь передачи сбоя.

func main() {
  done := make(chan struct{})
  defer func() { fmt.Println(recover()) }()
  go func() { panic("worker failed") }()
  <-done // канал намеренно никто не закрывает
}
Ответ и разбор

recover действует только в той же goroutine: defer в main не перехватит panic worker. Main заблокирован на done, поэтому не завершится раньше worker; panic в worker завершит весь процесс. Для штатного сбоя не паникуй: верни error через канал или errgroup. Если recovery нужен как страховка от чужого кода, ставь его внутри worker goroutine и передай восстановленную ошибку владельцу. Здесь ожидание на открытом канале не лечит panic, а только исключает гонку с ранним выходом main.

Применение

3. Разбери сценарий

Функция парсит внешние данные от клиента. Как решить, где вернуть error, а где panic?

Ответ и разбор

Неверные/неполные пользовательские данные ожидаемы, поэтому парсер возвращает error с позицией или причиной. Panic оставь для нарушения внутреннего инварианта, которое означает ошибку программы. На публичной границе не допускай утечки panic в контракт ответа; HTTP middleware может вернуть 500, но не превращай ожидаемые ошибки в panic.

Применение

4. Выбери границу обработки

Обработчик мутирует структуру заказа, затем сторонний callback паникует посреди операции. Нужно сохранить инвариант суммы строк и total. Можно ли просто recover и продолжить?

Ответ и разбор

Нет. Recover останавливает раскрутку стека, но не отменяет уже сделанные изменения и внешние эффекты. Работай с глубокой изолированной копией заказа, примени к ней callback и проверь total == sum(lines). Затем опубликуй новую версию под lock после проверки, что исходная версия не изменилась; при конфликте отклони операцию или повтори её. Не держи lock во время произвольного callback. Если callback уже отправил запрос или записал данные вовне, локальный rollback этого не отменит. Recovery на границе той же goroutine должен вернуть ошибку, а не успех.