第21章:タイムアウト設計(止めどきが大事)⏰🛑
21.1 タイムアウトって、なんでそんなに大事?🥺💥
Sagaの世界は「外部I/O(ネットワーク・別サービス)」が前提になりがちです🌍📡 外部I/Oは、遅くなる・止まる・返事が来ないが普通に起きます😇
タイムアウトが無いとどうなる?👇
- リクエストがず〜っと待ち続けて、スレッド/コネクション/メモリを食い続ける🍔🧠
- 「どこまで進んだか分からない」まま、Sagaが迷子になる🚶♀️💫
- リトライが遅れて、復旧がさらに遅くなる🐢💦
- 最悪、待ち続けが増えてサービスが詰む(雪だるま)⛄💥
なのでタイムアウトは、“止めどきのルール”=設計そのものです✍️✨
21.2 まず整理!タイムアウトは1種類じゃないよ📚🧩
ざっくり、タイムアウトには「どこまで待つ?」が複数あります👇(用語は実装やライブラリで多少ズレます)
- 接続タイムアウト(connect timeout):そもそも繋がらない…の待ち時間🔌⏳
- ヘッダ受信タイムアウト(headers timeout):相手が応答開始しない…の待ち時間📨⌛
- ボディ受信タイムアウト(body timeout):応答は始まったけど、完了しない…の待ち時間📦🕰️
- 全体デッドライン(deadline):この処理はトータルでここまで!という上限⏰🧱
「connect」「read」などの概念整理は、このへんが分かりやすいです📖✨ (Zalando Engineering)
21.3 Saga視点での結論:タイムアウトは “失敗” じゃなくて “不明” 😵💫❓
ここが超重要です🔑💖
外部呼び出しでタイムアウトしたとき、こういう状態になりがち👇
- 自分:返事が来てない
- 相手:実は成功して処理を完了してるかも
- ネットワーク:途中で返事が落ちただけかも
つまりタイムアウトは、しばしば 「失敗」ではなく「結果が分からない(Unknown)」 です😵💫
だからSagaでは、タイムアウトを受けたら次のどれにするかを決めます👇
- 冪等キーつきで安全にリトライする🔁🔑
- **照会APIで“成功したか確認”**してから次へ進む🔎✅
- 人手/運用フローへエスカレーションする🧑💼📣
- 補償へ入る(ただし「相手が成功してるかも」を踏まえて慎重に)🧯⚖️
21.4 “3段階タイムアウト”で考えると迷子にならない🧠🗺️
Sagaでのおすすめ思考はコレ👇(超よく効きます✨)
- (A) 1回の外部呼び出しのタイムアウト(例:8秒)📡⏱️
- (B) Step全体のタイムアウト(例:リトライ含め30秒)🧩⏳
- (C) Saga全体のデッドライン(例:10分、または業務都合で24時間)🧾⏰
(A)が短くても、(B)と(C)があると「しつこく粘る/諦める」を整理できます🙂✨

逆に(C)が無いと、永遠に “保留Saga” が溜まっていきます📚😇
21.5 TypeScriptでの実装:まずは “AbortSignalで止める” が基本🥳✂️
今どきは AbortController / AbortSignal でキャンセル(中断)を統一するのが超王道です✋✨
特に AbortSignal.timeout() は「指定時間で自動中断」できて便利です⏰🛑 (MDNウェブドキュメント)
21.5.1 fetch にタイムアウトを付ける(いちばんシンプル)🌟
// タイムアウトつき fetch(AbortSignal.timeout を使う)
export async function fetchWithTimeout(url: string, timeoutMs: number) {
const res = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
return res;
}
AbortSignal.timeout() は指定時間で自動的に abort されるシグナルを返します(=自動タイマー付き)⏲️✨ (MDNウェブドキュメント)
21.5.2 互換用:AbortController + setTimeout 版(保険)🧯
環境によっては AbortSignal.timeout() が無い/挙動差が気になることもあるので、保険としてこの形も覚えておくと安心です🙂↕️🧡
export async function fetchWithControllerTimeout(url: string, timeoutMs: number) {
const ac = new AbortController();
const timerId = setTimeout(() => {
ac.abort(); // 中断
}, timeoutMs);
try {
const res = await fetch(url, { signal: ac.signal });
return res;
} finally {
clearTimeout(timerId); // 成功でも失敗でもタイマー片付ける🧹
}
}
AbortController/AbortSignal は Node の標準APIとしても載っています📚 (Node.js)
21.6 Nodeのfetchは「内部タイムアウト」もあるので注意⚠️🧠
Nodeの fetch は内部で undici を使うため、headers/body のタイムアウト既定値が存在します(環境や版で変動しやすいです)🧨
型定義上、headersTimeout の既定が 300e3(300秒)として記述されている例もあります📝 (fossies.org)
さらに、fetch 自体は仕様上それらをオプションで直接いじれないので、必要なら custom dispatcher(Agent) を渡す、という案内がされています🧰✨ (GitHub)
実務の安全策:自分で “外側タイムアウト(AbortSignal)” を必ず付ける (内部既定に期待しないのが事故りにくいです🙂)
21.7 Axios を使う場合:timeout は “ミリ秒”&既定は無制限👀⏱️
Axiosは timeout オプションで「何msで打ち切るか」を指定できます🔧
そして既定は 0(= タイムアウト無し)です😱 (axios-http.com)
import axios from "axios";
export async function payWithAxios(orderId: string) {
const res = await axios.post(
"https://example.com/pay",
{ orderId },
{ timeout: 8000 } // 8秒で打ち切り
);
return res.data;
}
21.8 Sagaに組み込む:Stepごとに TimeoutPolicy を持たせよう🧩⏰✨
ここからが「Sagaの教材としての本題」です📌💕
21.8.1 Step定義に timeoutMs を入れる(最小形)🧱
type StepContext = {
sagaId: string;
// 冪等キーや相関IDなども本当は持たせたいけど、ここでは最小で😊
};
type StepResult = { ok: true } | { ok: false; reason: string };
type StepDefinition = {
name: string;
timeoutMs: number;
run: (ctx: StepContext, signal: AbortSignal) => Promise<StepResult>;
compensate: (ctx: StepContext) => Promise<void>;
};
async function runStep(step: StepDefinition, ctx: StepContext): Promise<StepResult> {
try {
const signal = AbortSignal.timeout(step.timeoutMs);
return await step.run(ctx, signal);
} catch (e: any) {
const name = e?.name ?? "UnknownError";
// タイムアウトは「不明」になりやすいので、reason を分ける✨
if (name === "TimeoutError" || name === "AbortError") {
return { ok: false, reason: "TIMEOUT" };
}
return { ok: false, reason: "ERROR" };
}
}
AbortSignal.timeout() を使うことで、Step実行を「時間で止める」仕組みが作れます⏰🛑 (MDNウェブドキュメント)
21.8.2 Stepの中で signal を渡して “本当に止める” 🛑📡
const reserveStockStep: StepDefinition = {
name: "ReserveStock",
timeoutMs: 6000,
run: async (ctx, signal) => {
const res = await fetch("https://example.com/stock/reserve", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sagaId: ctx.sagaId }),
signal,
});
if (!res.ok) return { ok: false, reason: "HTTP_" + res.status };
return { ok: true };
},
compensate: async (ctx) => {
await fetch("https://example.com/stock/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sagaId: ctx.sagaId }),
});
},
};
ポイントはこれ👇
Promise.raceで “見かけ上” タイムアウトしても、裏で通信が続いたら意味ない😇signalを渡して 通信自体を止めるのが大事🛑✨
21.9 タイムアウト値、どう決める?ふわっと決めないコツ🧁📏
21.9.1 まずは “遅延の分布(p95/p99)” を見る👀📈
- 平均だけ見て決めると、たまに起きる遅さで事故る😵💫
- まずは p95/p99 を観測して、そこから決めるのが現実的です📊✨(運用で調整しやすい) ※タイムアウト設計は「接続/応答/読み取り」など段階を意識するのが大事、という整理も役立ちます🧠 (Zalando Engineering)
21.9.2 “ユーザーの許容時間” から逆算(予算)💰⏱️
- 例:ユーザーが待てるのが2秒なら、内部サービス合計はそれ以下に収めたい
- だから Saga全体デッドライン(C) を先に決めると、各Stepに配れる時間が決まる🧩✨
21.9.3 目安(超ざっくり)📌
- 同一DC/同一ネットワークの内部呼び出し:数秒単位が多い
- 外部API:数秒〜十数秒(相手SLA次第)
- “長い処理” は同期で待たず、非同期化(受付→後で完了通知)も検討対象📮✨
21.10 タイムアウト時の分岐ルール(Saga用)🚦🧯
タイムアウトが起きたら、必ずどの道に行くかを決めるのが設計です✍️✨
おすすめの最小ルール👇
-
TIMEOUT(結果不明)
- まずは 冪等キー があるなら 安全にリトライ🔁🔑
- 可能なら 照会で確定(成功してたら次へ、失敗なら補償へ)🔎✅
- それも無理なら “保留” 状態にして運用へ(後で人やバッチが解決)🧑💼📌
-
ERROR(明確に失敗)
- 即補償へ🧯
-
HTTP_4xx(業務的にダメ)
- リトライしない(間違いを直してね系)🙅♀️🧾
-
HTTP_5xx(相手都合)
* リトライ候補(でも回数/間隔は第22章のテーマ)🔁📉
21.11 “Saga全体のタイムアウト” を実装する考え方(ウォッチドッグ)🐶⏰
Sagaは途中で止まることがあるので、放置しない仕組みが必要です📌
- Saga状態に
deadlineAt(いつまでに終えるか)を保存🗓️ - 定期ジョブで「deadlineを超えたSaga」を検出🔎
- 超えてたら
TimedOutにして、運用通知 or 自動補償(慎重に)📣🧯
これだけで「永遠にPendingが溜まる」事故が激減します✨
21.12 AI活用ミニ(雛形を出させて、人が安全に直す)🤖🧠✅
Copilot/Codexに投げると便利なお願い例👇
- 「StepDefinition に timeoutMs と retryPolicy を含めた型を作って」🧩
- 「AbortSignal.timeout が無い環境向けのフォールバック関数も作って」🧯
- 「TIMEOUT/ERROR/HTTP_4xx/HTTP_5xx を分類する関数を書いて」🚦
出てきたコードは、ここまでのルール(特に TIMEOUT=不明)に合ってるかチェックしてね👀✨
21.13 章末ミニ演習📝💕
演習1:タイムアウト予算を配ってみよう💰⏱️
注文Saga(全体デッドライン 20秒)で👇のStepがあるとするよ🛒📦
- 決済:p95=4秒 / p99=9秒
- 在庫確保:p95=1秒 / p99=3秒
- 配送依頼:p95=2秒 / p99=6秒
- 各Stepの「1回の呼び出しタイムアウト(A)」を決めてみてね✍️
- リトライ込みの「Stepタイムアウト(B)」も決めてみてね🔁
- 合計が全体20秒(C)を超えないように調整してみてね🧠✨
演習2:TIMEOUTの扱いを決めよう😵💫❓
「決済のタイムアウト」が起きたとき、次のどれにする?(理由も)👇
- すぐ補償(注文キャンセル)🧯
- 冪等キーでリトライ🔁
- 決済照会をして確定してから次へ🔎
- 保留にして運用へ📣
21.14 チェックリスト✅✨(ここだけ押さえれば勝ち!)
- タイムアウトは必ず入れる(外部I/Oは止まる前提)⏰🛑
- タイムアウトは「失敗」ではなく「不明」になりうる😵💫❓
- (A)呼び出し (B)Step (C)Saga全体 の3段階で設計する🧩🧠
- 止めた後の行き先(リトライ/照会/運用/補償)を決める🚦🧯
AbortSignal.timeout()は便利(自動でabortする)⏲️✨ (MDNウェブドキュメント)- Axiosは既定だとタイムアウト無し(0)なので注意⚠️ (axios-http.com)
- Nodeのfetch/undiciの内部タイムアウトは環境差が出るので、自分で外側タイムアウトを持つのが安全🧯🧰 (GitHub)