Errors
Throw errors with a code and data that callers can branch on.
When a function throws, the call fails and every write it made is rolled back. A plain throw new Error('...') reaches the caller as a message. To give the caller something to branch on, throw a TetherError with a code and, optionally, data.
Throwing a TetherError
// tether/functions/drafts.ts
import { mutation, z, TetherError } from '../_generated/db';
export const save = mutation({
args: z.object({ _id: z.string(), revision: z.number(), content: z.string() }),
handler: async ({ args, db }) => {
const draft = await db.drafts.findById(args._id);
if (!draft) {
throw new TetherError('NOT_FOUND', 'Draft not found');
}
if (draft.revision !== args.revision) {
throw new TetherError('CONFLICT', 'Changed elsewhere', { revision: draft.revision });
}
await db.drafts.update({
where: { _id: args._id },
data: { content: args.content, revision: draft.revision + 1 },
});
return { revision: draft.revision + 1 };
},
});
new TetherError(code, message?, data?):
| Argument | Description |
|---|---|
code |
A string the caller compares against, such as 'CONFLICT' |
message |
A human-readable message. Defaults to the code. |
data |
Any JSON value: details the caller needs, such as the current revision |
TetherError is exported by @tthr/server and by your generated _generated/db.
Over HTTP
A failed query or mutation returns 400. The body has the message, plus the code and data for a TetherError:
{ "error": "Changed elsewhere", "code": "CONFLICT", "data": { "revision": 3 } }
A plain throw has error only, as before:
{ "error": "Draft not found" }
Tether's own auth failures carry a code too, so a client can tell an expired token from a bad one:
| Status | Code | Meaning |
|---|---|---|
403 |
AUTH_NOT_CONFIGURED |
A function that needs a user got a token the environment can't check: it has no auth provider. See Signing users in. |
401 |
TOKEN_EXPIRED |
The token is valid apart from its exp. Get a fresh one and retry. |
401 |
INVALID_TOKEN |
Bad signature, issuer or audience, issued to another app, or Strands didn't recognise it. |
503 |
AUTH_UNAVAILABLE |
Strands or the provider's keys couldn't be reached. Retry. |
403 |
UNAUTHENTICATED |
The function is authenticated and the call had no token. |
The JavaScript client handles the first and third for you when you give it onTokenExpired (see Token renewal).
Catching it in your app
The client SDKs throw a TetherError back to you, with the same code and data. Any other failure is a plain Error.
import { TetherError } from '@tthr/client';
try {
await tether.mutation('drafts.save', { _id, revision, content });
} catch (e) {
if (e instanceof TetherError && e.code === 'CONFLICT') {
showConflict(e.data.revision);
} else {
throw e;
}
}
instanceof TetherError matches a TetherError from any Tether package, so it doesn't matter whether you import the class from @tthr/client, @tthr/server or @tthr/vue. isTetherError(e) does the same check as a type guard.
Vue and Nuxt
useMutation and useQuery put the TetherError in their error ref, and mutate(), $query() and $mutation() throw it. In Nuxt, TetherError and isTetherError are auto-imported, and the code and data survive the Nuxt server proxy and server-side rendering.
<script setup lang="ts">
const { mutate: save, error } = useMutation(api.drafts.save);
async function onSave() {
try {
await save({ _id, revision, content });
} catch (e) {
if (isTetherError(e) && e.code === 'CONFLICT') {
revision.value = e.data.revision;
}
}
}
</script>
Server-side clients
TetherServerClient (from @tthr/client and @tthr/vue/server) and createClient from @tthr/server throw a TetherError the same way.
Bridges
A TetherError thrown by a function in a bridged project reaches the calling function as a TetherError with the same code and data, so it can be caught and handled there or rethrown to the client.
Logs
A failed execution's error is logged against the function as usual. For a TetherError, the log entry's data holds the code and data.