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.