Errors

Errors & retries

Every non-2xx response has the same shape, so you can branch on a stable string instead of parsing prose.

error body
{
"error": {
"code": "scope_required",
"message": "Key lacks the required scope: payouts:read"
}
}

fetch does not throw on error statuses

A 500 is a successful fetch with 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

StatusCodeMeaningWhat to doSDK class
400validation_failedA field failed schema validation. The message names the field.Fix the request. Retrying identically will fail identically.BadRequestError
400invalid_requestThe body was unparseable or structurally wrong.Check you sent JSON and set content-type.BadRequestError
400invalid_storage_pathAn upload path outside your own user/{id}/ prefix.Use the path returned by the upload-url flow verbatim.BadRequestError
400file_not_foundThe referenced object isn't in storage yet.Complete the upload before creating the listing.BadRequestError
400price_too_lowA paid listing was priced under $1.00.Use 0 for free, or at least 100 cents.BadRequestError
401missing_credentialsNo Authorization header, or not a bearer token.Send `Authorization: Bearer <key>`.AuthenticationError
401invalid_keyMalformed, revoked, or expired key.Create a new key. Don't retry.AuthenticationError
402payment_requiredA premium key that hasn't completed activation checkout.Activate it in the dashboard; the same key then works.PaymentRequiredError
403scope_requiredValid key, but it lacks the scope this endpoint needs.Create a key with the scope named in the message.PermissionError
404not_foundNo such resource, or it isn't yours to see.Check the id or slug. Ownership failures also read as 404.NotFoundError
404model_not_foundUnknown inference model id.List the catalog with GET /inference/models.NotFoundError
429rate_limitedToo many requests.Honour Retry-After, then back off exponentially.RateLimitError
503service_unavailableInference 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.

backoff.ts
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.

overrides.ts
const nd = new NodeData({ apiKey, maxRetries: 4, timeout: 30_000 });
// Per-call override — this one should fail fast rather than retry
await nd.models.list({ limit: 5 }, { maxRetries: 0, timeout: 3_000 });

Typed handling

handle.ts
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

Every 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.