Skip to content

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.

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" }
}
kindWhenIn e.data
validation422: the record failed validation, including a unique value already takendetails: { field: message }
auth401 that a token refresh didn’t fix, or 403 (a rule refused)reason: "expired", "invalid" or "forbidden"
conflict409, e.g. a delete blocked by a restrict relationserverCode
precondition_failed412: the If-Match ETag is stale, see belowcurrentEtag, when the server sent it
rate_limit429retryAfterMs, from Retry-After (at most 10 s)
serverany other non-2xx: 400, 404, 405, 410, 415, 500, …status, and body when the server sent one
networkno HTTP response: DNS, TLS, refused, offlinecause
abortedan 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.

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 A
const 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");
}
// 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" });
vb.client.etags.get("posts", "p_42"); // → string | undefined
vb.client.etags.set("posts", "p_42", 'W/"42"'); // manual seed
vb.client.etags.delete("posts", "p_42");
vb.client.etags.clear(); // wipe all

The cache is in-memory per Vaultbase instance — multiple Vaultbase objects don’t share state.

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.

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;
}

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();
}
  • Always narrow with isVaultbaseError(e) before reading typed fields.
  • Don’t retry validation, auth with reason: "forbidden", or a server 404: they won’t resolve themselves.
  • Do retry precondition_failed after re-reading.
  • Do retry network and rate_limit with exponential backoff (capped) — but only for idempotent ops.
  • Never retry POST create blindly without an idempotency key (server issues UUIDs, so duplicate creates are visible as two records).