TROUBLESHOOTING · ERROR FAQ
API Returns HTTP 200 but an Error in the Body: Diagnosis & Best Practice
The most expensive API failure is the one your client doesn't notice: the request returns HTTP 200, your code takes the success path, and the error sits unhandled in the response body. Async task APIs and aggregation layers are the usual sources. This page covers why the pattern exists at all, the one diagnostic rule that neutralizes it, defensive client code in Python and JS you can paste today, the silent-failure trap specific to polling loops, and the error contract ModAPIS holds itself to — five minutes to verify against your own code.
Why some APIs do this
Three recurring reasons. Legacy gateways that map every completed backend exchange to 200 and push the real outcome into the payload. Async task APIs where the submit is genuinely successful (hence 200) and the business failure happens later — but which then also smuggle immediate rejections into 200 bodies. And proxies/aggregators that upstream-return an error envelope their own transport layer doesn't map. You don't have to like it; you do have to code for it.
Diagnosis: read the body code before the status
The rule: the body's error code is the primary outcome; the HTTP status is transport metadata. Concretely — parse the JSON body first, check for an error field, and only then look at the status. A 200 with a code is a failure; a 5xx with a retryable code is a backoff, not a crash. Clients that invert this order are the ones that bill for generations that never ran.
Defensive client code
The same shape in JavaScript — one wrapper, applied at the HTTP client boundary so the rest of the codebase never sees a raw response:
def call_api(session, url, **kw):
r = session.post(url, **kw)
body = r.json()
err = body.get("error")
if err: # body first, status second
if err.get("retryable"):
return retry(url, **kw)
raise ApiError(err["code"], err.get("action"))
if r.status_code >= 400: # non-JSON or transport-level failure
r.raise_for_status()
return body async function callApi(url, opts) {
const res = await fetch(url, opts);
const body = await res.json();
if (body.error) {
if (body.error.retryable) return callApi(url, opts); // with backoff in practice
throw new ApiError(body.error.code, body.error.action);
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return body;
} The polling-loop variant: silent failure at scale
The trap compounds in polling loops: a client polls a task endpoint, receives 200-plus-error, treats it as "not done yet", and loops forever — a hung job with a clean log. Two defenses: give every poll response an explicit terminal-state check (completed / failed — failed exits the loop with the code), and cap total wall-clock time per task as a backstop. The polling design guide covers the full loop; the Veo error guide shows the triage table that the failed branch should route into.
The contract worth demanding
ModAPIS's contract: errors never hide behind a bare 200 without a structured body — every failure carries {code, retryable, action}, five-value code enum, consistent across all models and endpoints, and failed tasks are not charged. Verify it against your own client with the quickstart; try the Kling 3.0 page for a full worked submit/poll example.