Раздел 46 · Effect: Тихая Академия

Повторы: retry и repeat

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке у башни появились расписания, но пока они лежат без дела. Здесь их отдадут тем, кто повторяет. Повторять можно провал, это 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

Башня учится повторять обрывы и только их, растит паузу с потолком, бьёт пульс по расписанию и шлёт сводку на рассвете по часам Анкмора.

Практика

Дальше

Ринцвальд гордо показывает тест сводки на рассвете.

Как проверить рассвет за миллисекунды, разберём в следующем уроке.