Skip to navigation

Errors and rate limits

The error envelope, what each status means, and how to retry correctly.

The error envelope

Every normalization endpoint returns the same shape on failure:

{
"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.

POST /oauth/token uses the OAuth-standard shape instead (error, error_description, error_uri). See Authentication.

Status codes

StatusMeaningWhat to do
400Invalid syntax, missing required field, or malformed JSONFix the request. Don’t retry unchanged.
401Unauthenticated — missing, malformed, or expired tokenRefresh the token and retry once.
403Authenticated but not authorized for this resource or scopeCheck credential provisioning. Don’t retry.
413Payload too largeReduce batch size below 300 records and retry.
429Rate limit exceededBack off. Honor Retry-After.
500Server-side failureRetry with exponential backoff.
503Service unavailable or overloadedRetry 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.

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.

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;
}

Exact error-class names come from the generated SDKs. Check the README in the SDK repos for the precise symbols in your language and version.

A retry policy that works

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
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);
}
}
}

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.