01TL;DR
- エラーは「再試行可能かどうか」で分類してから対処方法を決めます。
- 指数バックオフ+ジッターで、リトライが集中してサービスをさらに追い詰める事態を防ぎます。
- 冪等性を確保しないままリトライすると、二重実行・二重課金などの副作用を生みます。
- チェックポイントを設けると、長時間タスクが途中で失敗しても最初からやり直す必要がなくなります。
- エラーを握りつぶすと「成功に見えるが何もしていない」状態が生まれ、後の診断が極端に難しくなります。
- サンプルコードはTypeScript(Node.js v20.18.1 / OpenAI API v4系)で記述しています。
02はじめに
この記事で得られること
AIエージェントを開発・運用していると、ある時点から「どう動かすか」より「どう壊れないようにするか」が主な関心になってきます。 外部APIの一時的な過負荷、ネットワークの瞬断、レート制限、想定外のレスポンス形式——こういった失敗は開発環境では再現しにくく、本番で初めて直面することが多いです。
この記事では、エラーを分類して対処戦略を決める考え方から、指数バックオフによるリトライ実装、冪等性の確保、チェックポイントからの再開、そしてユーザーへの報告設計まで、一連の流れをコード付きで解説します。
対象読者
- AIエージェントや自動化ワークフローの本番運用を経験しはじめた方
- リトライ処理をとりあえず書いたが、本当にこれでいいのかと感じている方
- タスクが途中で失敗したとき、どこから再開すればよいかを設計したい方
前提・実行環境
| 項目 | バージョン |
|---|---|
| Node.js | v20.18.1 |
| TypeScript | 5.4系 |
openai npm パッケージ |
v4系 |
zod npm パッケージ |
v3系 |
コードは簡潔さを優先して記述しており、プロダクション向けには認証・ロギング等の追加が必要な箇所があります。 また、特定のAIプロバイダ固有の動作ではなく、HTTP APIを利用する場面全般に適用できる考え方を中心に取り上げています。
03エラーの分類:対処戦略を決める入り口
リトライ処理を書く前に、まずエラーを「どう分類するか」を決める必要があります。 分類なしにすべてのエラーをリトライすると、回復不可能なエラーに対して無駄なリクエストを繰り返すことになります。
想定内エラーと想定外エラー
想定内エラーは、システムを設計した時点で「起こりうる」と認識していたエラーです。 代表的なものを挙げると次のとおりです。
- 外部APIのレート制限(HTTP 429)
- 一時的なサービス過負荷(HTTP 503)
- ネットワークタイムアウト
- 入力バリデーションエラー(HTTP 400、400系のうち入力起因のもの)
一方の想定外エラーは、設計時に考慮していなかったエラーです。 未知のレスポンス形式、内部状態の矛盾、依存ライブラリのバグなどが該当します。 想定外エラーはリトライしても解決しないことが多く、まずアラートを上げて人間に知らせる方が適切です。
再試行可能エラーと再試行不可能エラー
もう一つの軸が「再試行可能かどうか」です。
flowchart TD
ROOT["エラーの分類"]
ROOT --> R["再試行可能\n(Retryable)"]
ROOT --> C["状況依存\n(Conditional)"]
ROOT --> N["再試行不可能\n(Non-retryable)"]
R --> R1["HTTP 429(Rate Limit)"]
R --> R2["Network timeout"]
R --> R3["HTTP 502/504"]
C --> C1["HTTP 503\n(過負荷か障害かによって変わる)"]
N --> N1["HTTP 400(入力エラー)"]
N --> N2["HTTP 401/403(認証エラー)"]
N --> N3["HTTP 404(リソース不在)"]
N --> N4["バリデーション失敗"]
HTTPステータスコードを軸にした大まかな判断基準を示します。
| HTTPステータス | 再試行の可否 | 理由 |
|---|---|---|
| 429 | 可(遅延必須) | 一時的なレート制限 |
| 500 | 条件付き | サーバー内部エラー。冪等な操作なら可 |
| 502/503/504 | 可 | 一時的な可用性低下 |
| 400 | 不可 | リクエスト自体が誤り |
| 401/403 | 不可 | 認証・認可エラー。リトライしても変わらない |
| 404 | 不可 | リソースが存在しない |
この分類をコードに落とし込みます。
// error-classifier.ts
export type RetryDecision = "retry" | "no-retry" | "alert";
export interface ClassifiedError {
decision: RetryDecision;
reason: string;
originalError: unknown;
}
export function classifyError(error: unknown): ClassifiedError {
// HTTP レスポンスエラーの場合
if (isHttpError(error)) {
const status = error.status;
if (status === 429) {
return { decision: "retry", reason: "rate_limit", originalError: error };
}
if (status >= 500 && status < 600) {
return { decision: "retry", reason: "server_error", originalError: error };
}
if (status === 400) {
return { decision: "no-retry", reason: "bad_request", originalError: error };
}
if (status === 401 || status === 403) {
return { decision: "alert", reason: "auth_error", originalError: error };
}
if (status === 404) {
return { decision: "no-retry", reason: "not_found", originalError: error };
}
}
// ネットワークエラー
if (isNetworkError(error)) {
return { decision: "retry", reason: "network_error", originalError: error };
}
// 上記に当てはまらない場合は想定外エラーとして扱う
return { decision: "alert", reason: "unexpected_error", originalError: error };
}
function isHttpError(error: unknown): error is { status: number; message: string } {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
typeof (error as { status: unknown }).status === "number"
);
}
function isNetworkError(error: unknown): error is Error {
if (!(error instanceof Error)) return false;
const networkErrorPatterns = ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND"];
return networkErrorPatterns.some((pattern) => error.message.includes(pattern));
}
04指数バックオフとジッター
再試行可能と判断したあと、次の問いは「いつリトライするか」です。 即座にリトライすると、同じ原因(レート制限や過負荷)でまた失敗します。 しかも、複数のクライアントが同時に失敗して同時にリトライすると、サービスへのリクエストが一点に集中し、回復を妨げることがあります(Thundering Herd問題と呼ばれます)。
指数バックオフとは
指数バックオフは、リトライのたびに待機時間を指数的に増やす戦略です。 1回目は1秒、2回目は2秒、3回目は4秒……というように増やします。
// wait-time = base * (2 ^ attempt)
// 例: base=1000ms の場合
// attempt 0: 1000ms
// attempt 1: 2000ms
// attempt 2: 4000ms
// attempt 3: 8000ms
ジッターで集中を避ける
指数バックオフだけだと、同時に失敗した複数のクライアントが同じタイミングでリトライします。 **ジッター(Jitter)**はランダムな揺らぎを加えることで、タイミングを分散させます。
よく使われるのは「Full Jitter」と呼ばれる方法です。 計算した待機時間の0〜100%の範囲でランダムに選びます。
waitTime = random(0, base * 2^attempt)
これを実装します。
// backoff.ts
export interface BackoffOptions {
/** 基底待機時間 (ms) */
baseDelayMs: number;
/** 最大待機時間 (ms) */
maxDelayMs: number;
/** 最大リトライ回数 */
maxAttempts: number;
/** ジッターを使うか */
useJitter: boolean;
}
export const DEFAULT_BACKOFF_OPTIONS: BackoffOptions = {
baseDelayMs: 1000,
maxDelayMs: 30000,
maxAttempts: 5,
useJitter: true,
};
/**
* 指定されたattempt番号に対する待機時間(ms)を返す。
* attempt は 0 始まり。
*/
export function calculateBackoffDelay(attempt: number, options: BackoffOptions): number {
const exponential = options.baseDelayMs * Math.pow(2, attempt);
const capped = Math.min(exponential, options.maxDelayMs);
if (options.useJitter) {
// Full Jitter: 0 〜 capped の範囲でランダム
return Math.floor(Math.random() * capped);
}
return capped;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
実行結果の確認用にログを出すと、待機時間の変化が見えます。
attempt=0 -> delay= 0ms (jitter適用で0になることもある)
attempt=1 -> delay= 873ms
attempt=2 -> delay=2341ms
attempt=3 -> delay=6012ms
attempt=4 -> delay=11854ms
リトライループを組み立てる
分類とバックオフを組み合わせたリトライループです。
// retry.ts
import { classifyError } from "./error-classifier";
import { calculateBackoffDelay, sleep, DEFAULT_BACKOFF_OPTIONS, BackoffOptions } from "./backoff";
export type RetryResult<T> =
| { success: true; value: T; attempts: number }
| { success: false; error: unknown; attempts: number; finalDecision: "no-retry" | "alert" | "exhausted" };
export async function withRetry<T>(
operation: () => Promise<T>,
options: Partial<BackoffOptions> = {}
): Promise<RetryResult<T>> {
const opts = { ...DEFAULT_BACKOFF_OPTIONS, ...options };
let lastError: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
const value = await operation();
return { success: true, value, attempts: attempt + 1 };
} catch (error) {
lastError = error;
const classified = classifyError(error);
console.error(
`[retry] attempt=${attempt + 1}/${opts.maxAttempts} decision=${classified.decision} reason=${classified.reason}`
);
if (classified.decision !== "retry") {
// リトライしない・アラート対象はすぐに抜ける
return {
success: false,
error,
attempts: attempt + 1,
finalDecision: classified.decision,
};
}
// 最後のattemptでも失敗していたらリトライしない
if (attempt < opts.maxAttempts - 1) {
const delay = calculateBackoffDelay(attempt, opts);
console.error(`[retry] waiting ${delay}ms before next attempt`);
await sleep(delay);
}
}
}
return {
success: false,
error: lastError,
attempts: opts.maxAttempts,
finalDecision: "exhausted",
};
}
使い方は次のとおりです。
// usage-example.ts
import OpenAI from "openai";
import { withRetry } from "./retry";
// OpenAI SDK はデフォルトで一部エラーを最大2回自動リトライします。
// withRetry と組み合わせる場合は maxRetries: 0 を指定して二重リトライを防いでください。
const client = new OpenAI({ maxRetries: 0 });
async function callChatCompletion(prompt: string): Promise<string> {
const result = await withRetry(
() =>
client.chat.completions.create({
model: "gpt-5.4-mini",
messages: [{ role: "user", content: prompt }],
}),
{ maxAttempts: 4, baseDelayMs: 500 }
);
if (!result.success) {
throw new Error(
`API call failed after ${result.attempts} attempts: ${result.finalDecision}`
);
}
return result.value.choices[0]?.message?.content ?? "";
}
// 実行ログの例(HTTP 429 が1回発生した場合)
[retry] attempt=1/4 decision=retry reason=rate_limit
[retry] waiting 312ms before next attempt
[retry] attempt=2/4 decision=retry reason=rate_limit ← 再度失敗
[retry] waiting 1847ms before next attempt
[retry] attempt=3/4 ... ← 成功
05冪等性の確保:「何度やっても同じ結果」を設計する
リトライ設計で見落としがちなのが**冪等性(Idempotency)**です。 冪等とは「同じ操作を何度繰り返しても結果が同じ」という性質です。
たとえば、「メールを送信する」操作は冪等ではありません。 リトライするたびに同じメールが複数送られてしまいます。 一方、「特定のレコードを作成または更新する(UPSERT)」は、同じデータで何度実行しても最終的な状態が同じです。
なぜ冪等性が重要か
エラーが発生したとき、操作が「完了したのかしていないのか」が不明な場合があります。 たとえば、リクエストは届いたがレスポンスが返ってくる前にネットワーク切断が起きた場合、サーバー側では処理が完了しているかもしれません。 冪等性がない操作をリトライすると、二重実行・二重課金・重複レコード作成などの問題が起きます。
sequenceDiagram
participant C as クライアント
participant S as サーバー
C->>S: リクエスト: 支払い処理
S-->>C: タイムアウト(処理は完了済み)
C->>S: リトライ: 支払い処理(二重課金が発生)
冪等キーで重複を防ぐ
多くの決済APIやメッセージキューが採用している対策が冪等キーです。 リクエストに一意のIDを付け、サーバー側で「同じIDのリクエストは一度しか処理しない」ようにします。
// idempotent-request.ts
import { randomUUID } from "crypto";
interface PaymentRequest {
amount: number;
currency: string;
recipientId: string;
}
interface PaymentResponse {
transactionId: string;
status: "completed" | "pending" | "failed";
}
/**
* 冪等キーを付けた支払いリクエストの例。
* 実際のAPIはIdempotency-Keyヘッダーをサポートしている必要があります。
*/
async function createPaymentIdempotent(
request: PaymentRequest,
idempotencyKey: string = randomUUID()
): Promise<PaymentResponse> {
const response = await fetch("https://api.example.com/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
// 同じキーで再送しても、サーバーは最初の結果を返す
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw Object.assign(new Error("Payment request failed"), { status: response.status });
}
return response.json() as Promise<PaymentResponse>;
}
// リトライ時は同じ idempotencyKey を使い回す
async function createPaymentWithRetry(request: PaymentRequest): Promise<PaymentResponse> {
const idempotencyKey = randomUUID(); // リトライループの外で生成する
const result = await withRetry(() => createPaymentIdempotent(request, idempotencyKey));
if (!result.success) {
throw new Error("Payment failed after retries");
}
return result.value;
}
AIエージェントのツール呼び出しで冪等性を意識する
AIエージェントでは、ツール(Function Calling)がリトライされる場面で問題が起きやすいです。 外部サービスへの書き込み系ツール(メール送信・DB挿入・Webhook呼び出しなど)は、冪等な実装を心がける必要があります。
操作を「読み取り系」と「書き込み系」で明確に分けておくと、どこにリトライを適用してよいかが整理できます。
// tool-definitions.ts
// 読み取り系: 何度実行しても副作用がない → リトライ自由
const readTools = [
"search_web",
"get_user_profile",
"fetch_document",
"list_emails",
] as const;
// 書き込み系: 冪等キーまたは重複チェックが必要
const writeTools = [
"send_email",
"create_record",
"post_message",
"charge_payment",
] as const;
type ReadTool = typeof readTools[number];
type WriteTool = typeof writeTools[number];
function isReadTool(name: string): name is ReadTool {
return (readTools as readonly string[]).includes(name);
}
/**
* ツールの種類に応じてリトライ戦略を変える。
*/
async function executeToolWithStrategy(
toolName: string,
toolFn: () => Promise<unknown>
): Promise<unknown> {
if (isReadTool(toolName)) {
// 読み取り系: 積極的にリトライ
const result = await withRetry(toolFn, { maxAttempts: 5 });
if (!result.success) throw new Error(`Tool ${toolName} failed`);
return result.value;
}
// 書き込み系: リトライは慎重に。冪等性が保証されている場合のみ
// ここでは1回だけ試み、失敗したら上位に委ねる
return toolFn();
}
06チェックポイント設計:途中から再開できるタスクを作る
リトライが単一のAPIコールを対象にするのに対して、チェックポイントは複数ステップからなる長時間タスク全体を対象にします。
たとえば「100件のドキュメントを要約してDBに保存する」タスクが、70件目で失敗したとします。 チェックポイントがなければ最初から100件やり直しです。 チェックポイントがあれば、70件目から再開できます。
チェックポイントの基本設計
チェックポイントには「どこまで完了したか」を記録します。 ファイル・DB・KVSなど、タスクが再起動しても残るストレージに書き出します。
// checkpoint.ts
import { readFile, writeFile, mkdir } from "fs/promises";
import { join } from "path";
export interface Checkpoint<TState> {
taskId: string;
completedItems: number;
totalItems: number;
state: TState;
updatedAt: string;
}
const CHECKPOINT_DIR = "./task-checkpoints";
export async function saveCheckpoint<TState>(
taskId: string,
completedItems: number,
totalItems: number,
state: TState
): Promise<void> {
await mkdir(CHECKPOINT_DIR, { recursive: true });
const checkpoint: Checkpoint<TState> = {
taskId,
completedItems,
totalItems,
state,
updatedAt: new Date().toISOString(),
};
const filePath = join(CHECKPOINT_DIR, `${taskId}.json`);
await writeFile(filePath, JSON.stringify(checkpoint, null, 2), "utf-8");
}
export async function loadCheckpoint<TState>(
taskId: string
): Promise<Checkpoint<TState> | null> {
const filePath = join(CHECKPOINT_DIR, `${taskId}.json`);
try {
const content = await readFile(filePath, "utf-8");
return JSON.parse(content) as Checkpoint<TState>;
} catch {
// チェックポイントがなければnullを返す(初回実行)
return null;
}
}
バッチ処理タスクへの適用
ドキュメントを順次処理するタスクにチェックポイントを組み込む例です。
// batch-summarizer.ts
import OpenAI from "openai";
import { saveCheckpoint, loadCheckpoint } from "./checkpoint";
import { withRetry } from "./retry";
interface Document {
id: string;
content: string;
}
interface SummaryResult {
documentId: string;
summary: string;
}
interface BatchState {
results: SummaryResult[];
}
const client = new OpenAI();
async function summarizeDocument(doc: Document): Promise<SummaryResult> {
const result = await withRetry(() =>
client.chat.completions.create({
model: "gpt-5.4-mini",
messages: [
{
role: "user",
content: `以下のドキュメントを3文で要約してください。\n\n${doc.content}`,
},
],
})
);
if (!result.success) {
throw new Error(`Failed to summarize document ${doc.id}`);
}
return {
documentId: doc.id,
summary: result.value.choices[0]?.message?.content ?? "",
};
}
/**
* ドキュメントを順次要約する。チェックポイントから再開できる。
*/
export async function summarizeDocuments(
taskId: string,
documents: Document[]
): Promise<SummaryResult[]> {
// チェックポイントがあれば途中から再開
const checkpoint = await loadCheckpoint<BatchState>(taskId);
let startIndex = 0;
let results: SummaryResult[] = [];
if (checkpoint) {
startIndex = checkpoint.completedItems;
results = checkpoint.state.results;
console.log(
`[batch] resuming from item ${startIndex}/${documents.length} (task: ${taskId})`
);
} else {
console.log(`[batch] starting fresh (task: ${taskId}, total: ${documents.length})`);
}
for (let i = startIndex; i < documents.length; i++) {
const doc = documents[i];
try {
const summary = await summarizeDocument(doc);
results.push(summary);
// N件ごとにチェックポイントを保存(ここでは10件ごと)
if ((i + 1) % 10 === 0 || i === documents.length - 1) {
await saveCheckpoint(taskId, i + 1, documents.length, { results });
console.log(`[batch] checkpoint saved: ${i + 1}/${documents.length}`);
}
} catch (error) {
console.error(`[batch] failed at item ${i} (documentId: ${doc.id}):`, error);
// チェックポイントを保存してからエラーを上位に伝える
await saveCheckpoint(taskId, i, documents.length, { results });
throw error;
}
}
console.log(`[batch] completed: ${documents.length} documents processed`);
return results;
}
// 実行ログの例(70件目で失敗し、再実行した場合)
--- 1回目の実行 ---
[batch] starting fresh (task: summary-2026-06, total: 100)
[batch] checkpoint saved: 10/100
[batch] checkpoint saved: 20/100
...
[batch] checkpoint saved: 70/100
[batch] failed at item 70 (documentId: doc-071): Error: API call failed
--- 2回目の実行(再起動後)---
[batch] resuming from item 70/100 (task: summary-2026-06)
[batch] checkpoint saved: 80/100
...
[batch] checkpoint saved: 100/100
[batch] completed: 100 documents processed
チェックポイントの粒度
チェックポイントを保存する頻度には、コストとのトレードオフがあります。
| 粒度 | メリット | デメリット |
|---|---|---|
| 1件ごと | 最大限やり直しを減らせる | I/Oコストが高い |
| N件ごと | I/Oコストを抑えられる | 最悪N-1件のやり直しが必要 |
| 重要な区切りのみ | 自然な単位で再開できる | 区切りを設計する手間がかかる |
筆者たちの経験では、1回のAPI呼び出しコストが高い場合(GPT-4oクラスのモデル)は1〜5件ごと、コストが低い場合(embeddings等)は10〜50件ごとを目安にしています。 これはあくまで一例で、タスクの特性によって調整が必要です。
07エラーを握りつぶさない:報告設計
エラー処理でもっとも危険なアンチパターンの一つが、エラーの握りつぶしです。
// アンチパターン: エラーを握りつぶす
try {
await sendNotification(userId, message);
} catch {
// 何もしない
// 「通知は重要じゃないから失敗しても無視でいい」という意図かもしれないが……
}
この書き方の問題は「呼び出し元が成功したと思って次の処理を続ける」点です。 通知が届いていないのにログには成功と残り、後になってユーザーから「なぜ通知が来なかったのか」と問い合わせが来たとき、原因を追いにくくなります。
最低限のエラー記録
握りつぶすくらいなら、少なくともログに残します。
// 最低限の対処
try {
await sendNotification(userId, message);
} catch (error) {
// エラー自体は飲み込むが、必ずログに残す
console.error("[notification] failed to send notification", {
userId,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
// 必要なら外部の監視サービスに送る
await reportToMonitoring(error, { context: "notification", userId });
}
エラーの構造化ログ
監視ツールで集計・アラートを設定するために、エラーを構造化して出力します。
// structured-error-logger.ts
type ErrorSeverity = "debug" | "info" | "warn" | "error" | "critical";
interface StructuredError {
severity: ErrorSeverity;
code: string;
message: string;
context: Record<string, unknown>;
timestamp: string;
traceId?: string;
}
export function logStructuredError(
severity: ErrorSeverity,
code: string,
message: string,
context: Record<string, unknown> = {},
traceId?: string
): void {
const entry: StructuredError = {
severity,
code,
message,
context,
timestamp: new Date().toISOString(),
traceId,
};
// JSON 1行で出力すると監視ツールがパースしやすい
console.error(JSON.stringify(entry));
}
// 使用例
logStructuredError(
"error",
"TOOL_EXECUTION_FAILED",
"Tool execution failed after retries",
{
toolName: "send_email",
userId: "user-123",
attempts: 5,
lastError: "HTTP 503 Service Unavailable",
},
"trace-abc123"
);
// 出力例
{
"severity": "error",
"code": "TOOL_EXECUTION_FAILED",
"message": "Tool execution failed after retries",
"context": {
"toolName": "send_email",
"userId": "user-123",
"attempts": 5,
"lastError": "HTTP 503 Service Unavailable"
},
"timestamp": "2026-06-11T10:30:00.000Z",
"traceId": "trace-abc123"
}
08ユーザーへのエラー報告設計
技術的なエラー処理とは別に、「ユーザーにどう伝えるか」も設計が必要です。
エラーの種別によってメッセージを変える
内部の技術的な詳細をそのままユーザーに見せると、混乱を招いたりセキュリティ上のリスクになったりします。 エラーの種別に応じてメッセージを使い分けます。
// user-error-message.ts
type UserFacingError =
| { type: "temporary"; retryAfterSeconds?: number }
| { type: "input_error"; field: string; hint: string }
| { type: "permission_denied" }
| { type: "resource_not_found"; resourceType: string }
| { type: "unexpected" };
export function toUserMessage(error: UserFacingError): string {
switch (error.type) {
case "temporary":
if (error.retryAfterSeconds) {
return `一時的なエラーが発生しました。${error.retryAfterSeconds}秒後に再試行してください。`;
}
return "一時的なエラーが発生しました。しばらくしてから再試行してください。";
case "input_error":
return `入力内容に問題があります(${error.field}):${error.hint}`;
case "permission_denied":
return "この操作を行う権限がありません。管理者にお問い合わせください。";
case "resource_not_found":
return `指定された${error.resourceType}が見つかりませんでした。`;
case "unexpected":
default:
return "予期しないエラーが発生しました。問題が続く場合はサポートにお問い合わせください。";
}
}
AIエージェントのレスポンスへの組み込み
AIエージェントが複数ステップのタスクを実行するとき、どのステップで失敗したかをユーザーに伝える設計の例です。
// agent-error-response.ts
interface AgentTaskResult {
success: boolean;
completedSteps: string[];
failedStep?: string;
userMessage: string;
technicalDetails?: string; // 開発者向け。本番では出力しない
}
async function runAgentTask(steps: Array<{ name: string; fn: () => Promise<void> }>): Promise<AgentTaskResult> {
const completedSteps: string[] = [];
for (const step of steps) {
try {
await step.fn();
completedSteps.push(step.name);
} catch (error) {
const classified = classifyError(error);
const userMessage =
classified.reason === "rate_limit"
? "処理が一時的に制限されています。数分後に再度お試しください。"
: "処理中にエラーが発生しました。最初からやり直してください。";
return {
success: false,
completedSteps,
failedStep: step.name,
userMessage,
technicalDetails: `step=${step.name} reason=${classified.reason}`,
};
}
}
return {
success: true,
completedSteps,
userMessage: "すべての処理が完了しました。",
};
}
09全体を組み合わせた設計図
ここまでの要素を組み合わせると、次のような流れになります。
flowchart TD
START["タスク開始"] --> CHECKPOINT["チェックポイントを確認"]
CHECKPOINT -- "あり" --> RESUME["途中から再開"]
CHECKPOINT -- "なし" --> FRESH["最初から開始"]
RESUME --> STEP["各ステップを実行"]
FRESH --> STEP
STEP -- "成功" --> SAVE["チェックポイントを保存"]
SAVE --> NEXT["次のステップへ"]
STEP -- "失敗" --> CLASSIFY["エラーを分類"]
CLASSIFY -- "再試行可" --> BACKOFF["バックオフ後リトライ"]
BACKOFF --> STEP
CLASSIFY -- "再試行不可" --> USERMSG["ユーザーへ適切なメッセージを返す"]
CLASSIFY -- "想定外" --> ALERT["アラート(監視通知)"]
ALERT --> USERMSG
実際のコードでは、これらを薄いラッパー関数として層を分けておくと、個々の責務が明確になります。
// orchestrator.ts(各要素の組み合わせ例)
import { withRetry } from "./retry";
import { saveCheckpoint, loadCheckpoint } from "./checkpoint";
import { logStructuredError } from "./structured-error-logger";
import { toUserMessage } from "./user-error-message";
import { classifyError } from "./error-classifier";
interface ProcessingOptions {
taskId: string;
batchSize?: number;
}
export async function processItemsReliably<TInput, TOutput>(
items: TInput[],
processor: (item: TInput) => Promise<TOutput>,
options: ProcessingOptions
): Promise<{ results: TOutput[]; message: string }> {
const { taskId, batchSize = 10 } = options;
const checkpoint = await loadCheckpoint<{ results: TOutput[] }>(taskId);
let startIndex = checkpoint?.completedItems ?? 0;
const results: TOutput[] = checkpoint?.state.results ?? [];
for (let i = startIndex; i < items.length; i++) {
let processed: TOutput;
const retryResult = await withRetry(() => processor(items[i]));
if (!retryResult.success) {
const classified = classifyError(retryResult.error);
logStructuredError("error", "ITEM_PROCESSING_FAILED", "Item processing failed", {
taskId,
itemIndex: i,
totalItems: items.length,
reason: classified.reason,
});
await saveCheckpoint(taskId, i, items.length, { results });
const userMsg = toUserMessage(
classified.reason === "rate_limit"
? { type: "temporary", retryAfterSeconds: 60 }
: { type: "unexpected" }
);
return { results, message: userMsg };
}
processed = retryResult.value;
results.push(processed);
if ((i + 1) % batchSize === 0 || i === items.length - 1) {
await saveCheckpoint(taskId, i + 1, items.length, { results });
}
}
return { results, message: "すべての処理が完了しました。" };
}
10BizPlanでの設計思想として
私たちが開発している事業計画エージェント BizPlan でも、複数の外部APIを呼び出すタスクが常に走っています。 エラー処理を後付けで入れようとすると、どの層でハンドリングするかが混乱しがちでした。
実際に取り組んで有効だと感じたのは、次の3点です。
エラー分類を関心ごとに分離する。
classifyErrorのような純粋関数にしておくと、テストが書きやすく、分類ロジックの変更が局所化されます。チェックポイントを「書き込み優先」で設計する。「成功したら保存」より「失敗する前に保存」を意識すると、再開時の取りこぼしが少なくなります。
ユーザーメッセージとシステムログを明確に分ける。ユーザーには「何ができるか」を伝え、ログには「何が起きたか」を残す、という役割分担を最初から決めておくことで、デバッグとUXの両立がしやすくなりました。
これらも「自分たちの場合にうまく機能した」という事例であり、システムの性質や要件によって適切な設計は異なります。
11まとめ
エラー処理とリトライ設計の要点を整理します。
- エラーは「再試行可能か否か」と「想定内か想定外か」で分類してから対処方法を決めます。
- 指数バックオフ+ジッターで、リトライのタイミングを分散させ、Thundering Herd問題を防ぎます。
- 冪等性のない操作はリトライで二重実行が発生します。冪等キーや書き込み前の存在確認で対策します。
- チェックポイントを設けることで、長時間タスクを失敗箇所から再開できます。
- エラーを握りつぶすと診断が困難になります。最低限のログ記録、可能なら構造化ログを出力します。
- ユーザーへのメッセージは技術詳細を隠し、「何をすればよいか」を伝える形にします。
関連記事として、レート制限・タイムアウトとの付き合い方、コンテキストウィンドウ管理:長い対話で情報を失わない工夫も合わせて参照していただければ、ネットワーク境界での対処全体を俯瞰できます。
12参考文献
- AWS Architecture Blog, "Exponential Backoff And Jitter" (2015年3月) — Full Jitter・Equal Jitter の比較分析
- Google Cloud, "Handling API errors" — 再試行可能エラーの分類基準
- Stripe API Reference, "Idempotent requests" — 冪等キー設計のリファレンス実装
- Microsoft Azure Architecture Center, "Retry pattern" — クラウドアーキテクチャにおけるリトライパターンの解説

