Errors

Refuse with an error the client can read, and keep every other error on the server.

A PublicError is an error the client is meant to read: its code, its message and its details reach it, from server code and over every transport. Throw one from a function, a hook or a plugin to refuse:

src/functions.ts
import { PublicError, allow, defineFunction, master } from 'ultimabase'
import { z } from 'zod'
import { Product } from './models.ts'

export const order = defineFunction({
	name: 'order',
	input: z.object({ sku: z.string(), quantity: z.int().min(1) }),
	run: async ({ input }) => {
		const product = await Product.findUniqueOrThrow(
			{ where: { sku: input.sku } },
			{ as: master },
		)
		if (product.stock < input.quantity) {
			// The code, the message and the details reach the client.
			throw new PublicError('OUT_OF_STOCK', `Only ${product.stock} left.`, {
				details: { sku: product.sku, left: product.stock },
			})
		}
		await Product.update(
			{
				where: { id: product.id },
				data: { stock: { decrement: input.quantity } },
			},
			{ as: master },
		)
	},
}).with({ access: allow.public })

The full example, with its tests: examples/errors.

The code is in capitals: OUT_OF_STOCK, or one of the kernel's codes, which keep their meaning. The details are any JSON value.

In server code

A caller in server code catches it, and branches on its code. The kernel's own errors are PublicErrors too, with the codes listed below:

src/app.test.ts
// A caller branches on the code, and lets any other error go on.
const outcome = await order.run({ sku: 'MUG-1', quantity: 5 }).then(
	() => 'Ordered.',
	(error: unknown) => {
		if (error instanceof PublicError && error.code === 'OUT_OF_STOCK') {
			return `Sorry: ${error.message}`
		}
		throw error
	},
)
expect(outcome).toBe('Sorry: Only 2 left.')
await expect(order.run({ sku: 'MUG-1', quantity: 5 })).rejects.toMatchObject({
	code: 'OUT_OF_STOCK',
	details: { sku: 'MUG-1', left: 2 },
})

// The kernel's own errors are PublicErrors too.
await expect(
	Product.create(
		{ data: { sku: 'MUG-1', name: 'Other mug' } },
		{ as: master },
	),
).rejects.toMatchObject({
	code: 'UNIQUE_VIOLATION',
	details: { model: 'Product', fields: ['sku'] },
})

Over the transports

REST answers with the error as its body, and a status from its code: an application's own code is a 400.

src/app.test.ts
const response = await post('/api/functions/order', {
	sku: 'MUG-1',
	quantity: 5,
})
// An application's own code is a 400.
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
	code: 'OUT_OF_STOCK',
	message: 'Only 2 left.',
	details: { sku: 'MUG-1', left: 2 },
})

GraphQL puts the code and the details in the error's extensions, with the path of the field that failed:

src/app.test.ts
const answer = await post('/graphql', {
	query: 'mutation { order(sku: "MUG-1", quantity: 5) }',
})
expect(await answer.json()).toMatchObject({
	data: null,
	errors: [
		{
			message: 'Only 2 left.',
			path: ['order'],
			extensions: {
				code: 'OUT_OF_STOCK',
				details: { sku: 'MUG-1', left: 2 },
			},
		},
	],
})

Every other error

An error that is not a PublicError, a bug or a service that failed, never reaches the client: its message could hold a secret. Its request answers INTERNAL, and the server logs the error with a correlation id the answer holds too:

src/functions.ts
export const subscribe = defineFunction({
	name: 'subscribe',
	input: z.object({ email: z.email() }),
	run: async ({ input }) => {
		const url = process.env['NEWSLETTER_URL']
		// Any other error is masked: the client sees `INTERNAL`.
		if (url === undefined) throw new Error('NEWSLETTER_URL is not set.')
		await fetch(url, { method: 'POST', body: JSON.stringify(input) })
	},
}).with({ access: allow.public })
src/app.test.ts
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
const response = await post('/api/functions/subscribe', {
	email: 'ada@example.com',
})
expect(response.status).toBe(500)
// The message stays on the server.
const { correlationId } = z
	.object({
		code: z.literal('INTERNAL'),
		message: z.literal('Internal error.'),
		correlationId: z.uuid(),
	})
	.strict()
	.parse(await response.json())
// The log line holds the same id, and the error with its stack.
const line = String(logged.mock.calls[0]?.[0])
expect(line).toContain(correlationId)
expect(line).toContain('NEWSLETTER_URL is not set.')

The log line is A request failed. over REST and A GraphQL field failed. over GraphQL, at error level, with correlationId and err. defineApp({ logger }) takes a pino logger, or any logger with its info, warn, error and child; without one, the kernel writes a readable line in a terminal and a JSON line everywhere else.

Reference

new PublicError(code, message, options?)

ArgumentTypeEffect
codestringIn capitals: a kernel code, or the application's own.
messagestringShown to the client as is: write it for the client.
options.detailsa JSON valueSent with the error; { issues } for VALIDATION_FAILED.
options.causeunknownThe error it comes from, kept on the server.

Codes

CodeWhenREST statusdetails
VALIDATION_FAILEDA value or an input its schema refused.400{ issues }
ACCESS_DENIEDAn access rule refused.403what refused
NOT_FOUNDNo row matches, or the caller cannot read it.404none
UNIQUE_VIOLATIONA second row with a unique value.409{ model, fields }
RELATION_RESTRICTEDA delete a restrict relation refused.409{ model, field }
UNAUTHENTICATEDA session is needed, or it ended.401none
INVALID_CREDENTIALSA wrong email or password.401none
TOO_MANY_ATTEMPTSToo many sign-ins; Retry-After says when to try again.429{ retryAfter }, in seconds
BAD_USER_INPUTA client's query, parameter or body the API refuses.400{ path }, from the kernel
INTERNALAny error that is not a PublicError.500correlationId, beside it
the application's ownA PublicError it threw.400its own

An issue is { path, message }: path lists the keys to the refused value, as ['days'].

The answer

TransportShape
REST{ code, message, details? }, or { code, message, correlationId }
GraphQLerrors: [{ message, path, extensions: { code, details? } }]

HTTP refuses some requests before any code runs, with the status's name as the code: a body over bodyLimit is 413 PAYLOAD_TOO_LARGE. See HTTP.

Limits

  • A code not in capitals is a type error.
  • A query the kernel refuses, such as an unknown field, throws an InvalidQuery in server code, with the code INVALID_QUERY and its path. Sent by a client, it answers BAD_USER_INPUT with the same message; thrown by server code and left uncaught, it is a bug, masked as INTERNAL.

On this page