> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://imo.ferndocs.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://imo.ferndocs.com/_mcp/server.

# Errors and rate limits

## The error envelope

Every normalization endpoint returns the same shape on failure:

```json
{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "The request body failed validation.",
    "details": {
      "code": "MISSING_FIELD",
      "message": "'client_request_id' is required.",
      "traceId": "b7c2f1e0-4a3d-4c9e-8f21-0d6e5a4b3c2f"
    }
  }
}
```

Always capture `details.traceId` — it's what IMO Health support needs to find
your request.

> **Note**
>
> `POST /oauth/token` uses the OAuth-standard shape instead
> (`error`, `error_description`, `error_uri`). See
> [Authentication](/documentation/getting-started/authentication#token-errors).

## Status codes

| Status | Meaning                                                     | What to do                                     |
| ------ | ----------------------------------------------------------- | ---------------------------------------------- |
| `400`  | Invalid syntax, missing required field, or malformed JSON   | Fix the request. Don't retry unchanged.        |
| `401`  | Unauthenticated — missing, malformed, or expired token      | Refresh the token and retry once.              |
| `403`  | Authenticated but not authorized for this resource or scope | Check credential provisioning. Don't retry.    |
| `413`  | Payload too large                                           | Reduce batch size below 300 records and retry. |
| `429`  | Rate limit exceeded                                         | Back off. Honor `Retry-After`.                 |
| `500`  | Server-side failure                                         | Retry with exponential backoff.                |
| `503`  | Service unavailable or overloaded                           | Retry with exponential backoff.                |

## Rate limits

`429` responses carry a **`Retry-After`** header giving the seconds to wait.
Honor it rather than guessing — it reflects live capacity.

> **Warning**
>
> Retrying `400`, `403`, or `413` unchanged will fail identically and still counts
> against your limit. Only `401` (once, after refresh), `429`, `500`, and `503` are
> retryable.

## Handling errors with the SDKs

The generated SDKs raise typed errors per status code, so you can branch on the
class rather than parsing status integers.

**`TypeScript`**

```typescript title="TypeScript"
import {
  ImoHealthClient,
  ImoHealthError,
} from "@imo-demo/precision-normalize";

try {
  const result = await client.normalize.normalize({
    clientRequestId: "batch-001",
    requests: records,
  });
} catch (err) {
  if (err instanceof ImoHealthError) {
    console.error(err.statusCode, err.message);
    // err.body holds the parsed { error: { code, message, details } } envelope
    if (err.statusCode === 429) {
      await sleep(Number(err.rawResponse?.headers?.get("retry-after") ?? 5) * 1000);
    }
  }
  throw err;
}
```

**`Python`**

```python title="Python"
from imo_precision_normalize.core.api_error import ApiError

try:
    result = client.normalize.normalize(
        client_request_id="batch-001",
        requests=records,
    )
except ApiError as err:
    print(err.status_code, err.body)
    if err.status_code == 429:
        time.sleep(int(err.headers.get("retry-after", 5)))
        raise
```

**`Java`**

```java title="Java"
try {
    NormalizeResponse result = client.normalize().normalize(request);
} catch (ImoHealthApiException err) {
    System.err.println(err.statusCode() + " " + err.body());
    if (err.statusCode() == 429) {
        // Honor Retry-After before retrying.
    }
    throw err;
}
```

> **Note**
>
> Exact error-class names come from the generated SDKs. Check the README in
> [the SDK repos](/sdks/overview) for the precise symbols in your language and
> version.

## A retry policy that works

```typescript
const RETRYABLE = new Set([429, 500, 503]);

async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const status = err?.statusCode;
      const last = attempt === attempts - 1;
      if (!RETRYABLE.has(status) || last) throw err;

      // Prefer the server's guidance; fall back to exponential backoff + jitter.
      const retryAfter = Number(err?.rawResponse?.headers?.get("retry-after"));
      const delay = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 2 ** attempt * 500 + Math.random() * 250;

      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw new Error("unreachable");
}
```

## Partial failures

A `200` does **not** mean every record succeeded. Batch calls report per-record
outcomes:

* `summary.count` — records processed
* `summary.error_count` — records that failed
* `requests[].response.error_message[]` — the per-record reasons

```typescript
const result = await client.normalize.normalize({ /* ... */ });

if ((result.summary?.errorCount ?? 0) > 0) {
  for (const record of result.requests ?? []) {
    const errors = record.response?.errorMessage ?? [];
    if (errors.length) {
      console.warn(`record ${record.recordId} failed:`, errors);
    }
  }
}
```

> **Warning**
>
> Always check `summary.error_count` before treating a batch as fully normalized.
> A record that fails to match returns an empty `items` array rather than an
> exception.