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

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

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

Урок 23 из 25 ~18 мин +20 XP

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

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

Содержание урока · 9 разделов

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

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

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

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

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 не возвращает ничего.

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

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

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

Форма повторов важна не меньше их числа: серия с удвоением — секунда, две, четыре, восемь — даёт внешней системе шанс подняться, не долбя её в ту же секунду. Джиттер нужен, когда сбой общий для нескольких потребителей: два инстанса, упавшие на одном таймауте, без него повторяют попытки синхронно. Верхняя граница задаётся дважды — числом попыток и суммарным бюджетом времени, потому что восемь попыток с удвоением — это больше суток ожидания, а тик, живущий сутки, перекроет следующий запуск. Внутри каждой попытки свои таймауты остаются на месте: сетевой вызов с дедлайном из урока про контекст не превращается ретраем в бесконечный. Наконец, не всякая ошибка заслуживает попытки: обрыв сети, таймаут и ответ 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 — механика обёртывания из урока про ошибки. Фатальная ошибка выходит из цикла сразу: повторы по битым данным дают тот же результат и лишь растягивают тик. Поле RetryAfter уважает подсказку внешнего API, если она есть.

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

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

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

Прочитайте код и выберите, что он выведет:

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

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

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

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

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

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

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

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

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

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

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

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 }

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

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

Строки

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

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

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

Термин

Почему cron не ретраит

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

Интерфейс Job объявляет Run без возврата ошибки: планировщик не знает исход тика и уходит до следующей границы расписания. Политика повторов — код обёртки.

1 / 6

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

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

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

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

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

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Ошибки в задачах: ретраи и алерты)

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

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

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

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

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

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

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

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

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

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

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

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь