Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 14 из 25 ~18 мин +50 XP

Ошибки: SkipRetry и Fatal

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

Содержание урока · 10 разделов

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

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

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

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

В реальном проекте хендлеры обёртывают сервисный слой, и решение «фатально или нет» хочется принимать в одном месте, а не в каждом 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 внутри добавляет сентинелл — в архиве останутся и причина, и метка.

Архив: куда попадает задача после 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). Что сделает очередь?

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

Прочитайте код и выберите, что он выведет:

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) на постановке

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

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

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

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

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

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

Термин

asynq.SkipRetry

нажмите, чтобы перевернуть
Определение

Сентинелл-ошибка хендлера: задача не ретраится и сразу уходит в архив. Обёртка через %w сохраняет контекст причины.

1 / 6

Путь ошибки разобран: обычная неудача ретраится, SkipRetry отправляет в архив, RevokeTask удаляет тихо, паника возвращается как ошибка. Мини-эксперимент: поставьте задачу с битым JSON в payload и обычным MaxRetry — затем добавьте ветку со SkipRetry и сравните: вместо двадцати суток ожиданий задача ляжет в архив с первой попытки, а вкладка Archived покажет текст ошибки. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go. Дальше по курсу: пятнадцатый урок покажет, как достать задачу из архива и запустить снова, шестнадцатый — как смотреть на тот же архив глазами, а восемнадцатый — почему фатальные ветки не отменяют идемпотентность.

Песочница урока

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

Вывод

Запустите код, чтобы увидеть вывод.

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

  • 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 и убедитесь, что задачи начали ретраиться, — это тест на вашу же ловушку.

Личные заметкиПусто
Хранятся только в вашем браузере (Asynq · Ошибки: SkipRetry и Fatal)

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

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

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

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

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

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

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

Термины урока

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

Следующая ступень учебного пути

Kafka25 уроков~12 чпрактика25 уроков уже доступны

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once