AI API 실패는 일반 API 실패와 다릅니다. 200 응답이 생성 성공을 의미하는 것은 아닙니다. null content 필드가 항상 오류인 것도 아닙니다. 어제 잘 동작하던 동일한 프롬프트가 제공자의 콘텐츠 정책 업데이트 때문에 오늘은 실패할 수도 있습니다.
이 가이드는 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는 여기에 세 번째 범주가 추가됩니다: 200을 반환하지만 사용 가능한 콘텐츠가 없는 응답, 즉 “소프트 실패”입니다.
문제가 생길 수 있는 경우는 세 가지입니다:
- 하드 실패 — 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 <key> 형식 확인 |
| 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"인 200 응답은 생성이 차단되었음을 의미합니다. content 필드는 null이거나 비어 있을 것입니다. 이걸 확인하지 않으면 앱은 조용히 아무것도 반환하지 않습니다.
| finish_reason | Meaning | What to do | Fix |
|---|---|---|---|
| stop | 정상 완료 | 아무것도 할 필요 없음 — 성공입니다 | 응답의 error.param 확인 |
| length | 토큰 한도 도달 | max_tokens를 늘리거나 프롬프트를 줄이세요 | Authorization: Bearer <key> 형식 확인 |
| content_filter | 안전 정책에 의해 차단 | 프롬프트를 바꾸세요; 특정 이름/주제를 피하세요 | 지수적 백오프(4단계 참조) |
| tool_calls | 텍스트 대신 도구를 호출함 | 도구 호출을 처리하세요; content는 null입니다 | 백오프로 재시도; 요청 형식 확인 |
| 504 | 게이트웨이 타임아웃 | 제공자 응답 지연 | 재시도; 더 빠른 모델 고려 |
출처**: 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")
사일런트 실패는 보통 세 가지 원인에서 발생합니다: 프롬프트가 모호하거나, 모델이 형식 지시를 무시했거나, 작업에 비해 입력이 너무 짧거나 길었습니다. 검증이 실패할 때 전체 출력을 로깅하면 어느 쪽 문제인지 가장 빠르게 파악할 수 있습니다.
레이트 리밋에 대한 지수적 백오프를 추가하세요
레이트 리밋 오류(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 |
|---|---|---|
작업이 10분 이상 queued에 머무름 | 서버 부하 | 다른 모델로 재시도 |
| 오류 상세 없이 failed | 프롬프트 필터링 또는 모델 오류 | 프롬프트 재작성 |
| 비디오 URL이 403 반환 | URL 만료 | 즉시 다운로드 |
| Runway 첫 폴링에서 task_not_exist | 작업 초기화 중(CometAPI 문서화된 동작) | 5초 대기 후 재시도 |
| Kling은 "succeeded"가 아닌 "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, 예상 라벨, 최소 길이 등). 검증 실패 시 전체 출력을 로깅하세요. 가장 흔한 원인은 모호한 프롬프트, 무시된 형식 지시, 작업에 비해 너무 짧거나 긴 입력입니다.
