Содержание урока · 9 разделов
Сценарий для старта: ежедневная выгрузка в шесть утра тянула данные из внешней системы. В четверг у той системы на десять минут отвалилась сеть, задача упала с таймаутом на первой же выборке и завершилась. Следующий тик — в пятницу. Утром аналитики смотрели на устаревший отчёт, дежурный ничего не заметил: ошибка была записана в лог, но никто не читал логи по расписанию. Две вещи сломались одновременно: задача не сделала ни одной второй попытки ради десяти минут простоя, и сбой не поднялся выше файла с логами. Обе чинятся в одном месте — обёртке вокруг тика, — но разными механизмами: попытками и алертом.
Повтор задачи go: почему cron не ретраит сам#
Повтор задачи go начинается с контракта интерфейса: Job объявляет метод Run без аргументов и без возврата, поэтому планировщик не получает ни ошибки, ни статуса, ни плана на повтор. Robfig/cron точен в другом: он гарантирует вызов по расписанию и ничего не обещает про исход. Единственная его встроенная реакция на аварию — cron.Recover из урока про wrappers, который ловит панику и пишет стек в лог, но и он не перезапускает тик. Значит, политика повторов — ответственность вашего кода, и её естественное место — та же цепочка обёрток: снаружи метрики, внутри ретраи. Такой порядок даёт побочный выигрыш: каждая попытка видна метрике как отдельный исход, и алерт по росту ошибок из урока про метрики задач считает неудачные попытки, а не только тики. И наоборот: логи у обёртки уже на месте, поэтому причина неудачной попытки читается по тем же полям задачи, что и у обычного тика.
Обёртка с экспоненциальными попытками
GOtype 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 — временные, ошибка валидации входных данных после повтора вернётся той же, а паника — дефект кода, который чинится релизом, а не попытками.
Классификация ошибок перед повтором
GOtype 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 у частой задачи не изменился, обёртка была лишней.
Чеклист ретраев задач#
- Каждый внешний вызов внутри задачи имеет собственный короткий ретрай с дедлайном.
- Обёртка тика ограничена дважды: числом попыток и суммарным бюджетом времени.
- Задержки растут экспоненциально с джиттером; между попытками проверяется контекст.
- Ошибки классифицированы: временные ретраятся, фатальные уходят в журнал сразу.
- Исчерпание бюджета видно в метрике, логе и журнале неудачных периодов.
- Необратимые шаги защищены ключами идемпотентности до первой попытки.
Соберите цикл с удвоением
Впишите единицу времени, возврат фатальной ошибки и чтение отмены контекста.
Расставьте каркас попыток
Расставьте строки в порядке файла: база задержки, цикл попыток, вызов работы, выход при успехе и удвоение.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ретраи задач
Арифметика повторов проверяется в песочнице честно: предикт с удвоением исполняется как есть, потому что пакеты time и math/rand там доступны. Сократите базу до миллисекунд — и цикл попыток с джиттером прогонится вживую. Сетевые вызовы подмените функцией-заглушкой, возвращающей ошибку первые два раза и успех на третий: так проверяется и классификация, и выход по успеху. Полный контур с внешним API, метриками и алертами собирается локально из модуля курса.
Ошибка в задаче по расписанию имеет цену, пропорциональную интервалу: для минутной задачи это минуты, для ночной — сутки. Каркас ответа один: ретраи с удвоением и бюджетом внутри тика, классификация ошибок перед каждой попыткой, а после исчерпания — метрика, лог и журнал неудачных периодов с эскалацией. Дальше по маршруту: ticker против cron — когда планировщик вообще не нужен, и итоговый проект — сборка сервиса, где эти ретраи стоят на месте. Если возвращались с пропуска — обёртки дают точку внедрения, алерты — правила порогов, а тестирование задач — как проверить саму обёртку повторов фейковой ошибкой. Полный маршрут — на странице курса. В домашнем задании — обёртка с бюджетом, классификация и журнал неудачных дат.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Job.Run не возвращает ошибку: политика повторов — код обёртки, а не настройка cron.
- Ретраи ограничены дважды: числом попыток и бюджетом времени; задержки растут с джиттером.
- Ошибки классифицируются: временные ретраются, фатальные уходят в журнал без попыток.
- Исчерпание бюджета оставляет три следа: метрика со статусом error, лог, журнал неудачных периодов.
- Необратимые шаги защищены идемпотентностью до первой попытки — иначе повторы удваивают эффект.
- Частая задача повторяется следующим тиком; редкой ретрай внутри тика обязателен.
Домашнее задание
Возьмите задачу с внешним вызовом и соберите обёртку RetryJob: экспоненциальная задержка с джиттером, бюджет попыток и времени, проверка контекста между попытками. Добавьте классификацию: тип FatalError для валидации и поле RetryAfter для API-ошибок, покройте таблицей в тестах с фейковыми ошибками. Затем протестируйте неудачу: заглушка падает все попытки — убедитесь, что счётчик error инкрементируется и в журнале осталась запись с датой, по которой можно перезапустить. Финал — проверьте тёмный сценарий: задача отправляет письмо и падает на отметке; добавьте ключ идемпотентности и убедитесь, что повтор не отправляет второе письмо.
Личные заметкиПусто
Вопросы и ответы по уроку
Сколько попыток и какую базу задержки выбирать?
Отталкивайтесь от цены тика и характера сбоя: кратковременные сбои сети обычно проходят за секунды-минуты, поэтому четыре попытки с базой в секунду дают пятнадцать секунд ожидания и закрывают большую часть случаев. Долгие восстановления по расписанию не догоняются ретраями — для них есть алерты и ручной перезапуск по журналу.
Как не растянуть тик ретраями до перекрытия со следующим запуском?
Суммируйте худший путь: длительность одной попытки плюс бюджет задержек, и сравните с интервалом расписания. Для редких задач запас есть; для частых либо сокращайте бюджет, либо отказывайтесь от обёртки — следующий тик и есть повтор. Страховка от перекрытия — SkipIfStillRunning, но лучше не создавать ситуацию.
Чем журнал неудачных тиков отличается от обычного лога ошибок?
Лог отвечает на вопрос, что случилось, журнал — что именно осталось несделанным. Запись журнала хранит период и параметры пропущенной работы: дату выгрузки, диапазон синхронизации. По ней запускается точечный перезапуск пропущенного, а не весь массив — при разборе инцидента это экономит часы и снижает риск повторных дублей.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь