Содержание урока · 10 разделов
- Asynq SkipRetry: команда «не повторять»
- Ошибки задач go: ретраибельные против фатальных
- Свой тип ошибки с решением о повторе
- Архив: куда попадает задача после SkipRetry
- Судьба задачи при ошибке
- Когда задача не должна повторяться
- RevokeTask: тихое удаление без архива
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Задача с опечаткой в 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, увидеть задачу в архиве, починить данные, запустить вручную — полный цикл вместо двадцати суток ретраев.
Хендлер с фатальной веткой
GOfunc 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 из трёх классов «сеть, данные, бизнес». Важно только одно: классификация живёт в типе, а не в разборе строк текста ошибки — строковые проверки ломаются от первой же правки формулировки.
Адаптер между сервисом и очередью
GOtype 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 внутри добавляет сентинелл — в архиве останутся и причина, и метка.
История: фатальную ветку написали как 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). Что сделает очередь?
Куда уедет задача?
Прочитайте код и выберите, что он выведет:
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) на постановкеСоберите фатальную ветку
Допишите конструктор ошибки и сентинелл, который отправит задачу в архив.
Соберите хендлер с классификацией
Расставьте строки: декод payload, фатальная ветка через адаптер, вызов сервиса через тот же адаптер.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
RevokeTask: тихое удаление без архива#
У SkipRetry есть младший брат — RevokeTask, появившийся в версии 0.25.0: задача не ретраится и в архив не попадает, а помечается выполненной и исчезает из активных. Сценарий у него один, но важный: задача, чья неудача — нормальный исход, который не стоит ни двадцати пяти попыток, ни строки в архиве. Классика — дубликаты: хендлер обнаруживает, что такой вебхук уже обработан, и вместо архивной строки «duplicate» просто возвращает RevokeTask. Второй случай — самоочистка: задача-«пиналочка» для внешнего процесса, который к моменту запуска уже не нужен. Обратная сторона — беззвучность: задача исчезает, и без своего лога вы никогда не узнаете, что она вообще была; поэтому RevokeTask идёт в паре с логом в хендлере, который фиксирует решение. Для всего остального выбирайте SkipRetry: архив с текстом ошибки дешевле, чем исчезнувшая задача, и разбирать инцидент по архиву можно и месяц спустя. Разница между двумя сентинеллами запоминается одной фразой: SkipRetry — «сохрани для осмотра», RevokeTask — «отмени тихо».
Config.ErrorHandler вызывается до всякой классификации — его получает и обычная ошибка, и SkipRetry, и даже паника, превращённая в ошибку. Это удобная точка для метрик: считайте отдельно фатальные и ретраибельные через errors.As по вашему AppError, и график «доля SkipRetry» покажет качество входных данных лучше любого тикета. Сигнатура — func(ctx context.Context, task *asynq.Task, err error) — уже содержит задачу, так что метку типа добавит один вызов.
Карточки: ошибки и ретраи
Путь ошибки разобран: обычная неудача ретраится, 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 и убедитесь, что задачи начали ретраиться, — это тест на вашу же ловушку.
Личные заметкиПусто
Вопросы и ответы по уроку
Считается ли задача со SkipRetry упавшей?
Да: это ошибка, а не успех. ErrorHandler получит её, счётчик Failed очереди вырастет, LastErr запишет текст. «Не повторять» — не «всё хорошо»: если фатальные ошибки должны быть тихими, отделяйте их в метриках по типу ошибки, а не надейтесь на то, что архив не заметят.
Можно ли вернуть задачу из архива в работу?
Можно: Inspector.RunTask переводит задачу из архива в pending, и она выполняется заново тем же хендлером — данные к этому моменту стоит починить, иначе задача вернётся в архив с той же ошибкой. Архив хранит байты, а не структуру: перед запуском убедитесь, что новая версия хендлера декодирует payload. То же самое делает кнопка Run во вкладке Archived в asynqmon. Как встроить это в админку — пятнадцатый урок.
Что лучше для дубликатов: SkipRetry или RevokeTask?
RevokeTask: дубликат — нормальный исход, и строка в архиве на него — шум, который засоряет разбор настоящих сбоев. Но исчезновение задачи должно оставлять след в логе хендлера с идентификатором дубликата. SkipRetry оставляет архивную запись — она уместна там, где неудача требует человека, а не там, где система справилась сама.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once