Skip to content

Errors and retries

Every error is RFC 9457 problem details with content-type: application/problem+json:

{
"type": "https://errors.sqlited.dev/sql-error",
"title": "Statement failed",
"status": 400,
"detail": "no such table: orders",
"requestId": "b80fd917-94b9-4497-950d-d726cde9717d"
}

A retryable failure carries one extra field:

{
"type": "https://errors.sqlited.dev/storage-unavailable",
"title": "Storage temporarily unavailable",
"status": 503,
"detail": "object storage did not accept the request; retry",
"requestId": "0d1c8e2b-6f31-4a55-9c0e-2a7f4b1d9e88",
"retryable": true
}
Field
type A stable identifier. Branch on this, never on title or detail.
status Matches the HTTP status.
detail Human-readable. Safe to log, safe to show a developer. Not machine-readable.
requestId Also the X-Request-Id header, on every response including successes. Quote it in support requests.
retryable Present and true only when retrying may work. Absent means it will not.

New type values may be added. Treat an unrecognized one as its status class suggests, and use retryable rather than a list of your own.

4xx will fail the same way if you repeat it. 503 may succeed if you repeat it. That is the whole retry policy, and the API is careful to put each failure on the correct side of it — a retryable failure reported as 400 makes you give up on a database that is about to be fine, and a decisive failure reported as 503 makes you hammer.

The exception worth knowing: 500 recovery-incomplete is not retryable. It is a 500 rather than a 503 precisely so that it surfaces instead of disappearing into a retry loop.

Status type What happened
400 bad-request The body is not what the endpoint expects, or is not parseable JSON.
400 sql-error SQLite refused your statement: syntax error, unknown table or column, constraint violation. detail is SQLite’s own message.
400 sql-denied The statement is valid SQLite but this platform does not allow it. See SQL support.
401 unauthenticated Missing, malformed, unknown, or revoked API key. Always "invalid API key".
403 forbidden The key is valid but lacks the required scope. detail names the scope needed.
403 suspended The organization or database is suspended. Talk to us; retrying will not help.
404 not-found No such database, no such route — or a database you are not entitled to. See below.
413 too-large The request body exceeds 8 MiB.
415 unsupported-media-type Send content-type: application/json.

All are 503 with "retryable": true. Back off exponentially with jitter; a fixed-interval retry from many clients rebuilds the load that caused the problem.

type What happened
durability-uncertain The write may or may not have landed. The only ambiguous outcome in the API — see below.
storage-unavailable Object storage refused the request. Nothing was acknowledged, so this is a clean retry.
unavailable The database could not be opened this time — ownership or storage disagreed. Both resolve on their own.
fenced The worker you reached no longer owns the database. Normal during a deploy; the next request opens a fresh handle.
closed The database was closed while your request was in flight. Same as fenced in practice.
capacity The worker is at its open-database limit. A scheduling signal, not a fault.
conflict (409) A control-plane record changed while your request was being applied. Retry the create or delete.

durability-uncertain is the only error where the state of your data is genuinely unknown. It means the segment reached object storage but ownership could not be proven at the moment of acknowledgement. The write may be there; it may not.

Retry it if the request is idempotent, or read back to check. There is no third option, and no amount of client-side cleverness produces one.

The practical defence is to make writes idempotent by design, which costs one column:

INSERT INTO events (id, kind) VALUES (?, ?) ON CONFLICT (id) DO NOTHING

With a client-generated id, a retry after durability-uncertain is safe whether or not the first attempt landed. Do this from the beginning; retrofitting it means auditing every write you have.

A database belonging to another organization returns 404, never 403 — identical to a database that never existed:

{
"type": "https://errors.sqlited.dev/not-found",
"status": 404,
"detail": "no such database: db_…"
}

A 403 would confirm the database exists, which turns the API into an oracle for enumerating other customers’ database ids. Nothing about your request should distinguish the two cases, and nothing does. A malformed id is also a 404 rather than a 400, for the same reason.

This means a 404 on your own database has three possible causes: the id is wrong, the database was deleted, or your key is scoped to a different database. GET /v1/databases with an org:admin key distinguishes them.

async function withRetries<T>(attempt: () => Promise<T>, tries = 4): Promise<T> {
for (let i = 0; ; i++) {
try {
return await attempt();
} catch (error) {
// Retry only what the server says is retryable, and only if there are tries left.
const problem = error as { retryable?: boolean };
if (!problem.retryable || i >= tries - 1) throw error;
// Exponential, with jitter: a fixed interval from many clients recreates the pile-up.
const backoffMs = Math.min(2000, 100 * 2 ** i) * (0.5 + Math.random());
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
}

The one thing to be careful about: this retries durability-uncertain too, which is correct only if the statement is idempotent. If it is not, catch that one type separately and read back instead. A full client with this wired in is in Client examples.