背景資料: https://aceround.app/blog/ja/backend-developer-interview-ai
この記事では、バックエンド面接でよく出る「外部 API のレスポンスが突然遅くなったらどうするか」という話を、Hedged Requests の実装問題として切り出します。LLM API、STT API、検索 API のように尾遅延がユーザー体験へ直撃する処理では、単純な retry より Hedged Requests のほうが説明しやすい場面があります。
結論
Hedged Requests は、最初のリクエストが一定時間返らない場合だけ、同じ論理リクエストを別経路へ投げる方式です。先に成功したレスポンスを採用し、残りは AbortController でキャンセルします。
ポイントは 3 つです。
- retry は「失敗してから再試行」しますが、hedge は「遅いだけの処理」にも効きます。
- 全リクエストを常に二重化するのではなく、p95/p99 付近だけに追加コストを払います。
- 副作用のある POST、課金処理、在庫更新などにはそのまま使ってはいけません。使うなら idempotency key が必要です。
面接で説明するなら、まずこう言えば十分です。
p50 は速いが p99 が悪い外部依存に対して、閾値を超えた時だけバックアップリクエストを出し、最初の成功を採用して残りをキャンセルします。追加トラフィックと尾遅延のトレードオフを取る設計です。
いつ使うべきか
Hedged Requests が向いているのは、以下の条件を満たす処理です。
- 読み取り、検索、生成、文字起こしなど、同じ入力に対して複数回呼んでも破壊的な副作用がない
- p50 は十分速いが、たまに極端に遅い
- timeout まで待つより、少し余分なリクエストを払ってでも UX を守りたい
- 呼び出し先がキャンセルを受け取れる、または少なくともクライアント側で結果を破棄できる
LLM/STT のリアルタイム処理だと、平均 latency よりも p95/p99 の悪化が目立ちます。面接中の音声認識やリアルタイム回答生成では、1 回の 400ms スパイクでも会話のテンポが崩れます。そこで「80ms 返らなければ backup を開始する」のような設計が候補になります。
最小実装
以下は依存なしで動く TypeScript 実装です。Bun でも Node.js 22 でも動きます。
type Attempt<T> = (attempt: number, signal: AbortSignal) => Promise<T>;
class HedgeTimeoutError extends Error {
constructor(readonly attempt: number, readonly timeoutMs: number) {
super(`attempt ${attempt} timed out after ${timeoutMs}ms`);
}
}
async function hedgedRequest<T>({
attempt,
hedgeAfterMs,
timeoutMs,
maxAttempts = 2,
}: {
attempt: Attempt<T>;
hedgeAfterMs: number;
timeoutMs: number;
maxAttempts?: number;
}): Promise<{ value: T; winner: number; hedged: boolean }> {
const controllers: AbortController[] = [];
const errors: unknown[] = [];
let launched = 0;
let pending = 0;
let settled = false;
let hedgeTimer: ReturnType<typeof setTimeout> | undefined;
return new Promise((resolve, reject) => {
const clearEverything = () => {
if (hedgeTimer) clearTimeout(hedgeTimer);
for (const controller of controllers) {
if (!controller.signal.aborted) controller.abort();
}
};
const launch = () => {
const current = launched++;
pending++;
const controller = new AbortController();
controllers[current] = controller;
const timeout = setTimeout(() => {
controller.abort(new HedgeTimeoutError(current, timeoutMs));
}, timeoutMs);
attempt(current, controller.signal)
.then((value) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
clearEverything();
resolve({ value, winner: current, hedged: launched > 1 });
})
.catch((err) => {
clearTimeout(timeout);
if (settled) return;
errors.push(err);
pending--;
if (launched < maxAttempts) {
launch();
return;
}
if (pending === 0) {
settled = true;
clearEverything();
reject(new AggregateError(errors, "all hedged attempts failed"));
}
});
};
launch();
hedgeTimer = setTimeout(() => {
if (!settled && launched < maxAttempts) launch();
}, hedgeAfterMs);
});
}
attempt は attempt 番号を受け取ります。0 なら primary、1 なら backup provider、というように呼び分けられます。
デモ用の疑似 provider
次に、たまに 400ms 台まで遅くなる primary provider と、安定して 90ms 前後で返る backup provider を作ります。
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason ?? new Error("aborted"));
},
{ once: true },
);
});
}
async function fakeProvider(
name: string,
latencyMs: number,
signal: AbortSignal,
): Promise<string> {
await sleep(latencyMs, signal);
return `${name}:${latencyMs}ms`;
}
async function main() {
const primaryLatencies = [42, 44, 430, 43, 45, 410, 44, 46, 420, 43];
const normal: number[] = [];
const hedged: number[] = [];
const winners: Record<number, number> = {};
for (const primaryLatency of primaryLatencies) {
const normalStart = performance.now();
await fakeProvider("primary", primaryLatency, new AbortController().signal);
normal.push(Math.round(performance.now() - normalStart));
const hedgedStart = performance.now();
const result = await hedgedRequest({
hedgeAfterMs: 80,
timeoutMs: 700,
attempt: (attemptNo, signal) => {
const latency = attemptNo === 0 ? primaryLatency : 90;
const name = attemptNo === 0 ? "primary" : "backup";
return fakeProvider(name, latency, signal);
},
});
hedged.push(Math.round(performance.now() - hedgedStart));
winners[result.winner] = (winners[result.winner] ?? 0) + 1;
}
const p95 = (values: number[]) => values.toSorted((a, b) => a - b)[
Math.ceil(values.length * 0.95) - 1
];
console.log({
normal,
hedged,
normalP95Ms: p95(normal),
hedgedP95Ms: p95(hedged),
winners,
});
}
await main();
実行例です。
bun run hedged-request-demo.ts
{
normal: [ 43, 45, 430, 44, 45, 410, 44, 47, 421, 43 ],
hedged: [ 44, 45, 172, 44, 46, 172, 45, 47, 171, 44 ],
normalP95Ms: 430,
hedgedP95Ms: 172,
winners: { "0": 7, "1": 3 }
}
primary が普通に速い 7 回では primary が勝ちます。primary が 400ms 台まで遅い 3 回だけ、80ms 後に起動した backup が勝ちます。常に二重リクエストを出すのではなく、遅くなりそうな時だけ追加コストを払うのが重要です。
retry との違い
retry は失敗に強くするための仕組みです。たとえば HTTP 500、ネットワークエラー、timeout の後に再試行します。
Hedged Requests は尾遅延に強くするための仕組みです。失敗していなくても、「まだ返ってこない」時点で backup を走らせます。
| 観点 | retry | hedged request |
|---|---|---|
| 起動条件 | 失敗後 | 一定時間返らない時 |
| 主目的 | 可用性 | p95/p99 latency 改善 |
| コスト | 失敗時だけ増える | 遅い成功時にも増える |
| 危険な用途 | 副作用あり処理 | 副作用あり処理、課金処理 |
実運用では両方を混ぜることもあります。ただし、retry + hedge はリクエスト数が急増しやすいので、上限、rate limit、circuit breaker を一緒に設計します。
面接で聞かれやすいトレードオフ
1. hedgeAfterMs をどう決めるか
固定値で始めるなら、直近の p90 から p95 あたりを使います。p50 に近すぎると backup が出すぎます。p99 に近すぎると効果が薄くなります。
実サービスなら、endpoint ごとに rolling percentile を持たせ、以下のように調整します。
- 通常時: p95 を hedge threshold にする
- コストが高い provider: p98 まで遅らせる
- リアルタイム UI: p90 まで早める
2. backup は同じ provider でよいか
同じ provider に同じリクエストを投げるだけでも、キュー詰まりや局所的な slow worker には効くことがあります。ただし、provider 全体が遅い場合は効きません。
LLM/STT なら、以下のように分ける設計が現実的です。
- primary: 品質またはコストが最適な provider
- backup: 少し高いが安定した provider
- fallback: 品質を落としても timeout を避ける provider
3. キャンセルできない処理はどうするか
AbortController はクライアント側の待機を止められますが、相手側の計算が本当に止まるかは API 次第です。キャンセルが伝わらない provider では、負荷と課金だけが残る可能性があります。
その場合は、hedge を使う前に以下を確認します。
- API が abort signal または request cancellation をサポートしているか
- 生成済みの結果を破棄しても課金されるか
- 同じ入力を複数回送って規約や rate limit に引っかからないか
4. 副作用はどう扱うか
副作用のある処理に素の Hedged Requests を使うのは危険です。たとえば、決済 API に同じ POST を 2 回送る設計は論外です。
どうしても使うなら、先に idempotency key を設計します。
Idempotency-Key: user_123:order_456:submit_v1
サーバー側がこの key を見て、「同じ論理操作は 1 回だけ実行する」と保証できて初めて hedge の議論に進めます。
本番に入れる時のチェックリスト
Hedged Requests を入れるなら、実装より観測のほうが重要です。
-
hedge_started_total: backup を起動した回数 -
hedge_won_total: backup が勝った回数 -
hedge_extra_cost_estimate: 追加リクエストのコスト -
primary_aborted_total: primary をキャンセルした回数 -
latency_p50/p95/p99: 導入前後の percentile - provider 別の error rate と timeout rate
特に hedge_started_total が急に増えた場合、外部 provider の劣化だけでなく、自分たちの threshold 設計ミスも疑います。
まとめ
Hedged Requests は派手なアルゴリズムではありません。しかし、システム設計面接ではかなり説明しやすいテーマです。
- p99 latency を下げるため、遅い時だけ backup を出す
- 最初の成功を採用し、残りをキャンセルする
- 追加トラフィック、課金、副作用、idempotency を必ず説明する
- retry、timeout、circuit breaker と役割を分けて話す
「外部 API がたまに遅いです。どうしますか?」と聞かれた時、timeout を長くするだけでは弱いです。Hedged Requests まで説明できると、単なる実装者ではなく、本番 latency とコストのトレードオフを見ているエンジニアとして伝わります。
