Errors + ETags
VaultbaseError shapes, optimistic concurrency, retry strategies.
Every SDK method that hits the network throws on non-2xx (except where
explicitly noted). Errors are normalised into VaultbaseError with a
discriminated kind field.
VaultbaseError shape
Section titled “VaultbaseError shape”import { VaultbaseError, isVaultbaseError } from "@vaultbase/sdk";
try { await vb.collection("posts").create({ title: "" });} catch (e) { if (isVaultbaseError(e)) { e.kind; // "validation" | "auth" | "server" | … e.message; // the server's `error` text e.data; // everything else, typed by kind (see below) } throw e;}e.data is a discriminated union on kind, so after checking the kind,
TypeScript knows which fields it has:
if (isVaultbaseError(e) && e.kind === "validation" && e.data.kind === "validation") { e.data.details; // { title: "title is required" }}kind | When | In e.data |
|---|---|---|
validation | 422: the record failed validation, including a unique value already taken | details: { field: message } |
auth | 401 that a token refresh didn’t fix, or 403 (a rule refused) | reason: "expired", "invalid" or "forbidden" |
conflict | 409, e.g. a delete blocked by a restrict relation | serverCode |
precondition_failed | 412: the If-Match ETag is stale, see below | currentEtag, when the server sent it |
rate_limit | 429 | retryAfterMs, from Retry-After (at most 10 s) |
server | any other non-2xx: 400, 404, 405, 410, 415, 500, … | status, and body when the server sent one |
network | no HTTP response: DNS, TLS, refused, offline | cause |
aborted | an AbortSignal fired, or auto-cancel | — |
A missing record or collection is kind: "server" with status: 404:
const notFound = (e: unknown) => isVaultbaseError(e) && e.data.kind === "server" && e.data.status === 404;On a 401 the SDK first tries one token refresh and repeats the request; you
only see auth with reason: "expired" when that fails, and the session has
then been cleared from the auth store.
A 401 on a request sent without the session, such as a login with a wrong
password, is auth with reason: "invalid". The stored session is kept.
Optimistic concurrency
Section titled “Optimistic concurrency”Vaultbase emits an ETag header on single-record GET / PATCH
responses. The SDK caches it per (collection, id) and attaches
If-Match: <tag> on the next mutation automatically.
When two clients race:
// Client Aconst post = await vb.collection("posts").get("p_42"); // tag W/"42"// Client B updates same record → server's tag is now W/"43"
await vb.collection("posts").update("p_42", { title: "x" });// → throws VaultbaseError, kind: "precondition_failed",// e.data.currentEtag: "W/\"43\"" (when the server sent it)Recovery strategies:
import { isVaultbaseError } from "@vaultbase/sdk";
async function safeUpdate() { for (let i = 0; i < 3; i++) { try { return await vb.collection("posts").update("p_42", { title: "x" }); } catch (e) { if (!isVaultbaseError(e) || e.kind !== "precondition_failed") throw e; // Re-read with the fresh tag and retry await vb.collection("posts").get("p_42"); } } throw new Error("Concurrency contention — gave up after 3 retries");}Override / disable concurrency
Section titled “Override / disable concurrency”// Use an explicit ETag (e.g. from a prior response):await vb.collection("posts").update("p_42", { title: "x" }, { ifMatch: 'W/"42"' });
// Disable optimistic concurrency for this call:await vb.collection("posts").update("p_42", { title: "x" }, { ifMatch: false });
// "auto" is the default spelled out: the cached ETag if there is one,// otherwise no If-Match. It is never sent as a tag.await vb.collection("posts").update("p_42", { title: "x" }, { ifMatch: "auto" });Inspect / clear the ETag cache
Section titled “Inspect / clear the ETag cache”vb.client.etags.get("posts", "p_42"); // → string | undefinedvb.client.etags.set("posts", "p_42", 'W/"42"'); // manual seedvb.client.etags.delete("posts", "p_42");vb.client.etags.clear(); // wipe allThe cache is in-memory per Vaultbase instance — multiple Vaultbase
objects don’t share state.
Validation errors
Section titled “Validation errors”try { await vb.collection("posts").create({});} catch (e) { if (isVaultbaseError(e) && e.data.kind === "validation") { e.data.details; // → { title: "title is required", author: "author is required" } }}details is a { field → message } map suitable for binding directly
to form-field error UI.
Rate-limit errors
Section titled “Rate-limit errors”When the server returns 429, the error’s kind is "rate_limit" and
e.data.retryAfterMs holds the server’s Retry-After, in milliseconds
(capped at 10 seconds).
try { await vb.collection("posts").list();} catch (e) { if (isVaultbaseError(e) && e.data.kind === "rate_limit") { const ms = e.data.retryAfterMs; await new Promise((r) => setTimeout(r, ms)); // …retry } throw e;}Network errors
Section titled “Network errors”kind: "network" covers anything that didn’t reach the server: DNS
failure, TLS error, connection refused, browser offline.
if (isVaultbaseError(e) && e.kind === "network") { showOfflineBanner();}Best practices
Section titled “Best practices”- Always narrow with
isVaultbaseError(e)before reading typed fields. - Don’t retry
validation,authwithreason: "forbidden", or aserver404: they won’t resolve themselves. - Do retry
precondition_failedafter re-reading. - Do retry
networkandrate_limitwith exponential backoff (capped) — but only for idempotent ops. - Never retry
POSTcreate blindly without an idempotency key (server issues UUIDs, so duplicate creates are visible as two records).