📆

リトライ管理はEffect.tsを推奨したい (Effect.ts Schedule)

に公開

リトライが必要だ

ウェブアプリケーションを作る上で、外部サービスを利用するとき、リトライについて考える必要があります。
たとえば、クラウドサービスを使う上でも、一瞬500エラーを返すけれども、次の瞬間にやりなおせばうまくいくことがあります。
こうしたとき、それがショットのリクエスト内のことであれば終了してもよいのかもしれませんが、ロングランであれば、サービスのユーザー体験に影響します。

そのようなとき、リトライするように設計することがウェブサービス開発者には求められます。[1]

既存のパッケージライブラリの課題

具体名を挙げることはしないが、従来のパッケージには下記のような課題があると感じていた。

  • どのようなスケジュールで実際のところ実行されるのか、実行してみるかコードを確認してみるまで分からない。
  • 複雑な要求だと途端に実現できなくなる。
    • たとえば、「100msからExponential Backoffでやる、ただし最大で5秒まで、30秒を初めて越えるまでリトライする。」というような要求を実現するには、実際に手元でバックオフのプランを計算してみて、回数を指定する必要があったりします。
  • 経過時間や試行回数に応じたログを出そうとしたときに、自然な方法がない。

基本どれも、抽象度が不十分か、微妙な方向性、というところに起因するのだろう。

逆に、以下のようなことは実現されていたりする。

  • リトライの方法をポリシーオブジェクトとして管理できる。
  • どのような条件のときにリトライすべきか、というのを指定できる。
  • 複数のポリシーを組み合わせることができる。

Effect.ts Schedule

Effect.tsにはScheduleというものがある。

import { Schedule } from 'effect';
          ┌─── スケジュールのアウトプット
          │   ┌─── スケジュールによって消費されるインプット
          │   │
          ▼   ▼
Schedule<Out, In, ...>
  • In はたとえば、リトライするかどうかの情報のための、エラータイプなど。

そして、例えばEffect.retry(task, schedule)scheduleの部分に渡すことで、そのスケジュールでのリトライをtaskに対して行うことができる。

  • 100ミリ秒ごとに成功するまで無限に繰り返す: Effect.retry(task, Schedule.fixed("100 millis"))
  • 5回までリトライする: Effect.retry(task, Schedule.recurs(5))

より完全な例は公式ドキュメントのRetryingを参照してほしい。

基本的なスケジュール

以下の説明はあくまでもEffect.retryでの利用を前提として、イメージしやすいように砕いて表現しています。

  • 即座のやり直しをずっと繰り返す: forever: Schedule<number>
    |action||action||action||action|...
    
  • task間で一定の時間を待つ: Schedule.spaced: (duration: Duration.DurationInput) => Schedule<number>
    |---------action--------|<--interval-->|action|<--interval-->|action|...
    
  • taskにかかった時間に関わらず、一定時間ごとに実行: Schedule.fixed: (interval: Duration.DurationInput) => Schedule<number>
    <-----interval-----|-----interval-----|-----interval-----|...
    |---------action--------||action|-----|action|-----------|...
    
  • 指数(バックオフ)で実行: Schedule.exponential: (base: Duration.DurationInput, factor?: number) => Schedule<Duration.Duration>
    |action|<->|action|<-->|action|<---->|action|<-------->...
    
  • フィボナッチ数列の感覚で実行(1,1,2,3,5,...のように、前ふたつの待ち時間の和を待つ): Schedule.fibonacci: (one: Duration.DurationInput) => Schedule<Duration.Duration>
  • 特定の回数まで繰り返す: Schedule.recurs: (n: number) => Schedule<number>
  • 特定の時間がたつまで繰り返す: Schedule.recurUpTo: (duration: Duration.DurationInput) => Schedule<Duration.Duration>
  • 条件が成り立つ間、繰り返す: Schedule.recurWhile: <A>(f: Predicate<A>) => Schedule<A, A>
  • すべての試行に特定の遅延を追加: Schedule.addDelay: <Out, In, R>(self: Schedule<Out, In, R>, f: (out: Out) => Duration.DurationInput): Schedule<Out, In, R>

ここで、 Schedule<number>を返しているものは、何回目の試行か、Schedule<Duration.Duration>は開始からの経過時間などを表しています。
この他にも多くのビルトインのスケジュールが提供されています。

完全な内容は、ぜひ公式のSchedule.tsのドキュメントなども参考にしてみてください。

Schedule.tsの目次だけでも見ると面白いと思います

Schedule.union/Schedule.intersect

次に、単純なスケジュールを組み合わせる方法を見ます。まず押さえると良いのは、Schedule.union/Schedule.intersectでしょう。

Schedule.union

双方のどちらかが続ける間、短いほうが採用され続けます。もしくは、最後に無限時間の待ちがあると思えば、単に短いほうが採用されると考えられます。

const schedule = Schedule.union(
  Schedule.exponential("100 millis"),
  Schedule.spaced("1 second"),
);
    exponential  spaced  union
#1: 100ms        1000ms  100ms
#2: 200ms        1000ms  200ms
#3: 400ms        1000ms  400ms
#4: 800ms        1000ms  800ms
#5: 1600ms       1000ms  1000ms
#6: 3200ms       1000ms  1000ms
#7: 6400ms       1000ms  1000ms
#8: 12800ms      1000ms  1000ms
#9: 25600ms      1000ms  1000ms
#10: 51200ms     1000ms  1000ms
...

こうすることで、途中までは二倍になっていき、1000msが最大になる、というようなスケジュールが実現できます。

Schedule.intersect

両方がどちらも続ける間、長いほうが採用され続けます。もしくは、最後に無限時間の待ちがあると思えば、単に長いほうが採用されると考えられます。

const schedule = Schedule.intersect(
  Schedule.exponential("10 millis"),
  Schedule.recurs(5),
);
    exponential  recurs  union
#1: 10ms         0ms     10ms
#2: 20ms         0ms     20ms
#3: 40ms         0ms     40ms
#4: 80ms         0ms     80ms
#5: 160ms        0ms     160ms
#6: 320ms        ∞       ∞
#7: 640ms        ∞       ∞
#8: 1280ms       ∞       ∞

これで、10msから二倍になっていきつつ、5回で終了する、というようなスケジュールが書けます。

さらに他の例については、公式ドキュメントのSchedule Combinatorsをぜひ参考にしてみてください。

スケジュールがどのように実行されるかを知ることができる

Scheduleは、どのように実行されるものか、というのを実際の実行をするわけでなく、知ることができます。公式ドキュメントには、以下のようなScheduleをログに出す関数が紹介されています。

import { Array, Chunk, Duration, Effect, Schedule } from "effect";

const log = (
  schedule: Schedule.Schedule<unknown>,
  delay: Duration.DurationInput = 0,
): void => {
  const maxRecurs = 10; // Limit the number of executions

  const delays = Chunk.toArray(
    Effect.runSync(
      Schedule.run(
        Schedule.delays(Schedule.addDelay(schedule, () => delay)),

        Date.now(),

        Array.range(0, maxRecurs),
      ),
    ),
  );

  delays.forEach((duration, i) => {
    console.log(
      i === maxRecurs
        ? "..." // Indicate truncation if there are more executions
        : i === delays.length - 1
          ? "(end)" // Mark the last execution
          : `#${i + 1}: ${Duration.toMillis(duration)}ms`,
    );
  });
};

これを利用すると、以下のようにあるスケジュールがどのようなものであるかを即座に知ることができる。

const schedule = Schedule.union(
  Schedule.exponential("100 millis"),
  Schedule.spaced("1 second")
);


log(schedule);

/*
Output:
#1: 100ms  < exponential
#2: 200ms
#3: 400ms
#4: 800ms
#5: 1000ms < spaced
#6: 1000ms
#7: 1000ms
#8: 1000ms
#9: 1000ms
#10: 1000ms
...
*/

もちろん、これに表われる以上の意味を持つスケジュールもあるが、多くの基本的なスケジュールにおいては、このログの範囲で理解しやすくなる。

スケジュールのすぐ近くで見やすく

上記を使って、ViteなどのIn-source TestingとInline Snapshotを利用すれば、そのリトライがに実際どのように実行される予定のものなのかを可視化できるだろうと思いました。

実際に以下のようにして実現することができました。

const stampOfSchedule = (
  schedule: Schedule.Schedule<unknown>,
  taskDuration: Duration.DurationInput = 0,
  maxRecurs = 10,
): string => {
  const delays = Chunk.toArray(
    Effect.runSync(
      Schedule.run(
        Schedule.delays(Schedule.addDelay(schedule, () => taskDuration)),
        0,
        Array.range(0, maxRecurs),
      ),
    ),
  );

  return delays
    .map((duration, i) => {
      if (i === maxRecurs) {
        return "..."; // Indicate truncation if there are more executions
      } else if (i === delays.length - 1) {
        return "(end)"; // Mark the last execution
      } else {
        return `#${i + 1}: ${Duration.toMillis(duration)}ms`;
      }
    })
    .join("\n");
};


f (import.meta.vitest) {
  const { it } = import.meta.vitest;
  it("forever", ({ expect }) => {
    expect(stampOfSchedule(Schedule.forever)).toMatchInlineSnapshot(`
      "#1: 0ms
      #2: 0ms
      #3: 0ms
      #4: 0ms
      #5: 0ms
      #6: 0ms
      #7: 0ms
      #8: 0ms
      #9: 0ms
      #10: 0ms
      ..."
    `);
  });

  it("recurs 5", ({ expect }) => {
    expect(stampOfSchedule(Schedule.recurs(5))).toMatchInlineSnapshot(`
      "#1: 0ms
      #2: 0ms
      #3: 0ms
      #4: 0ms
      #5: 0ms
      (end)"
    `);
  });

  it("recurs 5 + 5mills", ({ expect }) => {
    expect(
      stampOfSchedule(
        Schedule.recurs(5).pipe(Schedule.addDelay(() => "5 millis")),
      ),
    ).toMatchInlineSnapshot(`
      "#1: 5ms
      #2: 5ms
      #3: 5ms
      #4: 5ms
      #5: 5ms
      (end)"
    `);
  });

  it("exponential", ({ expect }) => {
    expect(stampOfSchedule(Schedule.exponential("1 seconds", 1.1)))
      .toMatchInlineSnapshot(`
        "#1: 1000ms
        #2: 1100ms
        #3: 1210ms
        #4: 1331ms
        #5: 1464.1ms
        #6: 1610.51ms
        #7: 1771.561ms
        #8: 1948.7171ms
        #9: 2143.58881ms
        #10: 2357.947691ms
        ..."
      `);
  });
}

どうでしょう、素敵ではないでしょうか。

また、動かすことができる完全な例として、以下のリポジトリを用意しておきました。bun test-devをしながら追加すると、スナップショットテストが落ちるので、<code>u</code>とタイプすれば更新されます。

https://github.com/LumaKernel/effect-schedule-inline-snapshot-demo

「100msからExponential Backoffでやる、ただし最大で5秒まで、30秒を初めて越えるまでリトライする。」

import { Duration, Effect, Schedule } from "effect";

const schedule = pipe(
  Schedule.union(
    Schedule.exponential("100 millis"),
    Schedule.spaced("5 seconds"),
  ),
  Schedule.intersect(Schedule.recurUpTo("30 seconds")),
);

if (import.meta.vitest) {
  const { it } = import.meta.vitest;
  it("complex schedule", ({ expect }) => {
    expect(stampOfSchedule(schedule, 0, 100)).toMatchInlineSnapshot(`
      "#1: 100ms
      #2: 200ms
      #3: 400ms
      #4: 800ms
      #5: 1600ms
      #6: 3200ms
      #7: 5000ms
      #8: 5000ms
      #9: 5000ms
      #10: 5000ms
      #11: 5000ms
      (end)"
    `);
  });
}

宣伝: 一緒にEffect.ts、そして体験づくり、物流とデータの未来を作りませんか

有効期限: 2025年09月30日 (適宜更新します)

さて、実はすでにtRPCからeffect/rpcに乗り換えつつあります。部分的にeffect/rpcがすでにプロダクションで動いております。
なぜeffect/rpcがよいのか、ということに関してはまた書きたいですね。
Effect.ts、だけでなくあらゆる手段で、いかに楽に高品質なソフトウェア、ひいてはデータプラットフォームを作るかということを考えています。
ユーザーにとって、開発者にとって、体験から設計する。そうした仲間を募集しています。
ClaudeCode MAXが無事に全員に配られるようにもなりました。嬉しい限りです。

私のチームを含め、いくつかのチームが採用をしています。採用ページ → https://recruit.optimind.tech/

あと、閲覧注意ですが、本当に僕が個人的に、今の会社についてと、コワーカー募集というnote記事を書きました。

脚注
  1. TCP/IPやその下のレイヤがやってくれるようなことではないのかと感じるかもしれませんが、実態とHTTPで一瞬エラーになることは本当によくあるので、やるのです。なんかもう少し理論的に説得力のある言い方ってありますでしょうか。ぜひ教えてください。 ↩︎

GitHubで編集を提案
OPTIMINDテックブログ

Discussion