---
title: "Ошибки: SkipRetry и Fatal"
description: "Asynq SkipRetry: ошибки задач go, которые не должны повторяться — архив вместо ретрая, свой тип фатальной ошибки, RevokeTask и ErrorHandler. С примерами."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/14-oshibki-handlera/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 18
---

# Ошибки: SkipRetry и Fatal

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 14 из 25. ~18 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/14-oshibki-handlera/*

Ошибка сети стоит ретрая, ошибка в payload — нет: повтор двадцать пять раз не починит битый JSON, и asynq SkipRetry говорит очереди «не повторять» — задача уйдёт в архив сразу. Разберём фатальные ошибки задач go, свой тип с решением о повторе, тихое удаление RevokeTask и ErrorHandler.

Задача с опечаткой в payload прожила двадцать суток: двадцать пять попыток, каждая — с той же ошибкой json.Unmarshal, каждый ретрай — с растущей паузой. Хендлер честно возвращал ошибку, очередь честно повторяла — и никто не совершил ничего плохого, кроме того, что ошибка не сказала «повтор бесполезен». Ошибки в фоновых задачах бывают двух сортов, и судьба у них разная: сбой сети или недоступная база лечится повтором, а битые данные, отсутствие записи, отказ в доступе — нет, сколько ни повторяй. Механика asynq по умолчанию не различает эти сорта: любая не-nil ошибка — повод для ретрая до исчерпания MaxRetry. Различать должны вы, и инструмент для этого — сентинелл-ошибка SkipRetry, свой тип ошибки с решением о повторе и вежливый ErrorHandler для метрик. В уроке: как устроен путь ошибки от ProcessTask до архива, как пометить фатальную ошибку и как не похоронить живую задачу по ошибке.

### Asynq SkipRetry: команда «не повторять»

Asynq SkipRetry — сентинелл-ошибка, которую хендлер возвращает вместо обычной: очередь не станет назначать ретрай и сразу отправит задачу в архив. Возврат простой: return asynq.SkipRetry, если нечего добавить к делу, или fmt.Errorf("json.Unmarshal failed: %v: %w", err, asynq.SkipRetry), если к сентинеллу хочется приклеить контекст — обёртка через %w работает, и errors.Is находит SkipRetry внутри цепочки. Механика внутри библиотеки честная: фильтр после хендлера проверяет ошибки через errors.Is, поэтому любые ваши обёртки и Join-ы не помешают распознаванию. Два момента, о которых часто спотыкаются. Первый: SkipRetry — это всё-таки ошибка, а не успех; задача считается упавшей, ErrorHandler получит её, счётчик Failed очереди вырастет — «не повторять» не значит «всё хорошо». Второй: архив — не мусорка; задача в нём сохранена для осмотра, и её можно запустить вручную, когда причину починят. Порядок действий при фатальной ошибке поэтому такой: пометить SkipRetry, увидеть задачу в архиве, починить данные, запустить вручную — полный цикл вместо двадцати суток ретраев.

### Хендлер с фатальной веткой

```go
func handleWebhook(ctx context.Context, t *asynq.Task) error {
    var p WebhookPayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        // битые данные повтор не починит — в архив
        return fmt.Errorf("decode payload: %v: %w", err, asynq.SkipRetry)
    }
    if p.EventID == "" {
        return fmt.Errorf("empty event_id: %w", asynq.SkipRetry)
    }
    return deliver(ctx, p)
}
```

Обе фатальные ветки оборачивают причину через %w с SkipRetry: архив получит задачу с осмысленным текстом ошибки, а errors.Is найдёт сентинелл в цепочке. Обычный сбой сети в deliver возвращается как есть — его повторяет очередь по обычной политике из шестого урока.

### Ошибки задач go: ретраибельные против фатальных

Ошибки задач go раскладываются по одному вопросу: изменит ли повтор исход. Ретраибельны всё, что про «сейчас не работает»: недоступная сеть и таймауты, пятьсот-коды, блокировки базы, исчерпанные квоты с окном ожидания. Фатальны всё, что про «так не бывает»: битый payload и неожиданный формат, отсутствие записи, которую никто не заведёт, нарушение инварианта, отказ в доступе по правам. Проверка перед возвратом ошибки простая: «представьте, что через пять минут всё здорово — изменится ли результат?» Если нет — это SkipRetry, а не ретрай. На границе живут случаи, где ответ зависит от контекста: отсутствие записи в базе для вебхука «заказ создан» — фатально, если заказ и правда не придёт, и ретраибельно, если вы читаете реплику с отставанием; денежная операция с неясным исходом — не ошибка вовсе, а повод для проверки состояния, о чём говорит восемнадцатый урок об идемпотентности. Ещё две пограничные категории стоит проговорить отдельно. Работа уже сделана — «письмо отправлено» по повторному вебхуку: повтор не нужен, но и архив не нужен, потому что ничего не сломалось; это территория RevokeTask из конца урока. Деградация, которая сама пройдёт, — плавающий лимит соединений, переполненный диск: здесь ретрай прав, но паузу должна диктовать ваша RetryDelayFunc из тринадцатого урока, а не слепая экспонента. Грубая ошибка противоположного сорта — оборачивать в SkipRetry всё подряд: тогда настоящий сбой сети уйдёт в архив с первой же неудачи, и очередь молча перестанет выполнять свою главную работу.

### Проверка: какая ошибка ретраибельная?

**Вопрос:** Какую из ошибок стоит вернуть как есть, оставив ретрай очереди?

- json.Unmarshal вернул «invalid character 'x'» на payload задачи
- ✅ HTTP-вызов упал с context deadline exceeded на таймауте сети
- Заказ с переданным ID не найден в базе, ID из payload задачи
- Пользователь в payload забанен навсегда: рассылка ему запрещена

Таймаут сети — классический ретраибельный случай: через пять минут всё может быть здорово, и повтор изменит исход. Остальные три — фатальные: битые данные и запреты повтором не лечатся, их место в архиве через SkipRetry.

### Свой тип ошибки с решением о повторе

В реальном проекте хендлеры обёртывают сервисный слой, и решение «фатально или нет» хочется принимать в одном месте, а не в каждом return. Идиома — свой тип ошибки с классификацией внутри: type AppError struct{ Err error; Permanent bool } с методом Error и, если нужно, Unwrap. Сервисный слой помечает Permanent там, где знает это лучше всех: валидация, отсутствие записи, отказ в доступе. Хендлер заканчивается одной точкой сборки: если errors.As находит AppError с Permanent — вернуть обёртку со SkipRetry, иначе вернуть ошибку как есть. Так знание о повторах не расползается: сервис не знает про asynq, asynq не знает про ваш бизнес, а мост между ними — десять строк адаптера. Внедряйте постепенно: один адаптер в самом шумном хендлере, затем метрики по классам из ErrorHandler, затем общий пакет ошибок для всех воркеров — миграция без большого переписывания. Тот же адаптер — удобное место для декора контекста: добавить идентификатор задачи из GetTaskID, счётчик попытки из GetRetryCount, чтобы текст ошибки в архиве был самодостаточным для разбора. Имена и форму подбирайте под проект: где-то хватит булева поля, где-то — enum из трёх классов «сеть, данные, бизнес». Важно только одно: классификация живёт в типе, а не в разборе строк текста ошибки — строковые проверки ломаются от первой же правки формулировки.

### Адаптер между сервисом и очередью

```go
type AppError struct {
    Err       error
    Permanent bool
}

func (e *AppError) Error() string { return e.Err.Error() }
func (e *AppError) Unwrap() error { return e.Err }

func toHandlerErr(err error) error {
    var ae *AppError
    if errors.As(err, &ae) && ae.Permanent {
        return fmt.Errorf("%v: %w", err, asynq.SkipRetry)
    }
    return err
}
```

Сервис помечает Permanent, адаптер переводит метку в язык очереди: Permanent — обёртка со SkipRetry и архив, остальное — обычный ретрай. Метод Unwrap сохраняет цепочку, так что errors.Is и errors.As продолжают работать сквозь адаптер в обе стороны. Оригинальная ошибка не теряется: %v печатает её текст, %w внутри добавляет сентинелл — в архиве останутся и причина, и метка.

> **Живое место: %v вместо %w — и фильтр слеп.** История: фатальную ветку написали как fmt.Errorf("bad payload: %v", asynq.SkipRetry) — и задачи продолжали ретраиться двадцать пять раз. Распознавание живёт на errors.Is, а errors.Is читает только цепочку, собранную через %w; интерполяция %v вставляет текст ошибки строкой, и сентинелл в ней теряется. Ошибка тихая: тесты на хендлер зелёные, а в архиве пусто. Лекарство — юнит-тест с errors.Is(err, asynq.SkipRetry) на каждую фатальную ветку и ревью-правило: SkipRetry встречается только под %w.

### Архив: куда попадает задача после SkipRetry

Архив — конечная станция неудачных задач: туда же, куда SkipRetry отправляет задачу сразу, очередь кладёт задачи с исчерпанными попытками из шестого урока. Задача в архиве сохранена целиком — тип, payload, текст последней ошибки, счётчик попыток — и живёт там с ограничениями, о которых стоит знать: библиотека держит не больше десяти тысяч задач в архиве каждой очереди и удаляет записи старше девяноста дней, старое вытесняется новым. Обрезка выполняется прямо при каждой записи в архив, внутри той же операции: отдельного фонового чистильщика для архива нет, и на пустой очереди он не нужен. Отсюда следствие для расследований: записи старше девяноста дней вы не увидите никогда, и если инцидент требует глубокого взгляда назад, выгружайте архив в собственное хранилище периодической задачей. Практический вывод: архив — рабочий инструмент разбора, но не вечное хранилище; если фатальная ошибка массовая, десять тысяч строк переполнятся быстро, и часть улик уйдёт. Смотреть на архив можно двумя способами: глазами — вкладка Archived в asynqmon из шестнадцатого урока, и кодом — Inspector с ListArchivedTasks и RunTask из пятнадцатого, что позволяет починить данные и вернуть задачу в работу из вашего же админ-хендлера. Текст ошибки в архиве — то, ради чего стоит обёртывать SkipRetry с контекстом: «empty event_id» на входе говорит всё, а «skip retry for the task» — ничего, кроме того, что сентинелл дошёл до места.

### Судьба задачи при ошибке

| Возврат из ProcessTask | Что произойдёт | Куда уйдёт задача |
| --- | --- | --- |
| Обычная ошибка | ретрай по политике паузы | состояние retry |
| Обычная ошибка, попытки исчерпаны | архив с последней ошибкой | архив |
| SkipRetry (в том числе обёрнутый через %w) | без ретрая, сразу | архив |
| RevokeTask | ни ретрая, ни архива | удаляется как выполненная |
| Паника | воркер превращает в ошибку | состояние retry |

### Когда задача не должна повторяться

- Битый payload или неожиданный формат данных: повтор увидит те же байты.
- Отсутствие записи, которую никто не создаст: вебхук об удалённом заказе.
- Отказ в доступе по правам: повтор с теми же правами вернёт тот же запрет.
- Задача уже неактуальна: событие устарело, окно закрылось, дедлайн прошёл.
- Инвариант нарушен на входе: повтор только усилит ущерб.
- После починки данных задачу возвращают вручную из архива — ретрай тут не помощник.

### Проверка: обёртка через %v

**Вопрос:** Хендлер вернул fmt.Errorf("bad payload: %v", asynq.SkipRetry). Что сделает очередь?

- Отправит задачу в архив: сентинелл присутствует в тексте
- ✅ Назначит ретрай: errors.Is не найдёт SkipRetry в цепочке
- Задача завершится успешно
- Воркер упадёт с паникой

Распознавание построено на errors.Is, который идёт по цепочке обёрток, собранной через %w. Интерполяция %v вставляет текст ошибки строкой — сентинелл теряется, и задача ретраится по обычной политике до исчерпания попыток.

### Куда уедет задача?

```go
func handle(ctx context.Context, t *asynq.Task) error {
    var p ReportPayload
    if err := json.Unmarshal(t.Payload(), &p); err != nil {
        return fmt.Errorf("decode: %w: %w", err, asynq.SkipRetry)
    }
    return build(ctx, p)
}
// первая же попытка, MaxRetry(10) на постановке
```

**Что выведет программа?**

- Девять ретраев с растущей паузой, потом архив
- ✅ Сразу в архив: ошибки.Is найдёт SkipRetry с первой попытки
- Задача удалится без следа
- Хендлер вызовут ещё раз сразу же

Двойная обёртка через %w собирает цепочку, и errors.Is находит SkipRetry уже на первой неудаче — MaxRetry тут не участвует, ретраев не будет. Задача ляжет в архив с текстом «decode: ...», где видна и причина, и сентинелл.

### Соберите фатальную ветку

Допишите конструктор ошибки и сентинелл, который отправит задачу в архив.

```go
if p.EventID == "" {
    return fmt.___("empty event_id: %w", asynq.___)
}
```

Подсказка: Обёртка — fmt.Errorf, сентинелл без повторов — asynq.SkipRetry.

Правильные ответы в пропусках: `Errorf`, `SkipRetry`

### Соберите хендлер с классификацией

Расставьте строки: декод payload, фатальная ветка через адаптер, вызов сервиса через тот же адаптер.

Правильный порядок строк:

1. `func handleOrder(ctx context.Context, t *asynq.Task) error {`
2. `    p, err := decodeOrder(t.Payload())`
3. `    if err != nil {`
4. `        return toHandlerErr(&AppError{Err: err, Permanent: true})`
5. `    }`
6. `    return toHandlerErr(orders.Deliver(ctx, p))`
7. `}`

### RevokeTask: тихое удаление без архива

У SkipRetry есть младший брат — RevokeTask, появившийся в версии 0.25.0: задача не ретраится и в архив не попадает, а помечается выполненной и исчезает из активных. Сценарий у него один, но важный: задача, чья неудача — нормальный исход, который не стоит ни двадцати пяти попыток, ни строки в архиве. Классика — дубликаты: хендлер обнаруживает, что такой вебхук уже обработан, и вместо архивной строки «duplicate» просто возвращает RevokeTask. Второй случай — самоочистка: задача-«пиналочка» для внешнего процесса, который к моменту запуска уже не нужен. Обратная сторона — беззвучность: задача исчезает, и без своего лога вы никогда не узнаете, что она вообще была; поэтому RevokeTask идёт в паре с логом в хендлере, который фиксирует решение. Для всего остального выбирайте SkipRetry: архив с текстом ошибки дешевле, чем исчезнувшая задача, и разбирать инцидент по архиву можно и месяц спустя. Разница между двумя сентинеллами запоминается одной фразой: SkipRetry — «сохрани для осмотра», RevokeTask — «отмени тихо».

> **ErrorHandler видит и SkipRetry.** Config.ErrorHandler вызывается до всякой классификации — его получает и обычная ошибка, и SkipRetry, и даже паника, превращённая в ошибку. Это удобная точка для метрик: считайте отдельно фатальные и ретраибельные через errors.As по вашему AppError, и график «доля SkipRetry» покажет качество входных данных лучше любого тикета. Сигнатура — func(ctx context.Context, task *asynq.Task, err error) — уже содержит задачу, так что метку типа добавит один вызов.

### Карточки: ошибки и ретраи

- **asynq.SkipRetry** — Сентинелл-ошибка хендлера: задача не ретраится и сразу уходит в архив. Обёртка через %w сохраняет контекст причины.
- **asynq.RevokeTask** — Сентинелл: ни ретрая, ни архива — задача помечается выполненной и исчезает. Для дубликатов и неактуальных задач, с логом.
- **Ретраибельная ошибка** — Всё, что «сейчас не работает»: сеть, таймауты, 5xx, блокировки. Возвращается как есть — очередь повторит по своей политике.
- **Фатальная ошибка** — Всё, что «так не бывает»: битые данные, отсутствие записи, запреты. Повтор исход не изменит — место ошибки в архиве.
- **Архив** — До 10 000 задач на очередь и 90 дней хранения: тип, payload, последняя ошибка. Возврат в работу — RunTask из Inspector.
- **ErrorHandler** — Функция из Config: вызывается на каждую ошибку, включая SkipRetry и панику. Место для метрик по классам ошибок.

Путь ошибки разобран: обычная неудача ретраится, SkipRetry отправляет в архив, RevokeTask удаляет тихо, паника возвращается как ошибка. Мини-эксперимент: поставьте задачу с битым JSON в payload и обычным MaxRetry — затем добавьте ветку со SkipRetry и сравните: вместо двадцати суток ожиданий задача ляжет в архив с первой попытки, а вкладка Archived покажет текст ошибки. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go. Дальше по курсу: [пятнадцатый урок](/courses/asynq/lessons/15-inspektor/) покажет, как достать задачу из архива и запустить снова, [шестнадцатый](/courses/asynq/lessons/16-asynqmon-ui/) — как смотреть на тот же архив глазами, а [восемнадцатый](/courses/asynq/lessons/18-idempotentnost/) — почему фатальные ветки не отменяют идемпотентность.

## Коротко: что запомнить

- Asynq SkipRetry — сентинелл-ошибка: задача уходит в архив сразу, без ретраев.
- Оборачивайте сентинелл через %w: распознавание живёт на errors.Is, %v теряет его.
- Вопрос классификации один: изменит ли повтор исход; сеть — ретрай, данные — архив.
- Свой тип AppError с меткой Permanent собирает решение о повторе в одном адаптере.
- Архив хранит до 10 000 задач на очередь и 90 дней: инструмент разбора, не вечное хранилище.
- RevokeTask удаляет задачу без архива — для дубликатов и неактуальных задач, с логом.
- ErrorHandler вызывается на каждую ошибку, включая SkipRetry и панику, — место для метрик.
- Паника в хендлере превращается в ошибку и ретраится по обычной политике.

## Домашнее задание

Возьмите хендлер из прошлых уроков и введите AppError с полем Permanent и адаптером toHandlerErr. Пометите фатальными битый payload и отсутствие записи, поставьте обе задачи — битую и с несуществующим ID — и убедитесь, что обе ушли в архив с первой попытки, а текст ошибки в asynqmon читается. Напишите тест errors.Is(err, asynq.SkipRetry) на каждую фатальную ветку. Отдельным тестом проверьте, что обычная сетевая ошибка проходит сквозь адаптер без SkipRetry — классификация не должна быть односторонней. Затем замените SkipRetry на %v и убедитесь, что задачи начали ретраиться, — это тест на вашу же ловушку.

## Вопросы и ответы по уроку

### Считается ли задача со SkipRetry упавшей?

Да: это ошибка, а не успех. ErrorHandler получит её, счётчик Failed очереди вырастет, LastErr запишет текст. «Не повторять» — не «всё хорошо»: если фатальные ошибки должны быть тихими, отделяйте их в метриках по типу ошибки, а не надейтесь на то, что архив не заметят.

### Можно ли вернуть задачу из архива в работу?

Можно: Inspector.RunTask переводит задачу из архива в pending, и она выполняется заново тем же хендлером — данные к этому моменту стоит починить, иначе задача вернётся в архив с той же ошибкой. Архив хранит байты, а не структуру: перед запуском убедитесь, что новая версия хендлера декодирует payload. То же самое делает кнопка Run во вкладке Archived в asynqmon. Как встроить это в админку — пятнадцатый урок.

### Что лучше для дубликатов: SkipRetry или RevokeTask?

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