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.
The one rule
Section titled “The one rule”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.
Decisive errors — fix the request
Section titled “Decisive errors — fix the request”| 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. |
Retryable errors — try again
Section titled “Retryable errors — try again”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. |
The one ambiguous outcome
Section titled “The one ambiguous outcome”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 NOTHINGWith 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.
404 versus 403
Section titled “404 versus 403”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.
A retry policy that works
Section titled “A retry policy that works”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.