AI APIの障害は、通常のAPIの障害とは異なります。200が返ってきても、生成が成功したとは限りません。content フィールドが null でも、必ずしもエラーではありません。そして、昨日うまくいった同じプロンプトが、プロバイダーのコンテンツポリシー更新により今日は失敗することもあります。
このガイドでは、AI APIのエラーの読み方、各失敗モードの意味、そして「何が壊れたのか」を特定できるエラーハンドリングの作り方を解説します。「何かが壊れた」だけで終わらせないために。
Note: この記事で使用している gpt-5.4 や gpt-5.4-mini のようなモデル名は、CometAPI のプラットフォーム識別子です。これらは https://api.cometapi.com/v1 経由でのみ動作し、OpenAI や Anthropic のAPIを直接叩いても動作しません。詳しくは モデル一覧 を参照してください。
なぜAI APIのデバッグは通常のAPIのデバッグより難しいのか
一般的なREST APIでは、200は成功、4xxはクライアントの誤りを意味します。AI APIには第3のカテゴリ、つまりHTTP 200を返すものの、使えるコンテンツが含まれていない「ソフトフェイル」があります。
起こりうる問題は3つあります:
- ハードフェイル — HTTPエラー(4xx, 5xx)。リクエストが完了していない。
- ソフトフェイル — HTTP 200だが、
finish_reasonがcontent_filterまたはlength、あるいはcontentがnull。 - サイレントフェイル — HTTP 200で見た目は正常だが、アプリケーション層でしか検知できない誤った出力。
多くのエラーハンドリングはケース1だけを扱います。実運用でバグが潜みやすいのはケース2と3です。
エラーレスポンス形式を理解する
テキスト補完エンドポイントは、一貫したエラー構造を返します:
{ "error": { "message": "human-readable description (often includes request id)", "type": "comet_api_error", "param": "the_problematic_parameter_or_null", "code": "error_code_or_null" }}
画像や動画のエンドポイントは異なるエラー形式を返します — エンドポイント間で固定の構造を仮定せず、常に生のレスポンスボディをパースしてください。
message フィールドには、何が問題なのかがたいてい明確に書かれています。param フィールドは、どのパラメータが原因かを示します。必ず両方をログに残しましょう。
HTTPステータスコードの意味を把握する
| Status | Meaning | Common cause | Fix |
|---|---|---|---|
| 400 | Bad request | モデルの欠落、このモデルに不正なパラメータ | レスポンスの error.param を確認 |
| 401 | Unauthorized | APIキーが不正または欠落 | Authorization: Bearer |
| 429 | Rate limited | リクエスト過多 | 指数バックオフ(手順4参照) |
| 500 | Server error | プロバイダー側の問題、または不正なリクエストボディ | バックオフつきで再試行;リクエスト形式確認 |
| 504 | Gateway timeout | プロバイダーが時間内に応答できず | 再試行;より高速なモデルを検討 |
出典**:** CometAPI chat completions docs
リトライ戦略では、400 と 500 の違いが重要です。400 はリクエストが誤っていることを意味し、同じリクエストを再試行しても状況は改善しません。500 や 504 はサーバー側の問題を示すため、再試行に意味があります。
finish_reason を確認する — 最も見落とされがちなフィールド
finish_reason: "content_filter" でHTTP 200のレスポンスは、生成がブロックされたことを意味します。content フィールドは null または空になります。これを確認しないと、アプリは何も返さずに黙って終わってしまいます。
| finish_reason | Meaning | What to do | Fix |
|---|---|---|---|
| stop | 正常終了 | 何もしない — 成功です | レスポンスの error.param を確認 |
| length | トークン上限に到達 | max_tokens を増やすか、プロンプトを短くする | Authorization: Bearer |
| content_filter | セーフティポリシーによりブロック | プロンプトを言い換える;特定の名称/話題を避ける | 指数バックオフ(手順4参照) |
| tool_calls | テキストの代わりにツールを呼び出した | ツール呼び出しを処理する;content は null | バックオフつきで再試行;リクエスト形式確認 |
| 504 | Gateway timeout | プロバイダーが時間内に応答できず | 再試行;より高速なモデルを検討 |
出典**:** CometAPI chat completions docs
import osimport loggingfrom openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutErrorfrom dotenv import load_dotenvload_dotenv()api_key = os.environ.get("COMETAPI_KEY")if not api_key: raise ValueError("COMETAPI_KEY is not set")client = OpenAI( base_url="https://api.cometapi.com/v1", api_key=api_key,)def safe_complete(messages: list, model: str = "gpt-5.4-mini", **kwargs) -> dict: """ Complete a chat request with full error and finish_reason handling. Returns {"content": str, "finish_reason": str, "tool_calls": list | None} Raises on API errors. """ try: response = client.chat.completions.create( model=model, messages=messages, **kwargs ) except APIStatusError as e: error_body = {} try: error_body = e.response.json().get("error", {}) except Exception: pass logging.error( f"API error status={e.status_code} " f"message={error_body.get('message')} " f"param={error_body.get('param')}" ) raise except (APIConnectionError, APITimeoutError) as e: logging.error(f"Network/timeout error: {e}") raise choice = response.choices[0] finish_reason = choice.finish_reason if finish_reason == "content_filter": raise ValueError( f"Generation blocked by content filter. " f"Model: {model}. Rephrase the prompt." ) if finish_reason == "length": used = response.usage.completion_tokens if response.usage else "unknown" logging.warning(f"Output truncated at token limit. Used {used} tokens.") # Return structured result so callers can handle tool_calls explicitly return { "content": choice.message.content or "", "finish_reason": finish_reason, "tool_calls": choice.message.tool_calls, }# Usageresult = safe_complete( messages=[{"role": "user", "content": "Summarize this article: [text]"}], model="gpt-5.4-mini")if result["finish_reason"] == "tool_calls": # Handle tool call — content will be empty print("Model wants to call a tool:", result["tool_calls"])else: print(result["content"])
サイレントフェイルをアプリケーション層で検出する
サイレントフェイルは最も検出が難しいものです。APIは200を返し、finish_reason は stop でも、出力が意味的に誤っています。これはアプリケーション層でしか検出できません。
よくあるパターン:
def validate_completion(result: dict, task: str) -> str: """ Application-layer validation for silent failures. Raises ValueError if the output doesn't meet basic expectations. """ content = result["content"].strip() # Empty output that isn't a tool call if not content and result["finish_reason"] != "tool_calls": raise ValueError(f"Empty output for task '{task}' with finish_reason='{result['finish_reason']}'") # Task-specific checks if task == "classify": valid_labels = {"positive", "negative", "neutral"} if content.lower() not in valid_labels: logging.warning( f"Unexpected classification output: '{content}'. " f"Expected one of {valid_labels}. " f"Model may have returned explanation instead of label." ) if task == "json_extract": import json try: json.loads(content) except json.JSONDecodeError: raise ValueError( f"Expected JSON output but got: '{content[:100]}...'. " f"Try adding 'respond with valid JSON only' to the prompt, " f"or use response_format={{\"type\": \"json_object\"}}." ) if task == "summarize" and len(content.split()) < 10: logging.warning( f"Suspiciously short summary ({len(content.split())} words). " f"Check if the input was too short or the model misunderstood the task." ) return content# Full flow with silent failure detectionresult = safe_complete( messages=[{"role": "user", "content": "Classify as positive/negative/neutral: 'Great product!'"}], model="claude-haiku-4-5")label = validate_completion(result, task="classify")
サイレントフェイルの主因はたいてい次の3つです。プロンプトの曖昧さ、モデルがフォーマット指示を無視した、またはタスクに対して入力が短すぎる/長すぎる。検証に失敗したときに出力全体をログに残すのが、どれに該当するかを最速で診断する方法です。
レート制限に指数バックオフを追加する
レート制限エラー(429)は一時的なものです。適切な対応は、待機してから徐々に待ち時間を延ばして再試行すること — レート制限のあるあらゆるAPIでの標準手法です。
import timeimport randomfrom openai import RateLimitErrordef complete_with_retry( messages: list, model: str = "gpt-5.4-mini", max_retries: int = 3, **kwargs) -> dict: """Retry on rate limits and server errors with exponential backoff.""" last_error = None for attempt in range(max_retries): try: return safe_complete(messages, model=model, **kwargs) except APIStatusError as e: if e.status_code < 500: raise # 4xx: don't retry, request is wrong last_error = e except RateLimitError as e: last_error = e except (APIConnectionError, APITimeoutError) as e: last_error = e if attempt < max_retries - 1: wait = (2 ** attempt) + random.random() # jitter prevents thundering herd logging.warning(f"Attempt {attempt + 1} failed. Waiting {wait:.1f}s before retry.") time.sleep(wait) raise RuntimeError(f"All {max_retries} attempts failed") from last_error
400 や 401 では再試行しないでください — これらはクライアントエラーであり、放置しても解決しません。
画像生成の失敗をデバッグする
画像生成には、標準的なHTTPエラーに加えて独自の失敗モードがあります。
import base64import requestsdef generate_image_safe(prompt: str, model: str = "dall-e-3") -> dict: """ Generate an image with full error handling. Returns {"url": str | None, "bytes": bytes | None, "blocked": bool} """ api_key = os.environ.get("COMETAPI_KEY") if not api_key: raise ValueError("COMETAPI_KEY is not set") BASE64_MODELS = {"gpt-image-2", "qwen-image"} headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } payload = {"model": model, "prompt": prompt, "size": "1024x1024"} if model in BASE64_MODELS: payload["output_format"] = "png" else: payload["response_format"] = "url" try: response = requests.post( "https://api.cometapi.com/v1/images/generations", json=payload, headers=headers, timeout=60 ) response.raise_for_status() except requests.exceptions.HTTPError as e: logging.error(f"Image generation HTTP error: {e.response.status_code} {e.response.text}") raise except requests.exceptions.Timeout: logging.error("Image generation timed out after 60s") raise data = response.json().get("data", []) if not data: logging.warning("Image generation returned empty data — prompt may have been filtered.") return {"url": None, "bytes": None, "blocked": True} item = data[0] if "revised_prompt" in item: logging.info(f"Provider revised prompt to: {item['revised_prompt']}") if "url" in item: return {"url": item["url"], "bytes": None, "blocked": False} return { "url": None, "bytes": base64.b64decode(item["b64_json"]), "blocked": False }
画像特有の注意点:
| Symptom | Cause | Fix |
|---|---|---|
| data 配列が空 | プロンプトがフィルタリングされた | revised_prompt を確認;言い換える |
| GPT Image 2 での response_format エラー | 未対応のパラメータ | output_format を使用 |
| Qwen Image で n > 1 エラー | モデルの制約 | ループして個別にリクエストする |
| URL が後で 403 を返す | URL の有効期限切れ | 生成後すぐにダウンロードする |
出典**:** CometAPI image generation docs
動画生成の失敗をデバッグする
動画生成は非同期であるため、失敗の仕方が異なります。ループの前にステータス変数を初期化して、タイムアウト時のエラーメッセージが常に正しく整形されるようにします。
def submit_and_poll_video( prompt: str, model: str = "veo3-fast", max_wait: int = 600) -> str: """Submit video task and poll to completion. Returns video URL.""" api_key = os.environ.get("COMETAPI_KEY") if not api_key: raise ValueError("COMETAPI_KEY is not set") headers = {"Authorization": f"Bearer {api_key}"} try: response = requests.post( "https://api.cometapi.com/v1/videos", headers=headers, files={ "prompt": (None, prompt), "model": (None, model), "size": (None, "16x9") }, timeout=30 ) response.raise_for_status() except requests.exceptions.HTTPError as e: logging.error(f"Video submit failed: {e.response.status_code} {e.response.text}") raise task_id = response.json()["id"] logging.info(f"Video task submitted: {task_id}") poll_url = f"https://api.cometapi.com/v1/videos/{task_id}" elapsed = 0 interval = 10 status = "unknown" # initialize before loop progress = 0 # initialize before loop while elapsed < max_wait: try: poll_response = requests.get(poll_url, headers=headers, timeout=30) poll_response.raise_for_status() except requests.exceptions.HTTPError as e: logging.error(f"Poll request failed: {e.response.status_code}") raise result = poll_response.json() status = result.get("status", "unknown") progress = result.get("progress", 0) logging.info(f"Task {task_id}: status={status} progress={progress}%") if status == "succeeded": return result["output"][0] elif status in ("failed", "cancelled"): error_detail = result.get("error", "no error detail returned") raise RuntimeError(f"Video task {task_id} failed: {error_detail}") time.sleep(interval) elapsed += interval raise TimeoutError( f"Video task {task_id} did not complete within {max_wait}s. " f"Last status: {status}, progress: {progress}%" )
動画特有の問題:
| Symptom | Cause | Fix |
|---|---|---|
| queued のまま10分以上進まない | サーバー負荷 | 別モデルで再試行 |
| エラー詳細なしで failed | プロンプトがフィルタリング、またはモデルエラー | プロンプトを言い換える |
| 動画URLが403を返す | URLの有効期限切れ | すぐにダウンロードする |
| Runway の最初のポーリングで task_not_exist | タスク初期化中(CometAPIのドキュメント化された挙動) | 5秒待って再試行 |
| Kling が "succeed" を返す | Kling のAPIは非標準のステータス文字列を使用 | ポーリングで両方を扱う |
出典**:** CometAPI video generation docs, Kling Video docs
Node.js 版
import OpenAI from 'openai';const apiKey = process.env.COMETAPI_KEY;if (!apiKey) throw new Error('COMETAPI_KEY is not set');const client = new OpenAI({ baseURL: 'https://api.cometapi.com/v1', apiKey,});async function safeComplete(messages, model = 'gpt-5.4-mini', options = {}) { let response; try { response = await client.chat.completions.create({ model, messages, ...options }); } catch (err) { if (err.status && err.status < 500) { console.error(`Client error ${err.status}: ${err.message}`); } else { console.error(`Server/network error: ${err.message}`); } throw err; } const choice = response.choices[0]; const finishReason = choice.finish_reason; if (finishReason === 'content_filter') { throw new Error(`Generation blocked by content filter. Model: ${model}`); } if (finishReason === 'length') { console.warn(`Output truncated. Used ${response.usage?.completion_tokens ?? 'unknown'} tokens.`); } return { content: choice.message.content ?? '', finishReason, toolCalls: choice.message.tool_calls ?? null, };}async function completeWithRetry(messages, model = 'gpt-5.4-mini', maxRetries = 3) { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await safeComplete(messages, model); } catch (err) { // Don't retry 4xx client errors if (err.status && err.status < 500) throw err; lastError = err; if (attempt < maxRetries - 1) { const wait = (2 ** attempt + Math.random()) * 1000; console.warn(`Attempt ${attempt + 1} failed. Retrying in ${(wait / 1000).toFixed(1)}s`); await new Promise(r => setTimeout(r, wait)); } } } throw new Error(`All ${maxRetries} attempts failed: ${lastError?.message}`);}// Usageconst result = await safeComplete( [{ role: 'user', content: 'Classify as positive/negative/neutral: "Great product!"' }], 'claude-haiku-4-5');if (result.finishReason === 'tool_calls') { console.log('Tool call requested:', result.toolCalls);} else { console.log(result.content);}
デバッグのチェックリスト
生成が失敗し、どこから手を付ければよいかわからないとき:
テキスト生成:
- APIキーが設定され、
Authorization: Bearer <key>形式になっているか? finish_reasonはstop以外になっていないか?contentが null か?finish_reasonがtool_callsかどうかを確認- 出力が切り詰められていないか?
finish_reason: "length"とusage.completion_tokensを確認 - エラーは4xx(リクエスト修正)か5xx(再試行)か?
- 出力はアプリケーション層の検証(サイレントフェイル検出)を通過しているか?
画像生成:
data配列が空か?(コンテンツフィルター)- GPT Image 2 で
response_formatを使っていないか?(未対応 —output_formatを使用) - Qwen Image で
n > 1を指定していないか?(未対応) - URL の有効期限切れ前に画像をダウンロードしたか?
動画生成:
- タスクが
queuedに留まっていないか?(別モデルを試す) - 失敗したタスクのレスポンスで
errorフィールドを確認したか? - URL の有効期限切れ前に動画をダウンロードしたか?
"succeed"(Kling)と"succeeded"(Veo, Runway)の両方を扱っているか?
FAQ
Q: 200が返ってくるのにコンテンツがありません。何が起きていますか?
finish_reason を確認してください。content_filter なら生成がブロックされています — リクエスト自体は成功していますが出力が抑止されました。tool_calls なら、テキストの代わりにツールを呼び出しており、設計上 content は null です。finish_reason が stop なのに内容が空ならサイレントフェイルです — レスポンス全体をログに残し、プロンプトを確認してください。
Q: プロンプトがフィルタリングされているかどうかを知るには?
テキスト: finish_reason === "content_filter" を確認。画像: data 配列が空かを確認。動画: 送信直後に failed になりエラー詳細がない場合があります。いずれも、より中立的な表現に言い換えてみてください。
Q: いつ失敗したリクエストを再試行すべきですか?
429 と 5xx では指数バックオフで再試行します。4xx では再試行しないでください — 不正なリクエストは自然には直りません。例外はAPIキーをローテーションする場合の 401 です。
Q: 指数バックオフとは何で、なぜ重要ですか?
すぐに再試行するのではなく、1秒、2秒、4秒…のように待機時間を徐々に延ばします。ランダムジッター(+ random.random())を加えると、多数のクライアントが同時に再試行するのを防げます。これはCometAPI 特有ではなく、レート制限のあるAPI全般の標準手法です。
Q: 動画タスクが queued のまま10分間止まっています。失敗ですか?
必ずしもそうではありません — 負荷によってキューが滞留することがあります。max_wait のしきい値までは待機し、その後は TimeoutError を投げて別モデルで再試行してください。手動で確認できるよう、タスクIDをログに残しておきましょう。
Q: サイレントフェイルはどうやって検出しますか?
サイレントフェイルはアプリケーション層の検証が必要です — APIは意味的な誤りを教えてくれません。期待する形式に合致しているか(有効なJSON、期待ラベル、最小長など)を確認します。検証失敗時は出力全体をログに残してください。最も一般的な原因は、曖昧なプロンプト、無視されたフォーマット指示、またはタスクに対して入力が短すぎる/長すぎることです。
