Error handling
Error classes, retry semantics, and idempotency.
Every non-2xx response is thrown as a single class:
import { EasyApiError } from "@easylabs/node";
class EasyApiError extends Error {
readonly status: number;
readonly code: string | null;
readonly details: unknown;
readonly retryAfterSeconds: number | null;
readonly raw: unknown;
}status— HTTP status from the response.code— short machine-readable code from the API error body (e.g.RATE_LIMITED,VALIDATION_ERROR), ornullwhen the body wasn't a structured error.details— arbitrary structured payload from the API (e.g. field-level validation errors).retryAfterSeconds— parsed from theRetry-Afterresponse header, ornull.raw— the original parsed JSON body for forensic logging.
message always begins with API request failed: … for backwards compatibility with code that previously matched against a plain Error.
Retryable vs. non-retryable
| Status | Class | Retry? | Notes |
|---|---|---|---|
| 400 / 422 | EasyApiError | No | Validation. Surface err.details to the user. |
| 401 / 403 | EasyApiError | No | Bad / revoked key. Stop retrying and rotate. |
| 404 | EasyApiError | No | Not found. |
| 409 | EasyApiError | No | Conflict — usually a duplicate idempotency key with a different body. |
| 429 | EasyApiError | Yes | Honour err.retryAfterSeconds before retrying. |
| 5xx | EasyApiError | Yes | Transient — back off and retry. |
| Network | TypeError (from fetch) | Yes | Timeouts, DNS, TLS — wrap with your own retry. |
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
const retriable =
(err instanceof EasyApiError && (err.status === 429 || err.status >= 500)) ||
err instanceof TypeError;
if (!retriable || attempt >= max) throw err;
const delay =
err instanceof EasyApiError && err.retryAfterSeconds
? err.retryAfterSeconds * 1000
: 250 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, delay));
}
}
}Idempotency keys
A few create endpoints accept an idempotency_key (or similar) inside the request body — most notably reportSubscriptionUsage and payInvoice. Pass a stable UUID per logical operation so retries safely deduplicate on the server:
import { randomUUID } from "node:crypto";
await easy.payInvoice("inv_123", {
instrument_id: "PI_...",
idempotency_key: randomUUID(),
});The 0.1 SDK does not yet send a top-level idempotency header on every POST; if you need at-least-once safety on endpoints that don't accept a body-level key, deduplicate at the application layer.
Network errors
The SDK is built on the platform fetch. Connection failures (timeouts, DNS, TLS) propagate as Node's TypeError("fetch failed") with a populated cause. They are not wrapped in EasyApiError because no HTTP exchange completed — branch on err instanceof EasyApiError first, and treat anything else as a transport-level failure to retry or surface separately.