Errors & retries
Every non-2xx response has the same shape, so you can branch on a stable string instead of parsing prose.
{ "error": { "code": "scope_required", "message": "Key lacks the required scope: payouts:read" }}fetch does not throw on error statuses
ok: false. Check res.ok yourself, and read res.text() before parsing — an error page from a proxy is often HTML, and res.json() would throw a confusing SyntaxError instead of showing you the real problem.Error codes
| Status | Code | Meaning | What to do | SDK class |
|---|---|---|---|---|
| 400 | validation_failed | A field failed schema validation. The message names the field. | Fix the request. Retrying identically will fail identically. | BadRequestError |
| 400 | invalid_request | The body was unparseable or structurally wrong. | Check you sent JSON and set content-type. | BadRequestError |
| 400 | invalid_storage_path | An upload path outside your own user/{id}/ prefix. | Use the path returned by the upload-url flow verbatim. | BadRequestError |
| 400 | file_not_found | The referenced object isn't in storage yet. | Complete the upload before creating the listing. | BadRequestError |
| 400 | price_too_low | A paid listing was priced under $1.00. | Use 0 for free, or at least 100 cents. | BadRequestError |
| 401 | missing_credentials | No Authorization header, or not a bearer token. | Send `Authorization: Bearer <key>`. | AuthenticationError |
| 401 | invalid_key | Malformed, revoked, or expired key. | Create a new key. Don't retry. | AuthenticationError |
| 402 | payment_required | A premium key that hasn't completed activation checkout. | Activate it in the dashboard; the same key then works. | PaymentRequiredError |
| 403 | scope_required | Valid key, but it lacks the scope this endpoint needs. | Create a key with the scope named in the message. | PermissionError |
| 404 | not_found | No such resource, or it isn't yours to see. | Check the id or slug. Ownership failures also read as 404. | NotFoundError |
| 404 | model_not_found | Unknown inference model id. | List the catalog with GET /inference/models. | NotFoundError |
| 429 | rate_limited | Too many requests. | Honour Retry-After, then back off exponentially. | RateLimitError |
| 503 | service_unavailable | Inference is temporarily unavailable. | Retry with backoff. This one is transient. | ServerError |
What to retry
- Retry: 408, 409, 429, and all 5xx, plus network-level failures (DNS, connection reset, timeout).
- Don't retry: 400, 401, 402, 403, 404. The request is wrong; sending it again wastes both machines' time.
- Never retry mid-stream. Replaying a partially consumed completion would duplicate tokens the caller already received.
Correct backoff
Server guidance always wins. Only fall back to your own schedule when Retry-After is absent, and add jitter so a fleet of clients that failed together doesn't retry in lockstep and re-create the spike.
1function backoffMs(attempt: number, retryAfter: number | null): number {2 // The one number we know is right.3 if (retryAfter !== null) return Math.min(retryAfter * 1000, 60_000);4 5 const base = Math.min(500 * 2 ** attempt, 8_000);6 // Full jitter: without it, every client retries at the same instant.7 return Math.round(base * (0.5 + Math.random() / 2));8}The SDK does this for you — two retries by default, configurable per client or per call.
const nd = new NodeData({ apiKey, maxRetries: 4, timeout: 30_000 }); // Per-call override — this one should fail fast rather than retryawait nd.models.list({ limit: 5 }, { maxRetries: 0, timeout: 3_000 });Typed handling
import { ApiError, AuthenticationError, PaymentRequiredError, PermissionError, RateLimitError, TimeoutError,} from "@node-data/sdk"; try { await nd.inference.create({ model: "node-fast-8b", messages });} catch (err) { if (err instanceof RateLimitError) { await sleep((err.retryAfter ?? 5) * 1000); } else if (err instanceof PaymentRequiredError) { notifyBilling(); } else if (err instanceof PermissionError) { console.error(`Needs scope: ${err.requiredScope}`); } else if (err instanceof AuthenticationError) { rotateKey(); } else if (err instanceof TimeoutError) { // Network-level: the request may or may not have been received queueForLater(); } else if (err instanceof ApiError) { // Anything else from the API still has status, code, and requestId report(err.code, err.status, err.requestId); } else { throw err; }}Include the request id in support requests
ApiError carries requestId from the x-request-id response header. It turns “a request failed yesterday” into one log line.Idempotency
The hard case isn't a failed request — it's one that succeeded while the response was lost. You cannot distinguish that from a failure, so a blind retry may create a duplicate listing. Until the API accepts an idempotency key, guard creates on your side: record a client-generated id before the call and check it before retrying.