Errors & retries
The SDK throws exactly two error classes — there are no per-status subclasses and no result objects.
import { OptoLinkError, OptoLinkConnectionError } from "@optomatica/optolink-sdk";
The error classes
OptoLinkError — any non-2xx response:
| Property | Type | Meaning |
|---|---|---|
status | number | HTTP status code |
message | string | The backend error message |
requestId | string | undefined | Present when the error envelope carries one |
details | { message: string; field?: string }[] | undefined | Present on 400 validation failures only — message text (no field names today) |
retryAfterSeconds | number | undefined | Parsed from 429 bodies (see below) |
raw | unknown | The full error body, verbatim |
OptoLinkConnectionError extends OptoLinkError — thrown when the request never got an HTTP response: network failure, connection reset, or timeout. status is 0.
try {
const link = await client.links.get(id);
} catch (err) {
if (err instanceof OptoLinkConnectionError) {
// network / timeout — status is 0
} else if (err instanceof OptoLinkError) {
console.error(err.status, err.message, err.details);
}
}
Non-JSON error bodies (a proxy's HTML 502, an empty body) still throw OptoLinkError; message falls back to OptoLink request failed with status <status> and raw holds the body text.
The error envelope
Backend failures return a JSON envelope; the SDK lifts its fields onto the error and keeps the whole thing on err.raw:
{
"statusCode": 404,
"error": "Not Found",
"message": "Link <id> not found",
"details": [{ "message": "…" }],
"timestamp": "2026-09-20T05:30:58.091Z",
"path": "/links/<id>",
"requestId": "…"
}
details[] appears only on 400 validation errors (a non-UUID id, for example, is a 400 — Validation failed (uuid is expected) — with no details[]). requestId appears only when the request carried an x-request-id header; the SDK does not send one by default.
Common statuses
| Status | message | When |
|---|---|---|
400 | Validation failed + details[] | Body/query validation errors |
400 | Validation failed (uuid is expected) | Non-UUID id argument |
401 | Invalid API key | Unknown, revoked, or expired key |
401 | Missing Authorization header / Invalid Authorization format / Invalid API key format | Malformed auth |
401 | Organization is suspended | Suspended organization |
403 | API key tier 'CLIENT' is not permitted on this endpoint (requires 'SERVER') | CLIENT-tier key on the links API |
404 | Link <id> not found | Missing link, or another org's link |
429 | Rate limit exceeded. Retry after N second(s). | Rate limited (see below) |
500 | varies | Backend fault — e.g. a duplicate shortCode surfaces a raw database error. Surfaced as-is, never papered over |
Rate limits (429)
The API sends no rate-limit headers — retry timing is communicated in the body only, as Rate limit exceeded. Retry after N second(s). The SDK parses N into err.retryAfterSeconds and, within the retry budget, sleeps that long before retrying automatically (a 429 whose body doesn't parse falls back to the standard backoff below).
The retry matrix
Defaults: timeout: 10_000 ms per attempt, retries: 2 (one shared budget per public call — see install).
| Failure | Writes (create, update, delete) | Reads (get, list, listAll, qrSvg, qrPng) |
|---|---|---|
| Connection error / timeout | retry | retry |
429 | retry, sleeping retryAfterSeconds | retry, same |
5xx | never — a retried POST that already landed would create a second link | retry |
Other 4xx | never | never |
Writes are never retried on 5xx because the backend has no idempotency keys. Reads are always safe to replay.
Backoff between attempts: 429 sleeps retryAfterSeconds; every other retryable failure sleeps min(0.5 · 2ⁿ, 5) seconds (n = attempt index).
One shared budget: create and update chain a write plus the hidden refetch GET — the pair draws from a single retry counter, so the refetch never gets a fresh budget. When the budget is exhausted, the last error is thrown.