01はじめに
外部APIを呼び出すシステムを運用していると、遅かれ早かれ HTTP 429(Too Many Requests)やタイムアウトエラーに直面します。 最初の数回は「少し待てば直る」で済むかもしれませんが、規模が大きくなるにつれて「たまに起きるエラー」ではなく「常に起き得る状態」として扱わなければならなくなります。
筆者たちが BizPlan(事業計画エージェント)の開発・運用を通じて学んだのは、レート制限とタイムアウトは防ぐものではなく、前提として設計するものだという認識の転換でした。 この記事では、その考え方のもとで実践してきた設計パターンをコードとともに紹介します。
対象読者:
- Node.js で外部 API(LLM API・SaaS の REST API など)を呼び出すシステムを開発・運用している方
- 429 やタイムアウトが「たまに起きる問題」から「設計上の課題」に変わりつつあると感じている方
- ユーザーに対してエラー体験をどう見せるか悩んでいる方
前提・実行環境:
- Node.js v20.18.1 以上
- TypeScript 5.4 以上
- 特定のライブラリへの依存は最小限にしています。
axiosの代わりに標準のfetchを使う例が中心です
この記事で得られること:
- レート制限の種類(RPM / TPM など)の理解と、どの軸で制限がかかるかの把握
- 指数バックオフとジッターの実装方法
- キューイングと同時実行制御の設計
- タイムアウトの段階設計(接続・読み込み・全体)
- ユーザーに対するエラー・待機状態の見せ方
02TL;DR
- 429 とタイムアウトは「バグ」ではなく「正常な制限信号」として設計に組み込む
- リトライには指数バックオフ+ジッターを使う。固定間隔リトライは逆効果になることがある
- 大量リクエストはキューで直列化し、同時実行数を制御する
- タイムアウトは「接続」「読み込み」「全体」の3段階で設定する
- ユーザーには「何が起きているか」と「いつ終わるか(の見込み)」を伝える
03レート制限の種類を整理する
外部 API のレート制限には、大きく分けて以下の軸があります。
| 制限の種類 | 意味 | 代表的な指標 |
|---|---|---|
| RPM(Requests Per Minute) | 1分あたりのリクエスト数 | API 呼び出し回数 |
| TPM(Tokens Per Minute) | 1分あたりのトークン数 | LLM API のプロンプト+補完トークン |
| RPD(Requests Per Day) | 1日あたりのリクエスト数 | 無料プランや低ティアで多い |
| 同時リクエスト数 | 同時に処理できるリクエスト数 | コネクション数制限 |
| バースト制限 | 短時間の急激な増加への制限 | 数秒間の集中アクセス |
LLM API の場合、RPM とは別に TPM が存在するため、「リクエスト数は少ないのに 429 が返ってくる」という状況が起きます。 プロンプトが長い場合は1リクエストあたりのトークン消費が大きく、TPM 制限に先にぶつかります。
429 レスポンスの中身を読む
429 が返ってきたとき、レスポンスヘッダーや本文に再試行までの待機時間が含まれていることがあります。
// src/api/rate-limit-parser.ts
export interface RateLimitInfo {
retryAfterSeconds: number | null;
limitType: string | null;
remaining: number | null;
}
export function parseRateLimitHeaders(headers: Headers): RateLimitInfo {
// Retry-After ヘッダー(秒数または HTTP 日付形式)
const retryAfter = headers.get("retry-after");
let retryAfterSeconds: number | null = null;
if (retryAfter !== null) {
const asNumber = Number(retryAfter);
if (!isNaN(asNumber)) {
// 秒数形式
retryAfterSeconds = asNumber;
} else {
// HTTP 日付形式(例: "Wed, 11 Jun 2026 12:00:00 GMT")
const retryDate = new Date(retryAfter);
const diffMs = retryDate.getTime() - Date.now();
retryAfterSeconds = diffMs > 0 ? Math.ceil(diffMs / 1000) : 0;
}
}
// OpenAI は x-ratelimit-remaining-requests / x-ratelimit-remaining-tokens を返します(API によって異なります)
const remainingRequests = headers.get("x-ratelimit-remaining-requests");
const remainingTokens = headers.get("x-ratelimit-remaining-tokens");
const limitType = headers.get("x-ratelimit-limit-requests")
? "requests"
: headers.get("x-ratelimit-limit-tokens")
? "tokens"
: null;
// remaining はリクエスト数制限・トークン数制限のうち、どちらが制限軸かに合わせて取得します
const remaining =
limitType === "requests"
? remainingRequests
: limitType === "tokens"
? remainingTokens
: remainingRequests ?? remainingTokens;
return {
retryAfterSeconds,
limitType,
remaining: remaining !== null && remaining !== undefined ? Number(remaining) : null,
};
}
// 使用例
const response = await fetch("https://api.example.com/v1/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ prompt: "Hello", max_tokens: 100 }),
});
if (response.status === 429) {
const info = parseRateLimitHeaders(response.headers);
console.log(`レート制限。待機時間: ${info.retryAfterSeconds}秒 / 制限軸: ${info.limitType}`);
// => レート制限。待機時間: 30秒 / 制限軸: tokens
}
ヘッダーの形式は API によって異なります。
OpenAI API では x-ratelimit-limit-requests、x-ratelimit-limit-tokens、x-ratelimit-remaining-requests などが返ります。
事前に利用する API のドキュメントでヘッダー仕様を確認しておくことを推奨します。
04指数バックオフとジッター
なぜ固定間隔リトライは問題になるのか
複数のクライアント(または同一クライアントの複数スレッド)が同時に 429 を受け取り、全員が「3秒待ってリトライ」した場合、再び同じタイミングでリクエストが集中します。 これを**サンダリングハード(thundering herd)**と呼びます。
時刻 0.0秒: リクエスト10本 → 全員 429
時刻 3.0秒: 10本が一斉リトライ → また 429(制限が解けていない場合)
時刻 6.0秒: また一斉リトライ ...
この状況を避けるために使うのが指数バックオフとジッターです。
指数バックオフ
待機時間を試行回数に応じて指数的に増やします。
待機時間 = 基準時間 × (2 ^ 試行回数)
試行1回目: 1秒 × 2^1 = 2秒 試行2回目: 1秒 × 2^2 = 4秒 試行3回目: 1秒 × 2^3 = 8秒
ただし上限を設けないと無限に増加するため、最大待機時間(cap)を設定します。
ジッターを加える
指数バックオフだけでは、同時にリトライしたクライアントが同じ待機時間になってしまいます。 ジッターはランダム性を加えてリトライタイミングを分散させます。
代表的なジッター戦略は「Full Jitter」と「Equal Jitter」の2種類です。
- Full Jitter:
random(0, cap)。完全にランダム化する。分散効果が最も大きい - Equal Jitter:
cap/2 + random(0, cap/2)。ある程度の待機時間を保証しつつ分散させる
AWS の技術ブログでも Full Jitter が多くのユースケースで推奨されています(参考文献参照)。
// src/api/backoff.ts
export interface BackoffOptions {
/** 基準待機時間(ミリ秒)。デフォルト 1000ms */
baseDelayMs?: number;
/** 最大待機時間(ミリ秒)。デフォルト 30000ms */
maxDelayMs?: number;
/** 最大試行回数(初回含む)。デフォルト 5 */
maxAttempts?: number;
/** ジッター戦略。デフォルト "full" */
jitter?: "full" | "equal" | "none";
}
export function calculateBackoffDelay(
attempt: number,
options: BackoffOptions = {}
): number {
const {
baseDelayMs = 1000,
maxDelayMs = 30000,
jitter = "full",
} = options;
// 指数バックオフ(上限付き)
const exponential = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
switch (jitter) {
case "full":
// 0 〜 exponential の一様乱数
return Math.floor(Math.random() * exponential);
case "equal":
// exponential/2 〜 exponential の一様乱数
return Math.floor(exponential / 2 + Math.random() * (exponential / 2));
case "none":
return exponential;
}
}
実際に動かしてみると待機時間がどう変わるかを確認できます。
// 動作確認用スクリプト
for (let attempt = 0; attempt < 5; attempt++) {
const delay = calculateBackoffDelay(attempt, {
baseDelayMs: 1000,
maxDelayMs: 30000,
jitter: "full",
});
console.log(`試行 ${attempt + 1}: 待機 ${delay}ms`);
}
// 実行結果(乱数なので毎回異なります)
// 試行 1: 待機 743ms (0〜1000ms の範囲)
// 試行 2: 待機 1821ms (0〜2000ms の範囲)
// 試行 3: 待機 3204ms (0〜4000ms の範囲)
// 試行 4: 待機 6891ms (0〜8000ms の範囲)
// 試行 5: 待機 14532ms (0〜16000ms の範囲)
リトライロジックを組み込む
バックオフ計算を実際のリトライ処理に組み込みます。
// src/api/retry.ts
import { calculateBackoffDelay, BackoffOptions } from "./backoff.js";
import { parseRateLimitHeaders } from "./rate-limit-parser.js";
export interface RetryOptions extends BackoffOptions {
/** リトライ対象とする HTTP ステータスコード。デフォルト [429, 500, 502, 503, 504] */
retryableStatusCodes?: number[];
/** リトライ前に呼ばれるフック(ログ・モニタリング用) */
onRetry?: (attempt: number, delayMs: number, reason: string) => void;
}
export class RetryableError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly attempt: number
) {
super(message);
this.name = "RetryableError";
}
}
export async function fetchWithRetry(
url: string,
init: RequestInit,
options: RetryOptions = {}
): Promise<Response> {
const {
maxAttempts = 5,
retryableStatusCodes = [429, 500, 502, 503, 504],
onRetry,
...backoffOptions
} = options;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch(url, init);
if (!retryableStatusCodes.includes(response.status)) {
// リトライ対象外(成功 or 4xx のうち 429 以外など)
return response;
}
// 429 の場合は Retry-After ヘッダーを優先する
let delayMs: number;
if (response.status === 429) {
const rateLimitInfo = parseRateLimitHeaders(response.headers);
if (rateLimitInfo.retryAfterSeconds !== null) {
// ヘッダー指定の待機時間に少しジッターを足す
delayMs = rateLimitInfo.retryAfterSeconds * 1000 + Math.random() * 1000;
} else {
delayMs = calculateBackoffDelay(attempt, backoffOptions);
}
} else {
delayMs = calculateBackoffDelay(attempt, backoffOptions);
}
if (attempt < maxAttempts - 1) {
onRetry?.(attempt + 1, delayMs, `HTTP ${response.status}`);
await sleep(delayMs);
} else {
// 最終試行でも失敗
throw new RetryableError(
`${maxAttempts} 回試行しましたが、HTTP ${response.status} が続いています。`,
response.status,
attempt + 1
);
}
lastError = new RetryableError(
`HTTP ${response.status}`,
response.status,
attempt + 1
);
} catch (error) {
if (error instanceof RetryableError) throw error;
// AbortError(全体タイムアウトによる中断)はリトライせず即時再スローします
if ((error as Error).name === "AbortError") throw error;
// ネットワークエラー(一時的な接続失敗など)
const delayMs = calculateBackoffDelay(attempt, backoffOptions);
if (attempt < maxAttempts - 1) {
onRetry?.(attempt + 1, delayMs, (error as Error).message);
await sleep(delayMs);
lastError = error as Error;
} else {
throw error;
}
}
}
throw lastError ?? new Error("不明なエラー");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 使用例
const response = await fetchWithRetry(
"https://api.example.com/v1/completions",
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
body: JSON.stringify({ prompt: "Hello", max_tokens: 100 }),
},
{
maxAttempts: 4,
baseDelayMs: 1000,
maxDelayMs: 60000,
jitter: "full",
onRetry: (attempt, delayMs, reason) => {
console.warn(`[API] リトライ ${attempt}回目 / ${delayMs}ms 待機 / 理由: ${reason}`);
},
}
);
// コンソール出力例(429 が続いた場合)
// [API] リトライ 1回目 / 1843ms 待機 / 理由: HTTP 429
// [API] リトライ 2回目 / 3102ms 待機 / 理由: HTTP 429
// [API] リトライ 3回目 / 7654ms 待機 / 理由: HTTP 429
05キューイングと同時実行制御
なぜキューが必要か
リトライロジックだけでは、大量リクエストが同時発行された場合に全リクエストが並行してバックオフを繰り返します。 その結果、サーバー側への負荷は分散されず、むしろ連続的な 429 ループに陥ることがあります。
根本的な対策はリクエストの発行速度そのものを制御することです。 キューを導入すると次のことが実現できます。
- 同時実行数の上限を設ける(例: 最大5リクエストを並行処理)
- RPM 制限に合わせた速度制御(例: 毎秒3リクエスト以下)
- 優先度付きのリクエスト処理
シンプルな同時実行制御
まず最もシンプルなケースから始めます。同時実行数を制限するだけの軽量な実装です。
// src/queue/concurrency-limiter.ts
export class ConcurrencyLimiter {
private readonly queue: Array<() => void> = [];
private activeCount = 0;
constructor(private readonly maxConcurrency: number) {}
async run<T>(task: () => Promise<T>): Promise<T> {
// スロット空きを待つ
await this.acquire();
try {
return await task();
} finally {
this.release();
}
}
private acquire(): Promise<void> {
if (this.activeCount < this.maxConcurrency) {
this.activeCount++;
return Promise.resolve();
}
// キューに入って待機
return new Promise<void>((resolve) => {
this.queue.push(() => {
this.activeCount++;
resolve();
});
});
}
private release(): void {
this.activeCount--;
const next = this.queue.shift();
if (next) {
next();
}
}
get stats() {
return {
active: this.activeCount,
queued: this.queue.length,
};
}
}
// 使用例: 100件のリクエストを同時3件に制限して実行
import { ConcurrencyLimiter } from "./queue/concurrency-limiter.js";
const limiter = new ConcurrencyLimiter(3);
const ids = Array.from({ length: 100 }, (_, i) => i + 1);
const results = await Promise.all(
ids.map((id) =>
limiter.run(async () => {
const response = await fetch(`https://api.example.com/items/${id}`);
return response.json();
})
)
);
console.log(`取得完了: ${results.length}件`);
// => 取得完了: 100件
100件のリクエストを投げても、実際に処理されるのは常に最大3件です。 残りのリクエストはキューで待機し、処理が完了するたびに順番に実行されます。
レート制限を考慮したトークンバケット
同時実行数だけでなく「1分あたり何リクエストまで」という制御をしたい場合、**トークンバケット(token bucket)**アルゴリズムが参考になります。
考え方は以下の通りです。
- バケットにはトークンが満たされている(例: 60トークン)
- リクエストを1件発行するたびにトークンを1消費する
- 一定時間ごとにトークンを補充する(例: 毎秒1トークン補充)
- トークンがなければ補充されるまで待つ
// src/queue/token-bucket.ts
export interface TokenBucketOptions {
/** バケット容量(最大トークン数) */
capacity: number;
/** トークン補充速度(1秒あたりのトークン数) */
refillRatePerSecond: number;
}
export class TokenBucket {
private tokens: number;
private lastRefillTime: number;
constructor(private readonly options: TokenBucketOptions) {
this.tokens = options.capacity;
this.lastRefillTime = Date.now();
}
/** トークンを消費する。不足時は補充されるまで待機する */
async consume(count = 1): Promise<void> {
while (true) {
this.refill();
if (this.tokens >= count) {
this.tokens -= count;
return;
}
// 不足分が補充されるまでの待機時間を計算
const deficit = count - this.tokens;
const waitMs = (deficit / this.options.refillRatePerSecond) * 1000;
await sleep(waitMs);
}
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefillTime) / 1000; // 秒に変換
const refillAmount = elapsed * this.options.refillRatePerSecond;
this.tokens = Math.min(
this.options.capacity,
this.tokens + refillAmount
);
this.lastRefillTime = now;
}
get currentTokens(): number {
this.refill();
return this.tokens;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 使用例: 1分あたり60リクエスト(毎秒1件)に制限
import { TokenBucket } from "./queue/token-bucket.js";
const bucket = new TokenBucket({
capacity: 60, // バースト時は最大60件まで処理可能
refillRatePerSecond: 1, // 毎秒1トークン補充(= 60 RPM)
});
async function callApi(endpoint: string): Promise<unknown> {
await bucket.consume(1); // トークンを消費してから実際の呼び出し
const response = await fetch(`https://api.example.com${endpoint}`);
return response.json();
}
// 100件のリクエストを投げると、最初の60件はほぼ即時、
// 以降は毎秒1件ずつ処理される
const results = await Promise.all(
Array.from({ length: 100 }, (_, i) => callApi(`/items/${i + 1}`))
);
LLM API で TPM(トークン/分)制限を受けている場合は、consume() に実際のトークン数を渡すことで制御できます。
// TPM 制限対応の例(プロンプトのトークン数を渡す)
const estimatedTokens = Math.ceil(prompt.length / 4); // 目安: 文字数 / 4
await bucket.consume(estimatedTokens);
06タイムアウトの段階設計
タイムアウトの種類
「タイムアウト」という言葉は複数の概念をまとめて指すことがあります。 混乱を避けるために、以下の3種類を区別して考えます。
| 種類 | 意味 | 発生タイミング |
|---|---|---|
| 接続タイムアウト | サーバーへの TCP 接続が確立するまでの時間 | 接続開始直後 |
| 読み込みタイムアウト | 接続後、レスポンスの最初のバイトが届くまでの時間 | 接続確立後 |
| 全体タイムアウト | リクエスト開始からレスポンス完了までの時間 | リクエスト全体 |
Node.js の fetch では AbortController を使ってタイムアウトを実装します。
標準 fetch は接続タイムアウトと読み込みタイムアウトを別々に設定する API を持っていないため、全体タイムアウトとして実装することが多いです。
// src/api/timeout.ts
export interface TimeoutOptions {
/** 全体タイムアウト(ミリ秒)。デフォルト 30000ms */
totalTimeoutMs?: number;
}
export class TimeoutError extends Error {
constructor(public readonly timeoutMs: number) {
super(`リクエストが ${timeoutMs}ms 以内に完了しませんでした。`);
this.name = "TimeoutError";
}
}
export async function fetchWithTimeout(
url: string,
init: RequestInit,
options: TimeoutOptions = {}
): Promise<Response> {
const { totalTimeoutMs = 30000 } = options;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, totalTimeoutMs);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
return response;
} catch (error) {
if ((error as Error).name === "AbortError") {
throw new TimeoutError(totalTimeoutMs);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
LLM API に適したタイムアウト設定
LLM API のように応答時間が処理内容によって大きく変わる場合、単一のタイムアウト値では扱いが難しくなります。 短いプロンプトは数秒で返ってきますが、長い推論を伴う処理は数十秒かかることもあります。
筆者たちの経験では、タイムアウトを処理の性質ごとに分けておくと運用しやすくなります。
// src/api/llm-client.ts
import { fetchWithTimeout, TimeoutError } from "./timeout.js";
import { fetchWithRetry, RetryableError } from "./retry.js";
export interface LlmRequestOptions {
/** プロンプト */
prompt: string;
/** 最大生成トークン数 */
maxTokens?: number;
/** タスク種別(タイムアウト時間を変える) */
taskType?: "quick" | "standard" | "long-running";
}
// タスク種別ごとのタイムアウト設定(目安値。環境に合わせて調整)
const TIMEOUT_BY_TASK: Record<string, number> = {
quick: 10_000, // 分類・判定など短い処理
standard: 60_000, // 要約・翻訳など標準的な処理
"long-running": 180_000, // 長文生成・複数ステップの推論
};
export async function callLlmApi(
apiKey: string,
options: LlmRequestOptions
): Promise<string> {
const { prompt, maxTokens = 500, taskType = "standard" } = options;
const timeoutMs = TIMEOUT_BY_TASK[taskType];
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchWithRetry(
"https://api.example.com/v1/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ prompt, max_tokens: maxTokens }),
signal: controller.signal,
},
{
maxAttempts: 3,
baseDelayMs: 2000,
maxDelayMs: 30000,
jitter: "full",
onRetry: (attempt, delayMs) => {
console.warn(`[LLM] リトライ ${attempt}回目 / ${delayMs}ms 待機`);
},
}
);
if (!response.ok) {
throw new Error(`API エラー: HTTP ${response.status}`);
}
const data = await response.json() as { choices: Array<{ text: string }> };
return data.choices[0].text;
} catch (error) {
if ((error as Error).name === "AbortError") {
throw new TimeoutError(timeoutMs);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// 使用例
try {
const result = await callLlmApi(process.env.API_KEY!, {
prompt: "以下の文章を100文字以内で要約してください。...",
maxTokens: 200,
taskType: "standard",
});
console.log("要約結果:", result);
} catch (error) {
if (error instanceof TimeoutError) {
console.error(`タイムアウト: ${error.timeoutMs}ms 超過`);
} else if (error instanceof RetryableError) {
console.error(`最大リトライ回数超過: ${error.message}`);
} else {
console.error("予期しないエラー:", error);
}
}
タイムアウト設定を決める考え方
適切なタイムアウト値は環境や API によって異なるため、以下の手順で決めると良いと思います。
- まず実際の応答時間を数十〜数百件計測してパーセンタイルを確認する
- p95(95パーセンタイル)の応答時間を確認し、その1.5〜2倍を初期値にする
- 本番環境でメトリクスを取りながら調整する
「短すぎるタイムアウト」は正常なリクエストも失敗扱いにするため、ユーザー体験を損ないます。 一方「長すぎるタイムアウト」はリソースを長時間占有し、障害時の復旧を遅らせます。 どちらのリスクを重く見るかはシステムの性質によって変わります。
07ユーザーへの見せ方(UI・メッセージ設計)
エラーを「そのまま」見せない
429 や タイムアウトのエラーを技術的な表現のまま表示してしまうと、ユーザーは何が起きているかわからず不安になります。
// 避けたいメッセージの例
HTTP 429: Too Many Requests
Error: Request timed out after 30000ms
RetryableError: 5回試行しましたが、HTTP 429が続いています。
ユーザーが知りたいのは「自分のアクションがどうなるか」です。 技術的な状態をそのまま伝えるのではなく、ユーザーの文脈に翻訳することが大切です。
// src/ui/error-messages.ts
import { TimeoutError } from "../api/timeout.js";
import { RetryableError } from "../api/retry.js";
export interface UserFacingError {
title: string;
message: string;
/** ユーザーが取れる行動(省略可) */
action?: string;
/** 自動リトライ中かどうか */
isRetrying?: boolean;
}
export function toUserFacingError(error: unknown): UserFacingError {
if (error instanceof TimeoutError) {
return {
title: "処理に時間がかかっています",
message: "サーバーの応答が遅れています。しばらく待ってからもう一度お試しください。",
action: "ページを再読み込みする",
};
}
if (error instanceof RetryableError) {
if (error.statusCode === 429) {
return {
title: "リクエストが混み合っています",
message:
"ただいまアクセスが集中しています。数分後にもう一度お試しください。",
action: "1〜2分後に再試行する",
};
}
return {
title: "一時的なエラーが発生しました",
message: "サービスに一時的な問題が発生しています。しばらく待ってからお試しください。",
};
}
// ネットワーク接続エラー
if ((error as Error).message?.includes("fetch")) {
return {
title: "接続できませんでした",
message: "ネットワーク接続を確認してから、もう一度お試しください。",
};
}
return {
title: "エラーが発生しました",
message: "予期しないエラーが発生しました。サポートにお問い合わせください。",
};
}
処理中の待機状態を伝える
レート制限によるリトライ中や、処理が長くかかっている場面でユーザーをただ待たせるのは体験として良くありません。 「何が起きているか」と「どのくらい待てば良いか」を伝えることで、ユーザーの不安を軽減できます。
// src/ui/progress-reporter.ts
export type ProgressStatus =
| "pending" // 待機中
| "processing" // 処理中
| "retrying" // リトライ中
| "succeeded" // 完了
| "failed"; // 失敗
export interface ProgressState {
status: ProgressStatus;
message: string;
/** 0〜100 のパーセント(不明な場合は null) */
progress: number | null;
/** リトライ中の場合の残り待機時間(秒) */
retryAfterSeconds: number | null;
}
export function buildProgressMessage(state: ProgressState): string {
switch (state.status) {
case "pending":
return "処理待ち中です...";
case "processing":
if (state.progress !== null) {
return `処理中 (${state.progress}%)`;
}
return "処理中です...";
case "retrying":
if (state.retryAfterSeconds !== null && state.retryAfterSeconds > 0) {
return `混み合っているため ${state.retryAfterSeconds} 秒後に再試行します...`;
}
return "再試行中です...";
case "succeeded":
return "完了しました。";
case "failed":
return state.message;
}
}
フロントエンド側での表示例(React を使った場合)です。
// src/components/ProcessingIndicator.tsx(概念例)
// React v18 想定。実際のコンポーネント実装は環境に合わせて調整してください。
import { useState, useEffect } from "react";
import type { ProgressState } from "../ui/progress-reporter.js";
import { buildProgressMessage } from "../ui/progress-reporter.js";
interface Props {
state: ProgressState;
}
export function ProcessingIndicator({ state }: Props) {
const [countdown, setCountdown] = useState(state.retryAfterSeconds ?? 0);
useEffect(() => {
if (state.status !== "retrying" || countdown <= 0) return;
const timer = setInterval(() => {
setCountdown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [state.status, countdown]);
const message = buildProgressMessage({
...state,
retryAfterSeconds: countdown > 0 ? countdown : null,
});
return (
<div role="status" aria-live="polite">
{state.status === "processing" || state.status === "retrying" ? (
<span aria-label="読み込み中" /> // スピナー等を配置
) : null}
<p>{message}</p>
{state.status === "retrying" && countdown > 0 && (
<progress value={state.retryAfterSeconds! - countdown} max={state.retryAfterSeconds!} />
)}
</div>
);
}
aria-live="polite" を使うことで、スクリーンリーダーにも状態変化を伝えられます。
バックエンドからプログレスを通知する(SSE の活用)
処理時間が長くなる場合、WebSocket や Server-Sent Events(SSE)を使ってバックエンドから進捗を push することも選択肢のひとつです。 特にリトライ中の待機状態をリアルタイムで伝えたい場面では有効です。
// src/api/sse-handler.ts(Node.js + Express 想定)
// Express v4 / Node.js v20 想定
import type { Request, Response } from "express";
import type { ProgressState } from "../ui/progress-reporter.js";
export function createSseHandler(
taskExecutor: (onProgress: (state: ProgressState) => void) => Promise<unknown>
) {
return async (req: Request, res: Response) => {
// SSE ヘッダーを設定
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const sendEvent = (state: ProgressState) => {
const data = JSON.stringify(state);
res.write(`data: ${data}\n\n`);
};
try {
sendEvent({ status: "processing", message: "処理を開始しました。", progress: 0, retryAfterSeconds: null });
const result = await taskExecutor((progressState) => {
sendEvent(progressState);
});
sendEvent({ status: "succeeded", message: "処理が完了しました。", progress: 100, retryAfterSeconds: null });
res.write(`data: ${JSON.stringify({ done: true, result })}\n\n`);
} catch (error) {
sendEvent({
status: "failed",
message: (error as Error).message,
progress: null,
retryAfterSeconds: null,
});
} finally {
res.end();
}
};
}
// 使用例(Express のルート定義)
import express from "express";
import { createSseHandler } from "./api/sse-handler.js";
const app = express();
app.get("/api/process/:id", createSseHandler(async (onProgress) => {
onProgress({ status: "processing", message: "データを取得中...", progress: 10, retryAfterSeconds: null });
// ... 実際の処理(LLM API 呼び出しなど)
onProgress({ status: "processing", message: "分析中...", progress: 60, retryAfterSeconds: null });
// ... 続きの処理
return { analysisResult: "..." };
}));
08運用観点での設計チェックリスト
ここまでの内容を踏まえ、設計・実装レビュー時に確認すると良い項目をまとめます。
リトライ設計
- リトライ対象のエラーコードを明示的に列挙しているか(全エラーをリトライしていないか)
- 指数バックオフとジッターを使っているか
- 最大リトライ回数と最大待機時間の上限を設けているか
Retry-Afterヘッダーがある場合はそちらを優先しているか- リトライ不能なエラー(400 Bad Request、401 Unauthorized など)で無限ループしていないか
キュー・同時実行制御
- 同時リクエスト数の上限を設定しているか
- キューがいっぱいになった場合の挙動を定義しているか(拒否 or ブロック)
- RPM / TPM 制限に対してマージンを持った速度設定になっているか
タイムアウト
- 全ての外部 API 呼び出しにタイムアウトを設定しているか(デフォルト無制限になっていないか)
- 処理の性質に応じてタイムアウト値を分けているか
- タイムアウトエラーとリトライ可能エラーを区別して処理しているか
ユーザー体験
- 技術的なエラーメッセージをユーザーの言葉に翻訳しているか
- 長時間処理中の状態をユーザーに伝える手段があるか
- リトライ中の残り待機時間を表示しているか
09よくある落とし穴
落とし穴1: 全 4xx をリトライしてしまう
400 Bad Request や 401 Unauthorized はリトライしても成功しません。
リクエスト自体に問題があるか、認証情報が無効なケースです。
リトライ対象は 429 と 5xx に限定することを推奨します。
// 良くない例: 全 4xx をリトライしている
retryableStatusCodes: [400, 401, 403, 404, 429, 500]
// 良い例: 一時的なエラーのみリトライ
retryableStatusCodes: [429, 500, 502, 503, 504]
落とし穴2: タイムアウトなしの外部呼び出し
Node.js の fetch はデフォルトでタイムアウトが設定されていません。
外部 API がレスポンスを返さずにコネクションを維持したまま固まると、プロセスのコネクションを長時間占有し続けます。
全ての外部 API 呼び出しにタイムアウトを設定する習慣をつけておくと、障害時の影響を限定できます。
落とし穴3: キューサイズの上限設定を忘れる
メモリに積み込めるキューサイズに上限を設けていないと、大量リクエストが来た場合にメモリが枯渇することがあります。 上限に達したときに何をするか(エラーを返す、一定時間ブロックするなど)を事前に決めておく必要があります。
落とし穴4: リトライ時に副作用が重複する
POST リクエストなど副作用のある操作をリトライする場合、同じ処理が2回実行されてしまうことがあります。 冪等キー(idempotency key)を使うか、リトライ前に処理の状態確認を挟むか、設計上の対策が必要です。
// 冪等キーを使う例(API がサポートしている場合)
const idempotencyKey = crypto.randomUUID();
await fetchWithRetry(
"https://api.example.com/v1/payments",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // 同じキーで再送しても1回分だけ処理される
},
body: JSON.stringify({ amount: 1000, currency: "JPY" }),
}
);
10まとめ
429 やタイムアウトと向き合う設計をまとめると、以下の4つの層に整理できます。
| 層 | 対策 | ポイント |
|---|---|---|
| リトライ層 | 指数バックオフ+ジッター | Retry-After を優先。リトライ対象を限定 |
| 流量制御層 | キュー・同時実行制御 | 発射速度を制限してサーバーへの集中を防ぐ |
| タイムアウト層 | 処理種別ごとの上限設定 | 全ての外部呼び出しにタイムアウトを |
| UX 層 | エラーの翻訳・進捗通知 | ユーザーに「何が起きているか」を伝える |
これらを一度に全部実装するのは大変です。 筆者たちの経験では、まずリトライに指数バックオフを入れるだけでもかなりの改善になります。 その後、同時実行制御、タイムアウト設計、UX の順番で段階的に整えていくアプローチが現実的だと感じています。
「前提として設計する」という視点を持つことで、エラーを恐れるのではなく、どう付き合うかを考えられるようになります。 一部の設計判断(タイムアウト値の選択・キュー戦略)はシステムの特性に依存するため、本記事の例をそのまま適用するのではなく、実際の計測結果を見ながら調整していただければと思います。
(関連記事: エラー処理とリトライ設計:失敗からの復帰のさせ方)
11参考文献
- Exponential Backoff And Jitter — AWS Architecture Blog(2015年3月)
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ - MDN Web Docs — AbortController(2026年6月時点)
https://developer.mozilla.org/en-US/docs/Web/API/AbortController - MDN Web Docs — Using server-sent events(2026年6月時点)
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events - Token bucket — Wikipedia
https://en.wikipedia.org/wiki/Token_bucket - OpenAI API — Rate limits(2026年6月時点)
https://platform.openai.com/docs/guides/rate-limits

