---
title: "Wrappers: Recover, DelayIfStillRunning, Skip"
description: "Cron wrappers в go: Recover гасит паники, Delay и Skip контролируют перекрытия, порядок цепочки WithChain и свои JobWrapper для задач планировщика."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/16-wrappers/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Wrappers: Recover, DelayIfStillRunning, Skip

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

Cron wrappers — механизм обёрток в robfig/cron v3, который добавляет поведение всем задачам через одну настройку при создании планировщика: Recover гасит панику вместо падения процесса, DelayIfStillRunning задерживает перекрывшийся тик, а SkipIfStillRunning пропускает его. Обёртка — это функция высшего порядка типа JobWrapper, принимающая задачу и возвращающая обёрнутую задачу, а опция WithChain собирает такие функции в цепочку. Разберём механику цепочки, порядок обёрток, где Recover обязателен, а где выбор между Delay и Skip решает продуктовый вопрос: терять тики или копить очередь.

Сценарий, который знают все, кто выкатил планировщик без обёрток: через месяц в проде ночной отчёт падает на новой конфигурации внешнего API, паника вылетает из горутины тика — и процесс умирает целиком вместе с HTTP-сервером и остальными задачами. Или второй вариант: задача синхронизации при деградации внешней системы начинает работать дольше интервала, запускается в двух, трёх, десяти копиях, и каждая пишет в базу. Обе проблемы лечатся одной механикой — обёртками; Recover и guard уже упоминались как спасение в уроках про [AddFunc](/courses/cron/lessons/05-addfunc/) и [конкурентность](/courses/cron/lessons/12-konkurentnost-zadach/). Здесь разберём механизм целиком: как устроен JobWrapper, в каком порядке применяется цепочка, что делают три обёртки из коробки и как написать собственную.

### Cron wrappers: цепочка WithChain и тип JobWrapper

Обёртка в robfig/cron v3 — это тип type JobWrapper func(job Job) Job: функция, которая принимает задачу и возвращает задачу. Опция cron.WithChain(w1, w2, w3) при создании планировщика объявляет список обёрток, и каждая зарегистрированная задача оборачивается всей цепочкой: AddFunc и AddJob из урока про [AddJob](/courses/cron/lessons/06-addjob-structura/) принимают обычный Job, а внутрь среза записей уходит уже обёрнутый. Порядок применения обратный: последняя обёртка в списке оказывается ближе всего к вашей задаче, первая — снаружи. Поэтому Recover ставят первой: внешняя обёртка ловит панику, вылетевшую из любой внутренней. Cron wrappers — декоратор поверх интерфейса Job: поведение приписывается задаче снаружи, а сам код задачи остаётся чистым. Граница тоже важна: цепочка фиксируется при создании cron.Cron — поменять её на живом планировщике нельзя, только собрать новый, как в уроке про [динамическое расписание](/courses/cron/lessons/15-izmenenie-raspisaniya/). Зато любая задача, добавленная на лету, получает те же обёртки автоматически.

### Планировщик с защитой на старте

```go
logger := cron.PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))

c := cron.New(cron.WithChain(
    cron.Recover(logger),
    cron.SkipIfStillRunning(logger),
))

c.AddFunc("*/15 * * * *", pullOrders)
c.AddFunc("0 2 * * *", nightlyDigest)
c.Start()
```

Две обёртки закрывают два самых дорогих вида инцидентов: панику одной задачи, которая раньше роняла процесс, и перекрытие запусков, которое раньше плодило параллельные копии. Логгер передаётся в обе обёртки: Recover пишет в него панику со стеком, SkipIfStillRunning — событие пропущенного тика. Выбор PrintfLogger здесь условен: подойдёт любой тип с методами Info и Error, а разговор про логгеры — тема следующего урока.

### Проверка: порядок обёрток в цепочке

**Вопрос:** WithChain(cron.Recover(logger), cron.SkipIfStillRunning(logger)): какая обёртка снаружи и что это даёт?

- SkipIfStillRunning снаружи: пропускает тик до того, как Recover успеет что-то сделать
- ✅ Recover снаружи: ловит панику из любой внутренней обёртки и из самой задачи
- Порядок не важен: обе обёртки независимы и работают параллельно
- Библиотека сама переставляет Recover на самый внутренний уровень

Обёртки применяются в обратном порядке: первая в списке оказывается самой внешней. Recover снаружи перехватывает панику независимо от того, где она случилась — в задаче, в DelayIfStillRunning или в вашей собственной обёртке. Порядок здесь реальный: поставьте Recover последним, и паника из SkipIfStillRunning уйдёт мимо него.

### Recover: паника в задаче вместо падения сервиса

Обёртка cron.Recover(logger) оборачивает каждую задачу отложенным recover и при панике делает две вещи: пишет в логгер сообщение с ошибкой и стеком, после чего возвращает управление — планировщик продолжает тикать, остальные задачи не тронуты. Без неё паника из горутины тика по умолчанию убивает всю программу, и это уже было разжевано в [уроке про AddFunc](/courses/cron/lessons/05-addfunc/): robfig/cron не ставит защиту сам. Что Recover не делает: он не исправляет баг и не снимает с вас ответственность. Паника — это ошибка программирования: nil-указатель, выход за границы, обращение к закрытому ресурсу. Обёртка меняет лишь масштаб аварии: вместо падения сервиса в три ночи — запись в логе и работа задачи на следующем тике. Поэтому связка такая: Recover на старте как страховка, а по каждой записи паники — разбор и фикс в коде. Механику panic и recover в чистом виде разбирает [урок про панику](/courses/go-advanced/lessons/03-panic-recover/); в cron поверх неё добавляется только вопрос, куда записать событие, — вопрос логгера из следующего урока.

### Своя обёртка: замер длительности

```go
func Timed(name string, next cron.Job) cron.Job {
    return cron.FuncJob(func() {
        start := time.Now()
        next.Run()
        d := time.Since(start)
        if d > 30*time.Second {
            log.Printf("%s: %v", name, d)
        }
    })
}

c := cron.New(cron.WithChain(
    cron.Recover(logger),
    Timed("sync", cron.SkipIfStillRunning(logger)),
))
```

JobWrapper — обычная функция высшего порядка, поэтому свои обёртки пишутся в три строки: замер до вызова next.Run(), замер после, решение по результату. Здесь задача синхронизации получает Recover снаружи, затем замер длительности, и только потом guard от перекрытий. На замерах такого рода строится наблюдаемость: в продакшене вместо log.Printf ставят метрику-гистограмму, о чём отдельный урок про [метрики задач](/courses/cron/lessons/18-metriki-zadach/).

### DelayIfStillRunning и SkipIfStillRunning: перекрытия под контролем

Обе обёртки решают вопрос перекрытий из [урока про перекрытие запусков](/courses/cron/lessons/11-perekrytie-zapuska/), но с противоположной семантикой. DelayIfStillRunning сериализует запуски: если предыдущий запуск ещё жив, новый тик ждёт его завершения и выполняется после — каждый тик гарантированно отработает, но при длительной деградации очередь догоняющих запусков растёт, и после восстановления они посыпятся подряд. SkipIfStillRunning пропускает тик, пока предыдущий запуск не закончился: перекрытий нет, но потерянные тики не восполняются. Если обёртке передан логгер, событие пропуска или задержки попадает в лог, поэтому решение о том, что делать с шумом, остаётся за вами. Правило выбора — продуктовое: для выгрузок и отчётов честнее Skip, потому что догоняющая серия из двенадцати запусков бесполезна — нужен один свежий; для задач, где важен каждый тик, например инкрементальных синхронизаций без чекпоинта, подходит Delay. Обе обёртки работают на уровне одной задачи: защита не распространяется на соседние задачи.

### Проверка: выбор обёртки для отчёта

**Вопрос:** Ночной отчёт раз в сутки иногда не укладывается в интервал: сбор данных затягивается. Какая обёртка уместнее?

- DelayIfStillRunning: отчёт должен выполниться любой ценой, пусть и с задержкой
- ✅ SkipIfStillRunning: свежий отчёт важнее, а догоняющий запуск при следующем тике бессмыслен
- Никакая: перекрытие раз в сутки не проблема
- Обе сразу: сначала Skip, затем Delay для двойной защиты

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

> **Recover не первым в цепочке.** Кейс из практики: обёртки написали в порядке WithChain(SkipIfStillRunning(logger), cron.Recover(logger)) — Recover внутри. Паника случилась не в задаче, а в собственной обёртке команды, которая стояла между ними, — защита не сработала, процесс упал. Порядок цепочки — не вкусовщина: Recover обязан быть первым элементом списка, чтобы ловить всё, что происходит ниже. Вторая ловушка того же рода — обёртка с собственным defer, который паникует при неправильных аргументах: паника в самой внешней обёртке Recover уже не поймает. Проверяйте цепочку на старте тестом, который паникует в задаче, — это два десятка строк, которые стоят одного ночного инцидента.

### Что напечатает цепочка обёрток?

```go
wrap := func(name string, next func()) func() {
    return func() {
        fmt.Println(name, "вход")
        next()
        fmt.Println(name, "выход")
    }
}
job := func() { fmt.Println("тик") }
job = wrap("первая", job)
job = wrap("вторая", job)
job()
```

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

- ✅ первая вход, вторая вход, тик, вторая выход, первая выход
- вторая вход, первая вход, тик, первая выход, вторая выход
- первая вход, первая выход, вторая вход, вторая выход, тик
- тик, первая вход, вторая вход, первая выход, вторая выход

Обёртка, применённая первой, остаётся самой внешней: её «вход» печатается раньше всех, а «выход» — позже всех. Это точно та же механика, что у WithChain: первая обёртка в списке снаружи, последняя — вплотную к задаче. Отсюда правило Recover-первым и порядок от общих защит к частным.

### Обёртки из коробки и их семантика

| Обёртка | Что делает | Когда уместна | Цена |
| --- | --- | --- | --- |
| cron.Recover(logger) | гасит панику, пишет ошибку со стеком | на старте для всех задач, без вариантов | скрывает баг, если нет разбора записей |
| cron.DelayIfStillRunning(logger) | ждёт завершения предыдущего запуска | каждый тик важен, деградации короткие | очередь догоняющих запусков растёт |
| cron.SkipIfStillRunning(logger) | пропускает тик, пока задача жива | отчёты и выгрузки со свежими данными | потерянные тики не восполняются |
| своя JobWrapper | любое поведение: замер, повтор, лимит | не закрыто коробочными обёртками | пишется и тестируется руками |

### Свои JobWrapper: метрики, ретраи, лимиты

Коробочных обёрток три, а тип JobWrapper — публичный, поэтому всё, что не покрылось из коробки, пишется как обычная функция высшего порядка. Типичные свои обёртки: замер длительности с отправкой в метрики, повтор запуска при ошибке, лимит параллельных запусков через семафор, аудит-запись о начале и конце. Строить их лучше поверх материала соседних уроков: повтор запусков при ошибке — ретраи из [урока про ретраи](/courses/cron/lessons/23-retrai-zadach/), замер длительности — материал урока про [метрики задач](/courses/cron/lessons/18-metriki-zadach/), а лимит параллелизма — очередь воркеров из [урока про конкурентность](/courses/cron/lessons/12-konkurentnost-zadach/). Два правила к своим обёрткам. Первое: обёртка не должна знать о бизнесе — она получает Job и возвращает Job, всё бизнес-поведение живёт внутри самой задачи, иначе декоратор превращается в нечитаемую свалку. Второе: обёртка обязана быть честной к панике — отложенные действия должны выполняться и при панике ниже по цепочке, поэтому recover-безопасные обёртки тестируют задачей, которая паникует.

### Чеклист обёрток планировщика

- Recover первым элементом WithChain на каждом планировщике сервиса.
- Выбор между Delay и Skip по продуктовому вопросу: терять тики или копить очередь.
- Логгер передан в обе обёртки: события пропусков и паник видны в одном месте.
- Своя обёртка не знает о бизнесе: получает Job, возвращает Job.
- Тест на панику в задаче: процесс жив, тик записан в лог, планировщик тикает дальше.
- Цепочка не меняется на лету: смена обёрток — новый Cron и старт из урока про Start и Stop.

> **Где запускать примеры.** Пакета cron в песочнице нет, но сама идея обёрток — чистые функции высшего порядка над func(): пример с wrap и предикт переписываются в песочницу один в один и работают через fmt.Println, а time для замеров в ней есть. Механику Recover не воспроизвести — паника там остаётся паникой, так что обёртки библиотеки и порядок цепочки проверяйте локально из модуля второго урока: go run с log.New и двумя задачами покажет и порядок сообщений, и поведение при перекрытии.

### Recover не оправдание: спорное про честный код

Спорная привычка, которую стоит оспорить: считать Recover готовой архитектурой вместо части работы с ошибками. Обёртка гасит панику, но не возвращает задачу к незавершённому делу: если отчёт упал посреди транзакции, Recover не откатит её, не почистит временные файлы и не перезапустит выгрузку до следующего тика. Зрелая настройка выглядит иначе: Recover как подстраховка плюс честная обработка ошибок внутри задач — ошибки через return и лог, а не через panic; дедлайны через контекст из [урока про контекст](/courses/cron/lessons/10-kontekst-zadachi/); идемпотентность, чтобы повторный тик после пропуска ничего не ломал. Отдельный вопрос — мониторинг: если Recover молча глотает паники каждую ночь, обёртка превратилась в маскировку деградации. События паник должны быть видимыми: лог с уровнем ошибки и стека, а лучше — счётчик в метриках, по которому ставится алерт. Тогда Recover выполняет свою роль: авария не распространяется, а её причина не живёт в проде месяцами.

### Соберите цепочку обёрток

Впишите опцию цепочки и обе обёртки: защита от паники и пропуск перекрытий.

```go
c := cron.New(cron.___(
    cron.___(logger),
    cron.___(logger),
))
```

Подсказка: Опция — WithChain; гашение паник — Recover; пропуск перекрытий — SkipIfStillRunning.

Правильные ответы в пропусках: `WithChain`, `Recover`, `SkipIfStillRunning`

### Расставьте сборку защищённого планировщика

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

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

1. `logger := cron.PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))`
2. `c := cron.New(cron.WithChain(`
3. `    cron.Recover(logger),`
4. `    cron.SkipIfStillRunning(logger),`
5. `))`
6. `c.AddFunc("*/15 * * * *", pullOrders)`

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

- **JobWrapper** — Тип func(job Job) Job: функция высшего порядка, добавляющая задаче поведение снаружи. Свои обёртки — обычные замыкания вокруг Run.
- **WithChain** — Опция cron.New, собирающая цепочку обёрток. Применяется в обратном порядке: первая в списке — снаружи, последняя — вплотную к задаче.
- **Recover** — Гасит панику задачи, пишет ошибку со стеком в логгер, планировщик тикает дальше. Ставится первым в цепочке; не заменяет фикс бага.
- **DelayIfStillRunning** — Задерживает перекрывшийся тик до завершения предыдущего запуска: каждый тик отрабатывает, но очередь догоняющих запусков растёт.
- **SkipIfStillRunning** — Пропускает тик, пока предыдущий запуск жив: перекрытий нет, потерянные тики восполняются следующим периодом. Для отчётов и выгрузок.
- **Граница цепочки** — Обёртки фиксируются при создании Cron и не меняются на лету. Новая задача, добавленная на живом планировщике, получает цепочку автоматически.

Обёртки — это декоратор над интерфейсом Job, приписанный всем задачам разом: WithChain собирает JobWrapper в цепочку, первая обёртка в списке снаружи, поэтому Recover ставится первой и ловит паники из всего, что ниже. DelayIfStillRunning и SkipIfStillRunning закрывают перекрытия с противоположными семантиками — копить очередь или терять тики, — и выбор между ними продуктовый. Свои обёртки пишутся как замыкания вокруг next.Run() и наследуют те же правила: никакого бизнеса, честное поведение при панике, тест на живучесть планировщика. Дальше по маршруту: [логирование задач](/courses/cron/lessons/17-logirovanie-zadach/) — куда попадают события обёрток, [метрики задач](/courses/cron/lessons/18-metriki-zadach/) — как превратить замеры длительности в гистограммы. Если возвращались с пропуска — [перекрытие запусков](/courses/cron/lessons/11-perekrytie-zapuska/) объясняет, откуда берётся проблема, которую закрывают Skip и Delay, а [graceful shutdown](/courses/cron/lessons/19-graceful-cron/) покажет, как обёртки ведут себя при остановке. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — своя обёртка замера и тест на панику.

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

- JobWrapper — func(job Job) Job: обёртка добавляет задаче поведение снаружи, не трогая её код.
- WithChain применяет обёртки в обратном порядке: первая в списке — самая внешняя.
- Recover ставится первым: гасит панику любой задачи, пишет ошибку со стеком и не даёт упасть процессу.
- DelayIfStillRunning копит очередь догоняющих запусков, SkipIfStillRunning теряет тики: выбор продуктовый.
- Свои обёртки — замыкания вокруг Run без бизнес-логики: замеры, ретраи, лимиты параллелизма.
- Цепочка фиксируется при создании Cron: новые задачи получают обёртки автоматически, смена — только через новый планировщик.

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

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

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

### Обязательно ли ставить Recover на каждый планировщик?

Без Recover паника одной задачи падает на весь процесс, включая HTTP-сервер и остальные задачи. Обёртка не заменяет разбор ошибок, но меняет масштаб аварии: вместо падения сервиса — запись в логе. Ставить её на старте дешевле, чем после первого ночного инцидента.

### Можно ли менять цепочку обёрток на живом планировщике?

Нет: цепочка фиксируется при создании cron.Cron. Чтобы поменять обёртки, собирают новый планировщик, регистрируют задачи и переключаются на него в рамках graceful shutdown. Задачи, добавленные на лету, цепочку текущего планировщика наследуют.

### Чем DelayIfStillRunning отличается от SkipIfStillRunning?

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