リトライ管理は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>とタイプすれば更新されます。
「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記事を書きました。
-
TCP/IPやその下のレイヤがやってくれるようなことではないのかと感じるかもしれませんが、実態とHTTPで一瞬エラーになることは本当によくあるので、やるのです。なんかもう少し理論的に説得力のある言い方ってありますでしょうか。ぜひ教えてください。 ↩︎

世界のラストワンマイルを最適化する、OPTIMINDのテックブログです。「どの車両が、どの訪問先を、どの順に、どういうルートで回ると最適か」というラストワンマイルの配車最適化サービス、Loogiaを展開しています。recruit.optimind.tech/
Discussion