Awaria AI API różni się od zwykłej awarii API. Odpowiedź 200 nie oznacza, że generowanie się powiodło. Pole content równe null nie zawsze jest błędem. A ten sam prompt, który działał wczoraj, dziś może się wyłożyć, bo dostawca zaktualizował politykę treści.
Ten przewodnik omawia, jak czytać błędy AI API, co oznacza każda forma niepowodzenia i jak zbudować obsługę błędów, która powie ci, co się zepsuło — zamiast tylko tego, że coś się zepsuło.
Uwaga: Nazwy modeli takie jak gpt-5.4 i gpt-5.4-mini użyte w tym artykule to identyfikatory platformy CometAPI. Działają tylko przez https://api.cometapi.com/v1 — nie bezpośrednio przez API OpenAI ani Anthropic. Zobacz pełną listę modeli.
Dlaczego debugowanie AI API jest trudniejsze niż debugowanie zwykłego API
W typowym REST API, 200 oznacza sukces, a 4xx oznacza, że to po twojej stronie. AI API dodają trzecią kategorię: miękkie błędy — odpowiedzi, które zwracają 200, ale nie zawierają użytecznej treści.
Mogą pójść źle trzy rzeczy:
- twarde niepowodzenie — błąd HTTP (4xx, 5xx). Żądanie nie zostało zrealizowane.
- miękkie niepowodzenie — HTTP 200, ale
finish_reasontocontent_filterlublength, albocontentjestnull. - ciche niepowodnienie — HTTP 200, treść wygląda dobrze, ale wynik jest błędny w sposób wykrywalny dopiero na poziomie aplikacji.
Większość obsługi błędów obejmuje tylko przypadek 1. Przypadki 2 i 3 to miejsca, gdzie pojawia się większość problemów produkcyjnych.
Zrozum format odpowiedzi błędu
Endpoint uzupełniania tekstu zwraca spójny format błędu:
{ "error": { "message": "human-readable description (often includes request id)", "type": "comet_api_error", "param": "the_problematic_parameter_or_null", "code": "error_code_or_null" }}
Endpointy obrazu i wideo zwracają różne formaty błędów — zawsze parsuj surowe body odpowiedzi zamiast zakładać stały format między endpointami.
Pole message zazwyczaj dokładnie wskazuje, co jest nie tak. Pole param mówi, który parametr to spowodował. Zawsze loguj oba.
Poznaj znaczenie każdego kodu statusu HTTP
| Status | Znaczenie | Częsta przyczyna | Naprawa |
|---|---|---|---|
| 400 | Bad request | Brak modelu, błędny parametr dla tego modelu | Sprawdź error.param w odpowiedzi |
| 401 | Unauthorized | Błędny lub brakujący klucz API | Zweryfikuj format Authorization: Bearer <key> |
| 429 | Rate limited | Zbyt wiele żądań | Exponential backoff (zob. Krok 4) |
| 500 | Server error | Problem po stronie dostawcy lub błędne body | Ponów z backoffem; sprawdź format żądania |
| 504 | Gateway timeout | Dostawca odpowiadał zbyt długo | Ponów; rozważ szybszy model |
*Źródło**: CometAPI chat completions docs
Różnica między 400 a 500 ma znaczenie dla strategii ponawiania. 400 oznacza, że żądanie jest błędne — ponowienie tego samego nie pomoże. 500 lub 504 oznacza problem serwera — ponawianie ma sens.
Sprawdź finish_reason — najczęściej pomijane pole
Odpowiedź 200 z finish_reason: "content_filter" oznacza, że generowanie zostało zablokowane. Pole content będzie null lub puste. Jeśli tego nie sprawdzisz, twoja aplikacja po cichu zwróci pustkę.
| finish_reason | Znaczenie | Co zrobić | Naprawa |
|---|---|---|---|
| stop | Normalne zakończenie | Nic — to sukces | Sprawdź error.param w odpowiedzi |
| length | Osiągnięto limit tokenów | Zwiększ max_tokens lub skróć prompt | Zweryfikuj format Authorization: Bearer <key> |
| content_filter | Zablokowane przez politykę bezpieczeństwa | Przeformułuj prompt; unikaj konkretnych nazw/tematów | Exponential backoff (zob. Krok 4) |
| tool_calls | Model wywołał narzędzie zamiast zwrócić tekst | Obsłuż wywołanie narzędzia; content będzie null | Ponów z backoffem; sprawdź format żądania |
| 504 | Gateway timeout | Dostawca odpowiadał zbyt długo | Ponów; rozważ szybszy model |
*Źródło**: 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"])
Wykrywaj ciche niepowodzenia na poziomie aplikacji
Ciche niepowodzenia są najtrudniejsze do złapania. API zwraca 200, finish_reason to stop, ale wyjście jest semantycznie błędne. Możesz je wykryć tylko na poziomie aplikacji.
Typowe wzorce:
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")
Ciche niepowodzenia zazwyczaj wynikają z jednego z trzech źródeł: prompt jest niejednoznaczny, model zignorował instrukcje formatowania, albo wejście było zbyt krótkie/długie do zadania. Logowanie pełnego wyjścia, gdy walidacja nie przejdzie, najszybciej pokaże, która to przyczyna.
Dodaj exponential backoff dla limitów zapytań
Błędy limitu zapytań (429) są tymczasowe. Właściwą reakcją jest odczekanie i ponowienie z rosnącymi opóźnieniami — standardowa praktyka dla każdego API z limitami:
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
Nie ponawiaj przy 400 ani 401 — to błędy po stronie klienta, które same się nie rozwiążą.
Debugowanie błędów generowania obrazów
Generowanie obrazów ma własne tryby niepowodzeń, oprócz standardowych błędów 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 }
Problemy specyficzne dla obrazów, na które warto uważać:
| Objaw | Przyczyna | Naprawa |
|---|---|---|
| Pusta tablica data | Przefiltrowany prompt | Sprawdź revised_prompt; przeformułuj |
| błąd response_format na GPT Image 2 | Parametr nieobsługiwany | Użyj output_format zamiast |
| n > 1 błąd na Qwen Image | Ograniczenie modelu | Zastąp pętlą wielu żądań |
| URL zwraca 403 później | URL wygasł | Pobierz od razu po wygenerowaniu |
*Źródło**: CometAPI image generation docs
Debugowanie błędów generowania wideo
Generowanie wideo psuje się inaczej, bo jest asynchroniczne. Zainicjalizuj zmienne statusu przed pętlą, aby komunikat o timeout zawsze był poprawnie sformatowany:
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}%" )
Problemy specyficzne dla wideo:
| Objaw | Przyczyna | Naprawa |
|---|---|---|
| Zadanie utknęło w queued 10+ min | Obciążenie serwera | Ponów na innym modelu |
| failed bez szczegółu błędu | Prompt przefiltrowany lub błąd modelu | Przeformułuj prompt |
| URL wideo zwraca 403 | URL wygasł | Pobierz natychmiast |
| task_not_exist przy pierwszym pollu Runway | Zadanie wciąż się inicjalizuje (zachowanie CometAPI) | Poczekaj 5s i ponów |
| Kling zwraca "succeed" zamiast "succeeded" | API Kling używa niestandardowego statusu | Obsłuż oba w logice pollingu |
Źródło**: CometAPI video generation docs**,* Kling Video docs
Wersja 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);}
Lista kontrolna debugowania
Gdy generowanie się nie powiedzie i nie wiesz, od czego zacząć:
Dla generowania tekstu:
- Czy klucz API jest ustawiony i w formacie
Authorization: Bearer <key>? - Czy finish_reason ma wartość inną niż
stop? - Czy
contentjest null? Sprawdź, czy finish_reason totool_calls - Czy wyjście zostało ucięte? Sprawdź
finish_reason: "length"iusage.completion_tokens - Czy błąd to 4xx (napraw żądanie) czy 5xx (ponów)?
- Czy wyjście przechodzi walidację na poziomie aplikacji? (ciche niepowodnienie)
Dla generowania obrazów:
- Czy tablica
datajest pusta? (filtr treści) - Czy użyłeś
response_formatna GPT Image 2? (nieobsługiwane — użyjoutput_format) - Czy ustawiłeś
n > 1na Qwen Image? (nieobsługiwane) - Czy pobrałeś obraz, zanim URL wygasł?
Dla generowania wideo:
- Czy zadanie utknęło w
queued? (spróbuj innego modelu) - Czy sprawdziłeś pole
errorw odpowiedzi nieudanego zadania? - Czy pobrałeś wideo, zanim URL wygasł?
- Czy obsługujesz zarówno
"succeed"(Kling), jak i"succeeded"(Veo, Runway)?
FAQ
P: Moje żądanie zwraca 200, ale nie ma treści. Co się stało?
Sprawdź finish_reason. Jeśli to content_filter, generowanie zostało zablokowane — żądanie się powiodło, ale wyjście zostało wyciszone. Jeśli to tool_calls, model wywołał narzędzie zamiast zwrócić tekst i content z definicji jest null. Jeśli finish_reason to stop, ale treść wciąż jest pusta, to ciche niepowodnienie — zaloguj całą odpowiedź i sprawdź prompt.
P: Skąd wiem, że mój prompt jest filtrowany?
Dla tekstu: sprawdź finish_reason === "content_filter". Dla obrazów: sprawdź, czy tablica data jest pusta. Dla wideo: sprawdź, czy zadanie przechodzi w status failed krótko po wysłaniu bez szczegółów błędu. W każdym przypadku spróbuj przeformułować prompt na bardziej neutralny.
P: Kiedy powinienem ponawiać nieudane żądanie?
Ponawiaj przy 429 i 5xx używając exponential backoff. Nie ponawiaj przy 4xx — błędne żądanie samo się nie naprawi. Wyjątkiem jest 401, jeśli rotujesz klucze API.
P: Czym jest exponential backoff i dlaczego ma znaczenie?
Zamiast ponawiać natychmiast, czekasz coraz dłużej: 1 s, 2 s, 4 s. Dodanie losowego jittera (+ random.random()) zapobiega zsynchronizowanemu „stadnemu” ponawianiu wielu klientów. To standardowa praktyka dla każdego API z limitami — nie specyficzna dla CometAPI.
P: Zadanie wideo utknęło w queued przez 10 minut. Czy to porażka?
Niekoniecznie — kolejki mogą się zapchać przy obciążeniu. Poczekaj do progu max_wait, potem rzuć TimeoutError i spróbuj innego modelu. Zaloguj ID zadania, aby w razie potrzeby ręcznie sprawdzić status.
P: Jak łapać ciche niepowodzenia?
Ciche niepowodzenia wymagają walidacji na poziomie aplikacji — API nie powie ci, że wynik jest semantycznie błędny. Sprawdź, czy wyjście spełnia oczekiwany format (poprawny JSON, oczekiwana etykieta, minimalna długość). Loguj pełne wyjście, gdy walidacja zawiedzie. Najczęstsze przyczyny to niejednoznaczne prompty, zignorowane instrukcje formatu lub wejścia zbyt krótkie albo zbyt długie do zadania.
