このガイドのゴール
- 外部 API 呼び出し時のエラーを適切に分類し、種類ごとの対処パターンを実装する
- Exponential Backoff によるリトライ、Circuit Breaker によるカスケード障害の防止を理解する
- タイムアウト設計・エラーログ・Graceful Degradation を組み合わせた堅牢な連携基盤を作る
外部 API はいつか必ず落ちます。問題は「落ちるかどうか」ではなく「落ちたとき自分のサービスがどう振る舞うか」です。このガイドでは Stripe・OpenAI・SendGrid を題材に、実務で必要なエラーハンドリングパターンを整理します。
エラーの分類と対処方針
エラーを正しく分類することが、適切な対処の第一歩です。すべてのエラーを同じようにリトライするのは、無駄であり場合によっては危険です。
| 分類 | HTTP ステータス例 | リトライ | 対処 |
|---|---|---|---|
| クライアントエラー | 400, 401, 403, 422 | しない | リクエストを修正して再送。バリデーション不備やAPIキー失効 |
| レート制限 | 429 | する(待機あり) | Retry-After ヘッダに従う。Exponential Backoff |
| サーバエラー | 500, 502, 503 | する | 一時的障害の可能性。Backoff + 上限回数 |
| タイムアウト | - | する(条件付き) | 冪等な操作のみリトライ。非冪等は二重実行のリスク |
| ネットワークエラー | - | する | DNS 障害・接続拒否など。Circuit Breaker を検討 |
認証エラー(401)やバリデーションエラー(422)を何度リトライしても同じ結果になります。リトライ対象は 429・5xx・ネットワークエラーに限定してください。400 番台は即座にエラーログに記録し、開発者に通知すべきです。
アーキテクチャ: エラーハンドリングの全体像
Exponential Backoff の実装
リトライ間隔を指数関数的に増やすことで、障害中の API に過剰な負荷をかけることを防ぎます。
import time
import random
import httpx
def call_with_backoff(url, payload, max_retries=5):
for attempt in range(max_retries):
try:
response = httpx.post(url, json=payload, timeout=30)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 0))
wait = max(retry_after, (2 ** attempt) + random.uniform(0, 1))
time.sleep(wait)
continue
if response.status_code >= 500:
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
continue
return response # 成功 or クライアントエラー
except (httpx.TimeoutException, httpx.ConnectError):
if attempt == max_retries - 1:
raise
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
raise Exception("Max retries exceeded")
Jitter(ランダム化)が必要な理由
複数のクライアントが同時にリトライすると、同じタイミングで API に殺到します(Thundering Herd 問題)。random.uniform(0, 1) を加えることで、リトライタイミングを分散させます。
Stripe の公式 SDK は 429・5xx に対する Exponential Backoff を自動で行います。SDK を使っている場合は自前のリトライロジックを外側に重ねると二重リトライになるため注意してください。
Circuit Breaker パターン
連続して失敗している API への呼び出しを一時的に遮断し、自分のサービスのリソース(スレッド・コネクション)を保護します。
3 つの状態
| 状態 | 動作 | 遷移条件 |
|---|---|---|
| CLOSED | 通常通りリクエストを通す | 失敗率が閾値(例: 50%)を超えたら OPEN へ |
| OPEN | リクエストを即座に拒否(API を呼ばない) | 一定時間(例: 30 秒)経過したら HALF-OPEN へ |
| HALF-OPEN | 少数のリクエストだけ通す(テスト) | 成功すれば CLOSED へ、失敗すれば OPEN に戻る |
// Laravel での簡易 Circuit Breaker(Redis ベース)
class CircuitBreaker
{
public function call(string $service, Closure $action, Closure $fallback)
{
$failures = Cache::get("cb:{$service}:failures", 0);
$openUntil = Cache::get("cb:{$service}:open_until");
if ($openUntil && now()->lt($openUntil)) {
return $fallback(); // OPEN 状態: API を呼ばない
}
try {
$result = $action();
Cache::put("cb:{$service}:failures", 0, 300);
return $result;
} catch (\Exception $e) {
$failures++;
Cache::put("cb:{$service}:failures", $failures, 300);
if ($failures >= 5) {
Cache::put("cb:{$service}:open_until", now()->addSeconds(30), 60);
}
return $fallback();
}
}
}
外部 API のタイムアウトが 30 秒の場合、100 件のリクエストが同時に滞留すると 100 スレッドが 30 秒ずつ占有されます。自分のサーバのスレッドプールが枯渇し、外部 API と無関係な機能まで応答不能になります。
タイムアウトの設計
タイムアウトは「短すぎると正常なレスポンスを取りこぼし、長すぎるとスレッドが滞留する」トレードオフです。
| API の種類 | 接続タイムアウト | 読み取りタイムアウト | 根拠 |
|---|---|---|---|
| 決済(Stripe) | 5 秒 | 15 秒 | 通常 1〜3 秒で応答。15 秒あれば 3D セキュア含む処理も完了 |
| AI 推論(OpenAI) | 5 秒 | 120 秒 | GPT-4 の長文生成は 30〜60 秒かかることがある |
| メール送信(SendGrid) | 5 秒 | 10 秒 | 送信リクエスト自体は軽量。実際の配送は非同期 |
| 一般的な REST API | 5 秒 | 10 秒 | 大半の API は 5 秒以内に応答する |
# httpx でのタイムアウト設定
client = httpx.Client(
timeout=httpx.Timeout(
connect=5.0,
read=30.0,
write=5.0,
pool=10.0, # コネクションプール取得の待ち時間
)
)
エラーログの設計
障害発生時に原因を特定するために、ログには十分な情報を含めます。
記録すべき項目
- タイムスタンプ: 発生時刻(UTC)
- API エンドポイント: どの API のどのパスへのリクエストか
- HTTP ステータスコード: レスポンスのステータス(タイムアウト時は記録なし)
- リクエスト ID: 外部 API が返す
Request-Idヘッダ(Stripe 等)。サポート問い合わせ時に必要 - リトライ回数: 何回目のリトライで最終的に成功/失敗したか
- レスポンスボディ: エラーメッセージ(ただし個人情報や認証トークンはマスク)
Stripe のサポートに問い合わせる際、Request-Id があると調査が大幅に早くなります。レスポンスヘッダの Request-Id をログに含めてください。OpenAI も x-request-id をヘッダで返します。
Graceful Degradation
外部 API が完全にダウンしたとき、自分のサービス全体を止めるのではなく、該当機能だけを縮退させます。
| API | 縮退時の動作例 |
|---|---|
| OpenAI | AI 回答が使えない旨を表示し、FAQ ページへ誘導 |
| Stripe | 「現在決済処理が混み合っています。しばらくしてから再度お試しください」を表示 |
| SendGrid | メール送信をキューに積んでおき、復旧後に一括送信 |
// Graceful Degradation の実装例
public function getAiResponse(string $prompt): string
{
try {
return $this->circuitBreaker->call(
'openai',
fn () => $this->openai->chat($prompt),
fn () => 'ただいま AI 回答機能をご利用いただけません。FAQ ページをご確認ください。'
);
} catch (\Exception $e) {
Log::error('OpenAI fallback triggered', ['error' => $e->getMessage()]);
return 'ただいま AI 回答機能をご利用いただけません。FAQ ページをご確認ください。';
}
}
決済 API 特有の注意点
Stripe の課金リクエストをリトライする際は、必ず Idempotency-Key ヘッダを付けてください。同じキーで複数回リクエストしても課金は 1 回しか実行されません。キーなしでリトライすると二重課金が発生します。
import stripe
stripe.Charge.create(
amount=1000,
currency="jpy",
source=token,
idempotency_key=f"charge_{order_id}", # 注文 ID をキーにする
)
本番チェックリスト
- エラーを クライアントエラー / レート制限 / サーバエラー / タイムアウト に分類して処理している
- リトライは 429 と 5xx のみ を対象にしている(400 番台はリトライしない)
- Exponential Backoff + Jitter を実装している
- リトライ回数に 上限(3〜5 回)を設けている
- Circuit Breaker で連続障害時の呼び出しを遮断している
- タイムアウト を API の特性に合わせて設定している
- エラーログに タイムスタンプ・エンドポイント・ステータス・リクエスト ID を含めている
- 外部 API ダウン時の Graceful Degradation を実装している
- 決済リクエストには 冪等性キー を付与している
まとめ
- エラーは 分類してから対処する(400 番台はリトライ不要、429・5xx はリトライ対象)
- Exponential Backoff + Jitter でリトライ間隔を制御し、Thundering Herd を防ぐ
- Circuit Breaker でカスケード障害を防止し、自分のサーバのリソースを保護する
- タイムアウトは API の応答特性に合わせて 個別に設定する(AI 推論系は長めに)
- 決済は 冪等性キー で二重課金を防ぎ、Graceful Degradation でユーザー体験を維持する
この記事の情報は 2026 年 4 月時点のものです。各 API のエラー仕様やリトライポリシーは変更される可能性があります。公式ドキュメントのエラーハンドリングセクションを併せて確認してください。