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

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

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

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

Своя политика ретраев

Экспоненциальная пауза из шестого урока хороша для всего сразу и ни для чего конкретного: asynq RetryDelayFunc заменяет её своей функцией, и свои повторы go ветвятся по ошибке, по типу задачи и по payload. Соберём политику с потолком и джиттером и посмотрим, где она стреляет.

Содержание урока · 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 провайдера, про окно обслуживания и про то, какая из ваших задач вообще терпит повтор.

Минимальная своя политика

GO
srv := 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 — например, приоритет тенанта из тела задачи — распарсите лёгкое поле или положите нужное в заголовок задачи при постановке. Правило экономности простое: функция паузы вызывается на каждой неудаче в горячем пути воркера, и работа в десять микросекунд здесь умножается на все сбои вашей системы.

Политика по ошибке и по типу задачи

GO
var 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 — её можно вызывать из своей политики как базу.

Отступление от экспоненты: когда и зачем#

Отступление от экспоненты — не всегда благо, и полезно знать, что дефолт делает хорошо. Экспонента с джиттером универсальна: она не знает ничего и потому не врёт; она растёт быстро и спасает от долбёжки при затяжном сбое. Честные поводы для отступления три. Первый — внешний мир говорит сам: 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
}
// задача падает трижды подряд, воркеры живы

Соберите конфиг с политикой

Допишите поле конфига и формулу паузы: пять минут на каждую неудачу.

srv := asynq.NewServer(redisConnOpt, asynq.Config{ Queues: map[string]int{"default": 10}, : func(n int, e error, t *asynq.Task) time.Duration { return 5 * }, })

Соберите политику с подсказкой провайдера

Расставьте строки: свой тип ошибки с методом, заготовка ошибки, функция паузы с подсказкой и дефолтом.

Строки

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

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

Тесты и грабли своей политики#

Политика — чистая функция от трёх аргументов, и тестировать её одно удовольствие: собрать ошибку, собрать задачу, сравнить duration. Покройте минимум четыре случая: первая неудача с n=0, десятая с n=9, ваша кастомная ошибка с подсказкой и незнакомая ошибка, уходящая в дефолт. Удобный каркас — табличный тест со срезом структур: имя случая, номер попытки, заготовка ошибки, ожидаемая длительность; новый класс ошибок добавляется строкой, а не новым тестом. Конструировать задачу в тесте можно через asynq.NewTask с пустым payload: политике важен только тип, который вы вернёте через t.Type(). Ещё один полезный случай — крайний: политика при n, равном MaxRetry минус один, должна вернуть разумную паузу, ведь это последний шанс задачи. Отдельный тест стоит написать на джиттер: прогоните формулу сто раз и проверьте, что результаты не одинаковые — иначе однажды джиттер потеряется при рефакторинге, и вы узнаете об этом по графику Redis. Из граблей самые частые: забыть, что n начинается с нуля, и получить первую паузу вдвое длиннее задуманной; тяжелоразбираемый payload в горячем пути; логирование на каждую неудачу, раздувающее логи при массовых сбоях. Смотреть на политику в бою удобнее всего через retry-список: поле NextProcessAt у задач показывает, когда именно ваша формула назначила следующую попытку — пауза видна как расстояние между LastFailedAt и NextProcessAt. Наконец, помните про границу ответственности: политика управляет только паузой, а судьбой задачи — повторять ли вообще — распоряжаются MaxRetry на постановке и SkipRetry в хендлере; попытка управлять повтором из функции паузы невозможна по построению.

Карточки: своя политика ретраев

Термин

RetryDelayFunc

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

Функция func(n int, e error, t *Task) time.Duration в Config сервера: заменяет дефолтную формулу для всех задач этого воркера.

1 / 6

Своя политика собрана: 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 совпадают с формулой.

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

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

Можно ли задать разную политику для разных очередей?

Напрямую — нет: RetryDelayFunc живёт в Config сервера и действует на всё, что он обрабатывает. Пути два: ветвление по типу задачи внутри одной функции или отдельные asynq.Server с разными Config для разных очередей — второй вариант заодно даёт независимые пулы Concurrency, о чём говорит двадцатый урок.

Вызывается ли функция паузы, когда попытки кончились?

Нет: после исчерпания MaxRetry или при SkipRetry задача уходит в архив без вызова политики — паузу назначать некому. Это стоит учитывать в логах: если вашей функции кажется, что её не зовут, проверьте, не кончились ли у задач попытки. Счётчик Retried против MaxRetry в TaskInfo покажет ситуацию сразу.

Что вернёт функция, если хендлер упал с паникой?

Панику воркер превращает в ошибку с текстом вида panic: ... или panic [файл:строка] и передаёт в тот же путь обработки, так что политика увидит её в аргументе e и назначит паузу по своим правилам. Отличать панику от обычных ошибок лучше функцией asynq.IsPanicError, которая появилась в версии 0.25: она проверяет тип ошибки, а не текст, поэтому не ломается от правок формулировок. В самой RetryDelayFunc отдельной ветки для паники нет — вызовите IsPanicError внутри своей функции и назначьте таким случаям особую паузу.

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

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

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

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

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

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

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