Содержание урока · 10 разделов
Платёжный шлюз сообщает окно обслуживания: «повторяйте через пятнадцать минут». SMTP-провайдер возвращает Retry-After: тридцать секунд. А дефолтная формула asynq на первой неудаче ждёт пятнадцать-сорок пять секунд и молчит про ваши окна: она не знает ни про шлюз, ни про почту — она знает только номер попытки. Для писем с Retry-After экспонента не вредна, просто слепа: попытка уйдёт раньше, чем разрешит провайдер, и вернётся с той же ошибкой. Для шлюза с окном — хуже: несколько сгоревших попыток впустую. Asynq позволяет заменить формулу целиком: поле RetryDelayFunc в asynq.Config принимает вашу функцию, которая на каждую неудачу решает, когда будет следующая попытка. В этом уроке пишем такую функцию: сигнатура, ветвление по ошибке и по типу задачи, потолок экспоненты вместо полного отказа от неё, джиттер против роя и тесты на чистой функции.
Asynq RetryDelayFunc: своя функция паузы#
Сигнатура — type RetryDelayFunc func(n int, e error, t *asynq.Task) time.Duration: на вход номер попытки, ошибка хендлера и сама задача, на выход — пауза перед следующей попыткой. Ставится в asynq.Config сервера и заменяет дефолт для всех задач этого воркера. Номер n — сколько повторов задача уже сделала: при первой неудаче он равен нулю, при второй — единице; это тот же счётчик, что видно в info.Retried и GetRetryCount. Функция вызывается только тогда, когда ретрай действительно состоится: после исчерпания MaxRetry или при SkipRetry задача уходит в архив, и до вашей формулы дело не доходит. Возвращённое значение — честная пауза: вернёте пять минут, задача ляжет в retry-множество Redis с баллом через пять минут; вернёте ноль — следующая попытка встанет в очередь сразу же, без паузы. Внутри функции доступен весь контекст неудачи: текст ошибки, её тип через errors.As, тип задачи через t.Type() и даже payload через t.Payload(). Это и есть источник гибкости: политика может знать про Retry-After провайдера, про окно обслуживания и про то, какая из ваших задач вообще терпит повтор.
Минимальная своя политика
GOsrv := asynq.NewServer(redisConnOpt, asynq.Config{
Queues: map[string]int{"payments": 10, "default": 5},
RetryDelayFunc: func(n int, e error, t *asynq.Task) time.Duration {
return 30 * time.Second
},
})
mux.HandleFunc("payment:charge", handleCharge)
srv.Run(mux)Поле RetryDelayFunc в Config заменяет экспоненту для всех задач сервера: после каждой неудачи следующая попытка назначается ровно через тридцать секунд. Это крайность, а не рецепт: константа не даёт нарастить паузу при затяжном сбое. Зато она показывает контракт — функция на входе получает попытку, ошибку и задачу, на выходе даёт duration.
Свои повторы go: ветвление по ошибке#
Свои повторы go становятся полезными, когда функция читает ошибку. Идиома — свой тип ошибки, который несёт подсказку о повторе: type RateLimitedError struct{ RetryAfter time.Duration } у обёртки HTTP-вызова или у адаптера провайдера. В RetryDelayFunc ошибку достают через errors.As: если это RateLimitedError — вернуть её RetryAfter, и попытка уйдёт ровно тогда, когда разрешил провайдер; если сетевая — короткая пауза с нарастанием; если ошибка валидации — здесь же решение SkipRetry принимает хендлер, а не функция паузы, об этом говорит четырнадцатый урок. Разделение труда получается честное: хендлер решает, повторять ли вообще, функция паузы — когда повторять. Тот же приём работает и в обратную сторону: сервисный слой может возвращать обычные ошибки, а адаптер-обёртка в хендлере оборачивать их в типы с подсказками — тогда знание про Retry-After не расползается по коду. Важно лишь не переборщить: каждый новый тип ошибки — контракт между хендлером и политикой, который придётся держать в голове и в тестах. Две-три категории с ясными именами стоят десяти нюансов.
Проверка: номер первой попытки
Задача впервые вернула ошибку, RetryDelayFunc задана. С каким значением n её вызовут?
Пауза по типу задачи и payload#
Второй аргумент ветвления — сама задача. Один воркер обслуживает десятки типов задач, а политика нужна разная: письма прощают минуты, платежи требуют секунд, генерация отчётов — часов. Switch по t.Type() возвращает каждой категории свою формулу — и весь зоопарк живёт в одном месте, а не в настройках десятка серверов. Типы сравнивайте через именованные константы из общего пакета, а не строковые литералы: префиксный switch устареет молча, если кто-то переименует тип и не найдёт все вхождения руками. Если политики у типов радикально разные, прямым путём остаётся разделение по очередям: отдельный asynq.Server со своим Config для платежей и для почты — об этом говорит двадцатый урок про масштабирование. Payload в функции паузы читается осторожно: t.Payload() возвращает сырые байты, и разбор JSON на каждую неудачу — работа, которой там не место. Если политике действительно нужен payload — например, приоритет тенанта из тела задачи — распарсите лёгкое поле или положите нужное в заголовок задачи при постановке. Правило экономности простое: функция паузы вызывается на каждой неудаче в горячем пути воркера, и работа в десять микросекунд здесь умножается на все сбои вашей системы.
Политика по ошибке и по типу задачи
GOvar retryDelay = func(n int, e error, t *asynq.Task) time.Duration {
var rle *RateLimitedError
if errors.As(e, &rle) {
return rle.RetryAfter // провайдер знает лучше
}
switch {
case strings.HasPrefix(t.Type(), "email:"):
return time.Duration(n+1) * time.Minute
case strings.HasPrefix(t.Type(), "payment:"):
return time.Duration(2*(n+1)) * time.Second
default:
return asynq.DefaultRetryDelayFunc(n, e, t)
}
}Ошибки с подсказкой провайдера главные: errors.As вынимает RetryAfter, и попытка уйдёт ровно в разрешённое окно. Дальше ветвление по префиксу типа: письма растут поминутно, платежи — посекундно. Всё незнакомое уходит в дефолтную формулу asynq — её можно вызывать из своей политики как базу.
Новичковая политика: return 0 — «пусть сразу повторит». Одновременно упали двести задач на умершем сервисе, и все двести вернулись в очередь без паузы, на следующем же опросе — снова двести неудач, снова ноль. Воркеры заняты собой, Redis дышит часто, зависимый сервис получает долбёжку вместо шанса очнуться. Вывод: ноль и отрицательные значения — инструмент для единичных задач с коротким сбоем, а не для всей системы. И добавляйте случайность: формула Sidekiq держит джиттер не для красоты, а чтобы волна одновременных сбоев не вернулась одной волной. Быстрая проверка своей формулы: если при одинаковых n она отвечает одинаково для всей волны, случайности в ней нет — добавьте rand в диапазон паузы.
Отступление от экспоненты: когда и зачем#
Отступление от экспоненты — не всегда благо, и полезно знать, что дефолт делает хорошо. Экспонента с джиттером универсальна: она не знает ничего и потому не врёт; она растёт быстро и спасает от долбёжки при затяжном сбое. Честные поводы для отступления три. Первый — внешний мир говорит сам: Retry-After, окна обслуживания, квоты; здесь экспонента слепа по определению, и её место занимает подсказка провайдера. Второй — бизнес-ритм: повтор платежа через сутки после сбоя бесполезен, клиент ушёл; платежам нужна плотная серия коротких попыток, а не сутки ожидания. Третий — потолок: дефолтная формула на больших n уходит в дни, и для задач, которые должны решиться в час, это архив, а не ретрай. Напомним числа из шестого урока без пересказа: первая неудача ждёт пятнадцать-сорок пять секунд, пятая — уже от четырёх с половиной до семи минут, а к двадцать пятой попытке пауза подбирается к четырём суткам; для задачи с часом жизни это не политика, а наказание. Потолок, впрочем, делается без отказа от экспоненты: вызовите DefaultRetryDelayFunc внутри своей функции и ограничьте результат сверху — минимум из формулы и вашего лимита. Так вы сохраните и случайность, и быстрый рост на старте, и контроль над хвостом. Полный отказ от дефолта оправдан, когда у вас есть собственные данные о том, когда повтор имеет смысл, — иначе вы просто напишете экспоненту хуже, чем её написали авторы библиотеки.
Политики паузы и их места#
| Политика | Формула | Где уместна |
|---|---|---|
| Дефолт (Sidekiq) | n в четвёртой степени + 15 с + джиттер | универсальный фон, когда лучше не знаешь |
| Константа | фиксированный интервал | внешние окна: Retry-After, квоты |
| Линейный рост | k на номер попытки | плотные серии: платежи, вебхуки |
| Экспонента с потолком | мин из дефолта и лимита | долгие задачи, которые должны успеть за час |
Чек-лист своей функции#
- Джиттер обязателен: волна одновременных сбоев не должна возвращаться одной волной.
- Потолок сверху: даже экспонента не должна уходить в дни для задач с часом жизни.
- Ветвление по ошибке через errors.As, а не по строкам текста.
- Ноль — осознанное исключение для единичных задач, не дефолт политики.
- Функция дешёвая: без разбора тяжёлого payload и без IO на каждой неудаче.
- Логирование внутри — только на отладке: политика вызывается на каждый сбой.
- Тесты на чистой функции: задайте n, ошибку и задачу — проверьте duration.
Проверка: нулевая пауза
RetryDelayFunc вернула 0 после неудачи. Что произойдёт?
Когда уедут три попытки?
Прочитайте код и выберите, что он выведет:
RetryDelayFunc: func(n int, e error, t *asynq.Task) time.Duration {
return time.Duration(2*(n+1)) * time.Minute
}
// задача падает трижды подряд, воркеры живыСоберите конфиг с политикой
Допишите поле конфига и формулу паузы: пять минут на каждую неудачу.
Соберите политику с подсказкой провайдера
Расставьте строки: свой тип ошибки с методом, заготовка ошибки, функция паузы с подсказкой и дефолтом.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Тесты и грабли своей политики#
Политика — чистая функция от трёх аргументов, и тестировать её одно удовольствие: собрать ошибку, собрать задачу, сравнить duration. Покройте минимум четыре случая: первая неудача с n=0, десятая с n=9, ваша кастомная ошибка с подсказкой и незнакомая ошибка, уходящая в дефолт. Удобный каркас — табличный тест со срезом структур: имя случая, номер попытки, заготовка ошибки, ожидаемая длительность; новый класс ошибок добавляется строкой, а не новым тестом. Конструировать задачу в тесте можно через asynq.NewTask с пустым payload: политике важен только тип, который вы вернёте через t.Type(). Ещё один полезный случай — крайний: политика при n, равном MaxRetry минус один, должна вернуть разумную паузу, ведь это последний шанс задачи. Отдельный тест стоит написать на джиттер: прогоните формулу сто раз и проверьте, что результаты не одинаковые — иначе однажды джиттер потеряется при рефакторинге, и вы узнаете об этом по графику Redis. Из граблей самые частые: забыть, что n начинается с нуля, и получить первую паузу вдвое длиннее задуманной; тяжелоразбираемый payload в горячем пути; логирование на каждую неудачу, раздувающее логи при массовых сбоях. Смотреть на политику в бою удобнее всего через retry-список: поле NextProcessAt у задач показывает, когда именно ваша формула назначила следующую попытку — пауза видна как расстояние между LastFailedAt и NextProcessAt. Наконец, помните про границу ответственности: политика управляет только паузой, а судьбой задачи — повторять ли вообще — распоряжаются MaxRetry на постановке и SkipRetry в хендлере; попытка управлять повтором из функции паузы невозможна по построению.
Не хотите расставаться с экспонентой, но боитесь суточных хвостов — оберните: внутри своей функции вызовите asynq.DefaultRetryDelayFunc(n, e, t) и верните минимум из результата и своего потолка. Случайность, быстрый рост на старте и контролируемый хвост останутся, а менять придётся одну константу. Такой гибрид — самый частый ответ на вопрос «как ограничить двадцать суток ретраев».
Карточки: своя политика ретраев
Своя политика собрана: RetryDelayFunc в Config, ветвление по ошибке через errors.As и по типу задачи, дефолт как база с потолком сверху и джиттер против роя. Мини-эксперимент: поставьте задачу, которая всегда падает, с политикой «две минуты на попытку» и посмотрите в asynqmon, как растёт расстояние между LastFailedAt и NextProcessAt при линейной формуле. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go. Дальше по курсу: четырнадцатый урок отвечает на вторую половину вопроса — какие задачи не должны повторяться вовсе; шестой останется справочником по арифметике дефолта, а двадцать четвёртый соберёт продовые грабли, включая взрыв ретраев.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- RetryDelayFunc в Config заменяет дефолтную формулу для всех задач сервера.
- Сигнатура func(n int, e error, t *Task) time.Duration; n — число уже сделанных повторов, первая неудача приходит с нулём.
- Функция вызывается только когда ретрай состоится: архив и SkipRetry до неё не доходят.
- Ветвление по ошибке через errors.As: свой тип с RetryAfter возвращает подсказку провайдера.
- Ветвление по t.Type() собирает зоопарк политик в одном месте; тяжёлый payload в функции паузы не место.
- Нулевая пауза возвращает задачу в очередь немедленно — при массовых сбоях это рой.
- Дефолт можно вызывать из своей политики и ограничивать потолком: экспонента плюс лимит хвоста.
- Судьбой задачи управляют MaxRetry и SkipRetry; политика отвечает только за паузу.
Домашнее задание
Реализуйте политику из урока: свой тип RateLimitedError с RetryAfter, обёртку HTTP-вызова, RetryDelayFunc с ветвлением по префиксам типов и дефолтом в незнакомых случаях. Напишите табличные тесты: n от нуля до девяти, ваша ошибка, незнакомая ошибка — и тест на джиттер со ста прогонами. Поставьте задачу, которая всегда падает, и убедитесь по asynqmon, что расстояния LastFailedAt — NextProcessAt совпадают с формулой.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли задать разную политику для разных очередей?
Напрямую — нет: RetryDelayFunc живёт в Config сервера и действует на всё, что он обрабатывает. Пути два: ветвление по типу задачи внутри одной функции или отдельные asynq.Server с разными Config для разных очередей — второй вариант заодно даёт независимые пулы Concurrency, о чём говорит двадцатый урок.
Вызывается ли функция паузы, когда попытки кончились?
Нет: после исчерпания MaxRetry или при SkipRetry задача уходит в архив без вызова политики — паузу назначать некому. Это стоит учитывать в логах: если вашей функции кажется, что её не зовут, проверьте, не кончились ли у задач попытки. Счётчик Retried против MaxRetry в TaskInfo покажет ситуацию сразу.
Что вернёт функция, если хендлер упал с паникой?
Панику воркер превращает в ошибку с текстом вида panic: ... или panic [файл:строка] и передаёт в тот же путь обработки, так что политика увидит её в аргументе e и назначит паузу по своим правилам. Отличать панику от обычных ошибок лучше функцией asynq.IsPanicError, которая появилась в версии 0.25: она проверяет тип ошибки, а не текст, поэтому не ломается от правок формулировок. В самой RetryDelayFunc отдельной ветки для паники нет — вызовите IsPanicError внутри своей функции и назначьте таким случаям особую паузу.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once