В прошлом уроке у башни появились расписания, но пока они лежат без дела. Здесь их отдадут тем, кто повторяет. Повторять можно провал, это Effect.retry, и успех, это Effect.repeat. В обоих случаях главный вопрос Тикки: что именно стоит повторять.
retry: повторить провал
const policy = Schedule.exponential('100 millis').pipe(Schedule.upTo({ times: 5 }));
Effect.retry(send('гроза'), policy);
Effect.retry(self, policy) исполняет self. Получилось, ответ готов. Упало, спрашивает расписание: ещё раз? через сколько? Расписание кончилось, дальше идёт ошибка последней попытки. Тип не меняется: Effect<void, SendFailed> на входе и на выходе, ведь все попытки могли упасть.
Вход расписания в retry это ошибка. Поэтому для retry годится расписание с Input, в который ошибка помещается: Schedule<..., SendFailed> или Schedule<..., unknown>, как у всех готовых конструкторов.
Вторым аргументом можно отдать не расписание, а объект с настройками:
schedule: расписание пауз;times: сколько раз повторить, без пауз;while: функция от ошибки, повторять, пока она отвечаетtrue;until: наоборот, повторять, пока она отвечаетfalse.
Настройки складываются: повтор идёт, только если согласны все.
Что стоит повторять
Провод отвечает по списку, тест пишет, в какую миллисекунду его звали и что он ответил:
Effect.retry(send('гроза'), { schedule: policy, while: (error) => error.reason === 'обрыв' });
retry обрывы Success [ '0:обрыв', '100:обрыв', '300:ok' ]
retry отказ Failure [ '0:обрыв', '100:нет такой башни' ]
Обрыв дважды, потом принял: попытки в 0, 100 и 300, как в расписании. Во втором случае после обрыва пришёл отказ, while ответил false, и retry отдал отказ сразу, не дожидаясь конца расписания.
Делить ошибки на временные и постоянные это главное в повторах. Повтор постоянной ошибки ничего не чинит, а нагрузку добавляет. В башне временная ошибка это обрыв, всё остальное постоянное. Решать это должен тот, кто знает провод, а не расписание: расписание отвечает «когда», while отвечает «что».
Перевод с Ук
Отправка с пределом по времени, Effect.timeout из урока 64, и те же повторы:
export const patient = send('гроза').pipe(
Effect.timeout('1 second'),
Effect.retry({
schedule: Schedule.exponential('200 millis'),
while: (error) => error.reason === 'обрыв',
}),
);
uk73.ts(10,29): error TS2339: Property 'reason' does not exist on type 'SendFailed | TimeoutError'.
Property 'reason' does not exist on type 'TimeoutError'.
Читаем снизу вверх. У TimeoutError нет поля reason. Откуда он взялся? timeout добавил к ошибкам провода свою: не успел. Теперь while получает SendFailed | TimeoutError, и спросить reason, не узнав сначала, кто перед тобой, нельзя. Это хорошая ошибка: без неё молчание соседа прошло бы мимо while как «не обрыв» и не повторялось бы. Чиним разбором по тегу: error._tag === 'TimeoutError' || error.reason === 'обрыв'. После первой половины условия TypeScript знает, что во второй остался только SendFailed. Эта строка понадобится в боссе.
repeat: повторить успех
Effect.repeat устроен так же, только повторяет, пока работа удаётся. Пульс башни из урока 61 был бесконечным циклом с sleep. Расписание делает то же без цикла:
export const beatBy = <O>(schedule: Schedule.Schedule<O>): Effect.Effect<O, never, Journal> =>
Effect.gen(function* () {
const journal = yield* Journal;
let count = 0;
return yield* Effect.repeat(
Effect.suspend(() => journal.write(`пульс ${++count}`)),
schedule,
);
});
Первый удар сразу, дальше по расписанию. Упала работа, repeat останавливается и отдаёт ошибку. Кончилось расписание, repeat отвечает его последним выходом: у spaced это число повторов. Effect.suspend нужен, чтобы номер удара считался при каждом запуске удара, а не один раз, когда записали квитанцию. И счётчик живёт внутри Effect.gen: каждый запуск beatBy начинает с единицы.
Вход расписания в repeat это успех работы, поэтому у repeat свои while и until. Опросить соседа, пока он не скажет «готов»:
Effect.repeat(ask, { schedule: Schedule.spaced('1 second'), until: (ready) => ready });
repeat until { _id: 'Exit', _tag: 'Success', value: true } 3
Сосед ответил «готов» на третий раз, repeat вернул этот ответ.
Как складывать расписания
В Effect 4 три способа сложить расписания.
-
Schedule.min([a, b])повторяет, пока хочет хоть одно, и берёт меньшую паузу. Пауза растёт, но не дольше потолка:export const capped = (base: Duration.Input, max: Duration.Input, times: number): Schedule.Schedule<Duration.Duration> => Schedule.min([Schedule.exponential(base), Schedule.spaced(max)]).pipe(Schedule.upTo({ times }));min [ 0, 100, 300, 700, 1200, 1700, 2200 ]Сто, двести, четыреста, а дальше потолок в пятьсот.
-
Schedule.max([a, b])повторяет, пока хотят оба, и берёт большую паузу.max([exponential('100 millis'), recurs(4)])растёт, какexponential, и кончается после четырёх повторов, какrecurs:max [ 0, 100, 300, 700, 1500 ] -
Schedule.concat(a, b)сначала исполняетaдо конца, потомb. Три быстрых повтора, потом два редких:concat [ 0, 100, 200, 300, 1300, 2300 ]
Если ты встречал в статьях Schedule.union, intersect, both, either и andThen, это имена Effect 3. В 4.1 им соответствуют min, max и concat.
Рассвет: Effect.schedule и cron
repeat запускает работу сразу, а потом по расписанию. Сводку для Патриция сразу слать нельзя: её ждут на рассвете. Для этого есть Effect.schedule: первый запуск после первой паузы.
repeat [ 0, 1000, 2000 ]
schedule [ 1000, 2000 ]
Одно и то же spaced('1 second') с upTo({ times: 2 }): repeat запустил работу трижды, первый раз сразу, schedule дважды, первый раз через секунду.
Рассвет это не «каждые 24 часа»: в Анкморе переводят часы, и в марте сутки длятся 23 часа, а в октябре 25. Такие расписания пишут строкой cron:
export const dawn: Schedule.Schedule<Duration.Duration, unknown, Cron.CronParseError> = Schedule.cron(
'0 6 * * *',
'Europe/London',
);
'0 6 * * *' это «в 06:00 каждый день», второй аргумент часовой пояс. Часы Анкмора в коде Europe/London. Сводка на рассвете дважды, начиная с разных дат:
cron [ '2026-07-01T05:00:00.000Z', '2026-07-02T05:00:00.000Z' ]
cron [ '2026-11-01T06:00:00.000Z', '2026-11-02T06:00:00.000Z' ]
cron [ '2026-03-28T06:00:00.000Z', '2026-03-29T05:00:00.000Z' ]
Летом шесть утра по Анкмору это пять по UTC, зимой шесть. В ночь на 29 марта часы перевели, и рассвет сдвинулся по UTC на час, а по Анкмору остался в шесть.
Обрати внимание на третий параметр типа: Cron.CronParseError. Строку cron разбирают, когда расписание запускают, и в ней может быть ошибка. Effect не прячет её: она в типе, и atDawn честно говорит, что может упасть ещё и так.
Где тут дыра
Без while провод с отказом «нет такой башни»:
дыра: retry без while Failure [
'0:нет такой башни',
'100:нет такой башни',
'300:нет такой башни',
'700:нет такой башни',
'1500:нет такой башни',
'3100:нет такой башни'
]
Шесть попыток и три секунды, чтобы узнать то, что было ясно с первой. Это ровно запись из журнала Тикки.
retry на всю ночь из трёх строк, третья с первой попытки обрывается:
const night = Effect.forEach(['ясно', 'гроза', 'туман'], (line) => send(line));
Effect.retry(night, { schedule: policy, while: (error) => error.reason === 'обрыв' });
дыра: retry вокруг ночи Success [ '0:ok', '0:ok', '0:обрыв', '100:ok', '100:ok', '100:ok' ]
retry повторяет то, что ему дали, целиком. Ночь упала на третьей строке, и при повторе первые две ушли соседям второй раз. Сосед получил «ясно» дважды. Повтор ставят на самый маленький кусок, который можно повторить безопасно: на одну строку, а не на ночь.
Башня, этап 37
Башня учится повторять обрывы и только их, растит паузу с потолком, бьёт пульс по расписанию и шлёт сводку на рассвете по часам Анкмора.
Практика
Дальше
Ринцвальд гордо показывает тест сводки на рассвете.
Как проверить рассвет за миллисекунды, разберём в следующем уроке.