---
title: "Своя политика ретраев"
description: "Asynq RetryDelayFunc: свои повторы go — функция паузы вместо экспоненты, ветвление по ошибке и типу задачи, потолки, джиттер и тесты. Примеры кода."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/13-svoi-retrai/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 18
---

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

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

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

Платёжный шлюз сообщает окно обслуживания: «повторяйте через пятнадцать минут». 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 её вызовут?

- С единицей: попытка первая
- ✅ С нулём: повторов ещё не было
- С двадцатью пятью: дефолтный максимум
- Со значением MaxRetry из постановки

n — число уже сделанных повторов: при первой неудаче он равен нулю, и ваша функция отвечает за паузу до второй попытки. Это тот же счётчик, что растёт в info.Retried: первая неудача видна как Retried=0, и формула с 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 — её можно вызывать из своей политики как базу.

> **Живое место: функция вернула ноль — и рой пошёл.** Новичковая политика: return 0 — «пусть сразу повторит». Одновременно упали двести задач на умершем сервисе, и все двести вернулись в очередь без паузы, на следующем же опросе — снова двести неудач, снова ноль. Воркеры заняты собой, Redis дышит часто, зависимый сервис получает долбёжку вместо шанса очнуться. Вывод: ноль и отрицательные значения — инструмент для единичных задач с коротким сбоем, а не для всей системы. И добавляйте случайность: формула Sidekiq держит джиттер не для красоты, а чтобы волна одновременных сбоев не вернулась одной волной. Быстрая проверка своей формулы: если при одинаковых n она отвечает одинаково для всей волны, случайности в ней нет — добавьте rand в диапазон паузы.

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

Отступление от экспоненты — не всегда благо, и полезно знать, что дефолт делает хорошо. Экспонента с джиттером универсальна: она не знает ничего и потому не врёт; она растёт быстро и спасает от долбёжки при затяжном сбое. Честные поводы для отступления три. Первый — внешний мир говорит сам: Retry-After, окна обслуживания, квоты; здесь экспонента слепа по определению, и её место занимает подсказка провайдера. Второй — бизнес-ритм: повтор платежа через сутки после сбоя бесполезен, клиент ушёл; платежам нужна плотная серия коротких попыток, а не сутки ожидания. Третий — потолок: дефолтная формула на больших n уходит в дни, и для задач, которые должны решиться в час, это архив, а не ретрай. Напомним числа из шестого урока без пересказа: первая неудача ждёт пятнадцать-сорок пять секунд, пятая — уже от четырёх с половиной до семи минут, а к двадцать пятой попытке пауза подбирается к четырём суткам; для задачи с часом жизни это не политика, а наказание. Потолок, впрочем, делается без отказа от экспоненты: вызовите DefaultRetryDelayFunc внутри своей функции и ограничьте результат сверху — минимум из формулы и вашего лимита. Так вы сохраните и случайность, и быстрый рост на старте, и контроль над хвостом. Полный отказ от дефолта оправдан, когда у вас есть собственные данные о том, когда повтор имеет смысл, — иначе вы просто напишете экспоненту хуже, чем её написали авторы библиотеки.

### Политики паузы и их места

| Политика | Формула | Где уместна |
| --- | --- | --- |
| Дефолт (Sidekiq) | n в четвёртой степени + 15 с + джиттер | универсальный фон, когда лучше не знаешь |
| Константа | фиксированный интервал | внешние окна: Retry-After, квоты |
| Линейный рост | k на номер попытки | плотные серии: платежи, вебхуки |
| Экспонента с потолком | мин из дефолта и лимита | долгие задачи, которые должны успеть за час |

### Чек-лист своей функции

- Джиттер обязателен: волна одновременных сбоев не должна возвращаться одной волной.
- Потолок сверху: даже экспонента не должна уходить в дни для задач с часом жизни.
- Ветвление по ошибке через errors.As, а не по строкам текста.
- Ноль — осознанное исключение для единичных задач, не дефолт политики.
- Функция дешёвая: без разбора тяжёлого payload и без IO на каждой неудаче.
- Логирование внутри — только на отладке: политика вызывается на каждый сбой.
- Тесты на чистой функции: задайте n, ошибку и задачу — проверьте duration.

### Проверка: нулевая пауза

**Вопрос:** RetryDelayFunc вернула 0 после неудачи. Что произойдёт?

- Задача уйдёт в архив: повтор не назначен
- ✅ Следующая попытка встанет в очередь сразу, без паузы
- Asynq заменит ноль дефолтной формулой
- Воркер подождёт минуту по умолчанию

Возвращённая пауза честная: срок следующей попытки записывается в retry-множество баллом «сейчас плюс ноль», и задача станет доступна на ближайшем же опросе. Именно поэтому ноль опасен при массовых сбоях — все неудачи вернутся без разгона.

### Когда уедут три попытки?

```go
RetryDelayFunc: func(n int, e error, t *asynq.Task) time.Duration {
    return time.Duration(2*(n+1)) * time.Minute
}
// задача падает трижды подряд, воркеры живы
```

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

- ✅ Вторая через 2 мин, третья через 4 мин, четвёртая через 6 мин
- Все три попытки через 2 минуты: формула постоянна
- Вторая через 2 мин, третья через 2 мин, четвёртая через 2 мин
- Вторая через 1 мин, третья через 2 мин, четвёртая через 3 мин

Первая неудача приходит с n=0 — пауза 2 минуты; вторая с n=1 — 4 минуты; третья с n=2 — 6 минут. Линейный рост читается с нуля, и это частая путаница: если вы ждёте «2 минуты после каждой», пишите константу, а не формулу от n.

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

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

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

Подсказка: Поле называется RetryDelayFunc, а длительность собирается из числа и time.Minute.

Правильные ответы в пропусках: `RetryDelayFunc`, `time.Minute`

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

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

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

1. `type RateLimitedError struct{ RetryAfter time.Duration }`
2. `func (e *RateLimitedError) Error() string { return "retry after " + e.RetryAfter.String() }`
3. `var errRateLimited = &RateLimitedError{RetryAfter: 30 * time.Second}`
4. `cfg.RetryDelayFunc = func(n int, e error, t *asynq.Task) time.Duration {`
5. `    var rle *RateLimitedError`
6. `    if errors.As(e, &rle) { return rle.RetryAfter }`
7. `    return asynq.DefaultRetryDelayFunc(n, e, t)`
8. `}`

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

Политика — чистая функция от трёх аргументов, и тестировать её одно удовольствие: собрать ошибку, собрать задачу, сравнить 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** — Функция func(n int, e error, t *Task) time.Duration в Config сервера: заменяет дефолтную формулу для всех задач этого воркера.
- **n в формуле** — Число уже сделанных повторов: первая неудача приходит с n=0. Тот же счётчик, что info.Retried.
- **DefaultRetryDelayFunc** — Дефолтная формула Sidekiq: n в четвёртой степени плюс 15 секунд плюс джиттер. Можно вызывать из своей политики как базу.
- **Retry-After** — Подсказка провайдера, которую несёт свой тип ошибки: errors.As в политике вынимает её, и попытка уходит в разрешённое окно.
- **Нулевая пауза** — Следующая попытка встанет сразу: при массовых сбоях превращается в рой без разгона. Инструмент для единичных случаев.
- **Границы ответственности** — Политика решает «когда повторять», MaxRetry и SkipRetry — «повторять ли вообще». Из функции паузы судьбой задачи не управляют.

Своя политика собрана: RetryDelayFunc в Config, ветвление по ошибке через errors.As и по типу задачи, дефолт как база с потолком сверху и джиттер против роя. Мини-эксперимент: поставьте задачу, которая всегда падает, с политикой «две минуты на попытку» и посмотрите в asynqmon, как растёт расстояние между LastFailedAt и NextProcessAt при линейной формуле. Формат прежний: код запускается локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go. Дальше по курсу: [четырнадцатый урок](/courses/asynq/lessons/14-oshibki-handlera/) отвечает на вторую половину вопроса — какие задачи не должны повторяться вовсе; [шестой](/courses/asynq/lessons/06-retrai-politika/) останется справочником по арифметике дефолта, а [двадцать четвёртый](/courses/asynq/lessons/24-grabli-prod/) соберёт продовые грабли, включая взрыв ретраев.

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

- 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 внутри своей функции и назначьте таким случаям особую паузу.
