---
title: "Ошибки в задачах: ретраи и алерты"
description: "Повтор задачи go после сбоя: экспоненциальные ретраи с бюджетом и джиттером, классификация ошибок, журнал неудачных тиков и эскалация в алерты."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/23-retrai-zadach/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Ошибки в задачах: ретраи и алерты

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 23 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/23-retrai-zadach/*

Повтор задачи go после сбоя — вопрос, который планировщик за вас не решит: robfig/cron вызывает метод Run, не ждёт от него отчёта и уходит до следующей границы расписания. Ночная синхронизация, упавшая на обрыве сети, следующим тиком получит шанс только через сутки, если внутри тика не устроены собственные попытки. Отсюда два уровня обороны: ретраи внутри задачи с экспоненциальной задержкой и конечным бюджетом, а когда бюджет исчерпан — журнал неудачного тика и эскалация в алерты, чтобы сбой не прожил до утра незамеченным. Retry cron-задачи — это обёртка и классификация ошибок, а не настройка библиотеки: разберём, как строить обе части и где их границы.

Сценарий для старта: ежедневная выгрузка в шесть утра тянула данные из внешней системы. В четверг у той системы на десять минут отвалилась сеть, задача упала с таймаутом на первой же выборке и завершилась. Следующий тик — в пятницу. Утром аналитики смотрели на устаревший отчёт, дежурный ничего не заметил: ошибка была записана в лог, но никто не читал логи по расписанию. Две вещи сломались одновременно: задача не сделала ни одной второй попытки ради десяти минут простоя, и сбой не поднялся выше файла с логами. Обе чинятся в одном месте — обёртке вокруг тика, — но разными механизмами: попытками и алертом.

### Повтор задачи go: почему cron не ретраит сам

Повтор задачи go начинается с контракта интерфейса: Job объявляет метод Run без аргументов и без возврата, поэтому планировщик не получает ни ошибки, ни статуса, ни плана на повтор. Robfig/cron точен в другом: он гарантирует вызов по расписанию и ничего не обещает про исход. Единственная его встроенная реакция на аварию — cron.Recover из [урока про wrappers](/courses/cron/lessons/16-wrappers/), который ловит панику и пишет стек в лог, но и он не перезапускает тик. Значит, политика повторов — ответственность вашего кода, и её естественное место — та же цепочка обёрток: снаружи метрики, внутри ретраи. Такой порядок даёт побочный выигрыш: каждая попытка видна метрике как отдельный исход, и алерт по росту ошибок из [урока про метрики задач](/courses/cron/lessons/18-metriki-zadach/) считает неудачные попытки, а не только тики. И наоборот: логи у обёртки уже на месте, поэтому причина неудачной попытки читается по тем же полям задачи, что и у обычного тика.

### Обёртка с экспоненциальными попытками

```go
type RetryJob struct {
    fn          func(ctx context.Context) error
    log         *slog.Logger
    maxAttempts int
    baseDelay   time.Duration
}

func (r RetryJob) Run() {
    delay := r.baseDelay
    for attempt := 1; attempt <= r.maxAttempts; attempt++ {
        err := r.fn(context.Background())
        if err == nil {
            return
        }
        r.log.Warn("попытка не удалась",
            "attempt", attempt, "err", err, "next_in", delay)
        time.Sleep(delay)
        delay *= 2 // 1s, 2s, 4s, 8s...
    }
}
```

Три ручки управления: число попыток, база задержки и бюджет времени, который в боевом коде проверяется между попытками. Задержка удваивается после каждой попытки, а случайный джиттер до половины текущего шага, добавленный к Sleep, размазывает старты: несколько инстансов, упавших вместе, не бьют в ту же секунду. Работа вынесена в функцию с возвратом ошибки — так обёртка видит исход попытки; бизнес-ошибку ей отдаёт сам код задачи, ведь Job.Run не возвращает ничего.

### Проверка: что происходит после исчерпания попыток

**Вопрос:** Все четыре попытки обёртки завершились ошибкой. Что произойдёт дальше?

- cron перезапустит тик автоматически через минуту
- Процесс завершится с ошибкой, чтобы перезапуск был виден
- ✅ Тик считается завершённым: исход попадает в метрику и журнал, следующий шанс — по расписанию или через эскалацию
- Обёртка продолжит попытки, пока задача не удастся

Бюджет попыток существует, чтобы сбой имел конечный след: метрика, запись в журнале ошибок и алерт для дежурного. Перезапуски без предела превращают сбойную задачу в бесконечную горутину, а падение процесса не помогает — соседние задачи пострадают зря. Следующий плановый тик или ручной перезапуск по журналу — штатные пути.

### Экспоненциальная задержка, джиттер и бюджет

Форма повторов важна не меньше их числа: серия с удвоением — секунда, две, четыре, восемь — даёт внешней системе шанс подняться, не долбя её в ту же секунду. Джиттер нужен, когда сбой общий для нескольких потребителей: два инстанса, упавшие на одном таймауте, без него повторяют попытки синхронно. Верхняя граница задаётся дважды — числом попыток и суммарным бюджетом времени, потому что восемь попыток с удвоением — это больше суток ожидания, а тик, живущий сутки, перекроет следующий запуск. Внутри каждой попытки свои таймауты остаются на месте: сетевой вызов с дедлайном из [урока про контекст](/courses/cron/lessons/10-kontekst-zadachi/) не превращается ретраем в бесконечный. Наконец, не всякая ошибка заслуживает попытки: обрыв сети, таймаут и ответ 503 — временные, ошибка валидации входных данных после повтора вернётся той же, а паника — дефект кода, который чинится релизом, а не попытками.

### Классификация ошибок перед повтором

```go
type FatalError struct{ Err error }

func (e *FatalError) Error() string { return e.Err.Error() }
func (e *FatalError) Unwrap() error { return e.Err }

func retryable(err error) bool {
    var fatal *FatalError
    if errors.As(err, &fatal) {
        return false // валидация, битые данные: повтор бесполезен
    }
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        return apiErr.RetryAfter > 0 // сервер сам просит паузу
    }
    return true // сеть, таймауты: попробуем ещё
}

// внутри цикла попыток:
if err := work(ctx); err != nil {
    if !retryable(err) {
        return err // в журнал без повторов
    }
    // пауза и следующая попытка
}
```

Ошибки помечаются типами, а классификация читает их через errors.As — механика обёртывания из [урока про ошибки](/courses/go-advanced/lessons/15-oshibki-wrapping/). Фатальная ошибка выходит из цикла сразу: повторы по битым данным дают тот же результат и лишь растягивают тик. Поле RetryAfter уважает подсказку внешнего API, если она есть.

### Проверка: какую ошибку не ретраить

**Вопрос:** Задача получила от бизнес-логики ошибку валидации входных данных. Почему её не отправляют в повтор?

- Ошибки валидации запрещено логировать
- ✅ Повтор вернёт ту же ошибку: входные данные не изменились между попытками
- Валидация работает быстрее, чем экспоненциальная задержка
- Ретраи ломают метрику длительности

Повтор имеет смысл, когда между попытками мир может измениться: сеть поднялась, сервер отдышался. Входные данные задачи между попытками те же, значит, и результат проверки тот же. Такой сбой уходит в журнал и на эскалацию к тем, кто исправит данные, — попытки лишь растянули бы тик без шанса на успех.

### Сколько секунд накопит цикл?

```go
delay := time.Second
total := 0
for i := 0; i < 4; i++ {
    total += int(delay.Seconds())
    delay *= 2
}
fmt.Println("суммарно:", total, "секунд")
```

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

- ✅ суммарно: 15 секунд
- суммарно: 7 секунд
- суммарно: 8 секунд
- суммарно: 31 секунда

Цикл прибавляет один, два, четыре и восемь секунд — итого пятнадцать. Восьмёрка на четвёртой попытке выросла из базы в два куба; ожидание семи секунд — ошибка со сдвигом на одну попытку, а тридцать одна — попытка лишняя. Такая арифметика помогает заранее посчитать бюджет: четыре попытки с базой в секунду стоят тик пятнадцати секунд тишины.

### Стратегия повтора по типу сбоя

| Сбой | Повтор | Почему |
| --- | --- | --- |
| обрыв сети, таймаут | да, с удвоением | вероятно, временный |
| 503 от внешнего API | да, уважая Retry-After | перегрузка проходит |
| ошибка валидации входных данных | нет | данные между попытками не изменились |
| падение на середине записи | только с идемпотентностью | иначе дубли писем и записей |
| паника | нет | дефект кода, ловится Recover и чинится релизом |

### Журнал ошибок и эскалация

Когда бюджет исчерпан, сбой должен оставить три следа. Первый — метрика: счётчик тиков по статусам из [урока про метрики задач](/courses/cron/lessons/18-metriki-zadach/) инкрементируется со статусом error, и алерт на рост ошибок срабатывает независимо от того, читает ли кто-то логи. Второй — структурированная запись в лог из [урока про логирование](/courses/cron/lessons/17-logirovanie-zadach/) с именем задачи, числом попыток и классом ошибки: дежурный по алерту приходит в лог и находит причину по тем же полям. Третий — журнал неудачных тиков, то есть запись в хранилище о том, какой период не обработан: у ночных выгрузок это строка с датой, по которой можно перезапустить именно пропущенное, а не весь месяц. Идея та же, что у очередей недоставленных писем: сбой не растворяется, он накапливается там, откуда его достанут руками. Порог эскалации привязывается к расписанию: для минутной задачи алерт срабатывает после двух неудачных тиков, для ночной — после первой.

> **Ретраи без идемпотентности удваивают эффект.** Кейс: задача рассылки после выборки отправляла письма, затем отмечала факт в базе. На третьей попытке — после двух отправленных писем — падала запись отметки: соединение с базой моргнуло. Обёртка ретраила задачу с начала: письма ушли повторно тем же адресатам, потому что отмеченные на первой попытке адреса не исключались. Разбор показал общий принцип: шаги задачи делятся на те, что можно повторить безнаказанно, и те, что нельзя. Лечение: ключ идемпотентности до необратимого шага — отметка «письмо за дату отправлено» фиксируется до отправки или в той же транзакции, а повтор пропускает уже сделанное. Связка с замками из [урока про несколько инстансов](/courses/cron/lessons/21-raspredelennye-blokirovki/) тут прямая: идемпотентность защищает и от дублей инстансов, и от дублей попыток.

### Спорное: попытки внутри тика или следующий тик как повтор

Спорный выбор, который стоит разобрать: оборачивать задачу ретраями или полагаться на следующий запуск по расписанию. Для задач с частыми тиками второй путь дешевле и честнее: пятиминутная задача, упавшая один раз, естественно повторится через пять минут, а обёртка ретраев лишь приблизила бы этот момент и добавила бы риск перекрытия — лечится это SkipIfStillRunning из [урока про wrappers](/courses/cron/lessons/16-wrappers/), но проще не создавать перекрытие вовсе. Для редких задач выбор обратный: у ночной выгрузки нет «следующего тика», есть завтра, поэтому попытки внутри тика обязательны, иначе десятиминутный простой системы стоит суток устаревших данных. Разумная граница: интервал расписания, меньший цены сбоя, — ретраи избыточны; интервал, больший цены сбоя, — ретраи обязательны. Пограничные случаи решаются метриками: если после включения ретраев счётчик error у частой задачи не изменился, обёртка была лишней.

### Чеклист ретраев задач

- Каждый внешний вызов внутри задачи имеет собственный короткий ретрай с дедлайном.
- Обёртка тика ограничена дважды: числом попыток и суммарным бюджетом времени.
- Задержки растут экспоненциально с джиттером; между попытками проверяется контекст.
- Ошибки классифицированы: временные ретраятся, фатальные уходят в журнал сразу.
- Исчерпание бюджета видно в метрике, логе и журнале неудачных периодов.
- Необратимые шаги защищены ключами идемпотентности до первой попытки.

### Соберите цикл с удвоением

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

```go
delay := 2 * time.___
for attempt := 1; attempt <= maxAttempts; attempt++ {
    err := work(ctx)
    if err == nil {
        return nil
    }
    if !isRetryable(err) {
        return ___
    }
    select {
    case <-time.After(delay):
    case <-ctx.___():
        return ctx.Err()
    }
    delay *= 2
}
```

Подсказка: База задержки — time.Second; фатальная ошибка возвращается как есть; отмена приходит в ctx.Done.

Правильные ответы в пропусках: `Second`, `err`, `Done`

### Расставьте каркас попыток

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

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

1. `delay := time.Second`
2. `for attempt := 1; attempt <= maxAttempts; attempt++ {`
3. `    err := work(ctx)`
4. `    if err == nil { return }`
5. `    timer := time.NewTimer(delay)`
6. `    delay *= 2`

### Карточки: ретраи задач

- **Почему cron не ретраит** — Интерфейс Job объявляет Run без возврата ошибки: планировщик не знает исход тика и уходит до следующей границы расписания. Политика повторов — код обёртки.
- **Экспоненциальный backoff** — Задержки растут геометрически: 1s, 2s, 4s, 8s. Даёт внешней системе время восстановиться; для четырёх попыток с базой в секунду бюджет — 15 секунд ожидания.
- **Джиттер** — Случайная добавка к задержке: инстансы, упавшие вместе, не бьют повторами в одну секунду. Обычно берут до половины текущего шага.
- **Классификация ошибок** — Временные (сеть, таймаут, 503) ретраятся, фатальные (валидация, битые данные) — нет: вход между попытками не меняется. Читается через errors.As.
- **Журнал неудачных тиков** — Запись в хранилище о непокрытом периоде: ночной выгрузке достаточно строки с датой для точечного перезапуска. Аналог очереди недоставленных писем.
- **Ретрай или следующий тик** — Частые задачи повторяются сами по расписанию — обёртка избыточна. Редким ретрай обязателен: «следующий тик» через сутки не спасает устаревший отчёт.

> **Где запускать примеры.** Арифметика повторов проверяется в песочнице честно: предикт с удвоением исполняется как есть, потому что пакеты time и math/rand там доступны. Сократите базу до миллисекунд — и цикл попыток с джиттером прогонится вживую. Сетевые вызовы подмените функцией-заглушкой, возвращающей ошибку первые два раза и успех на третий: так проверяется и классификация, и выход по успеху. Полный контур с внешним API, метриками и алертами собирается локально из модуля курса.

Ошибка в задаче по расписанию имеет цену, пропорциональную интервалу: для минутной задачи это минуты, для ночной — сутки. Каркас ответа один: ретраи с удвоением и бюджетом внутри тика, классификация ошибок перед каждой попыткой, а после исчерпания — метрика, лог и журнал неудачных периодов с эскалацией. Дальше по маршруту: [ticker против cron](/courses/cron/lessons/24-ticker-vs-cron/) — когда планировщик вообще не нужен, и [итоговый проект](/courses/cron/lessons/25-itogovyj-proekt/) — сборка сервиса, где эти ретраи стоят на месте. Если возвращались с пропуска — [обёртки](/courses/cron/lessons/16-wrappers/) дают точку внедрения, [алерты](/courses/prometheus/lessons/14-alerts-obzor/) — правила порогов, а [тестирование задач](/courses/cron/lessons/22-testy-zadach/) — как проверить саму обёртку повторов фейковой ошибкой. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — обёртка с бюджетом, классификация и журнал неудачных дат.

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

- Job.Run не возвращает ошибку: политика повторов — код обёртки, а не настройка cron.
- Ретраи ограничены дважды: числом попыток и бюджетом времени; задержки растут с джиттером.
- Ошибки классифицируются: временные ретраются, фатальные уходят в журнал без попыток.
- Исчерпание бюджета оставляет три следа: метрика со статусом error, лог, журнал неудачных периодов.
- Необратимые шаги защищены идемпотентностью до первой попытки — иначе повторы удваивают эффект.
- Частая задача повторяется следующим тиком; редкой ретрай внутри тика обязателен.

## Домашнее задание

Возьмите задачу с внешним вызовом и соберите обёртку RetryJob: экспоненциальная задержка с джиттером, бюджет попыток и времени, проверка контекста между попытками. Добавьте классификацию: тип FatalError для валидации и поле RetryAfter для API-ошибок, покройте таблицей в тестах с фейковыми ошибками. Затем протестируйте неудачу: заглушка падает все попытки — убедитесь, что счётчик error инкрементируется и в журнале осталась запись с датой, по которой можно перезапустить. Финал — проверьте тёмный сценарий: задача отправляет письмо и падает на отметке; добавьте ключ идемпотентности и убедитесь, что повтор не отправляет второе письмо.

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

### Сколько попыток и какую базу задержки выбирать?

Отталкивайтесь от цены тика и характера сбоя: кратковременные сбои сети обычно проходят за секунды-минуты, поэтому четыре попытки с базой в секунду дают пятнадцать секунд ожидания и закрывают большую часть случаев. Долгие восстановления по расписанию не догоняются ретраями — для них есть алерты и ручной перезапуск по журналу.

### Как не растянуть тик ретраями до перекрытия со следующим запуском?

Суммируйте худший путь: длительность одной попытки плюс бюджет задержек, и сравните с интервалом расписания. Для редких задач запас есть; для частых либо сокращайте бюджет, либо отказывайтесь от обёртки — следующий тик и есть повтор. Страховка от перекрытия — SkipIfStillRunning, но лучше не создавать ситуацию.

### Чем журнал неудачных тиков отличается от обычного лога ошибок?

Лог отвечает на вопрос, что случилось, журнал — что именно осталось несделанным. Запись журнала хранит период и параметры пропущенной работы: дату выгрузки, диапазон синхронизации. По ней запускается точечный перезапуск пропущенного, а не весь массив — при разборе инцидента это экономит часы и снижает риск повторных дублей.
