预览提示 · 本站为上线前预览:模型清单与费率以正式上线公告为准

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:

python
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
javascript
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.

Quick answers

Why would an API return 200 and still fail my request?
Usually one of three designs: a legacy gateway that maps completed exchanges to 200, an async task API that reports later failures in-band, or an aggregator proxying an upstream error envelope it doesn't remap. The body is where the real outcome lives.
What's the minimum defense in client code?
One wrapper at the HTTP boundary: parse the JSON body, branch on its error field first, fall back to HTTP status only if the body carries no signal. Every downstream caller then handles outcomes, not transport.
How do I catch 200-but-error in a polling loop before it hangs?
Check for terminal state on every poll response — a failed status with a code must exit the loop immediately — and enforce a wall-clock cap per task as the backstop.
已复制