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:
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:
// 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.
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:
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:
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 })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?)
| Argument | Type | Effect |
|---|---|---|
code | string | In capitals: a kernel code, or the application's own. |
message | string | Shown to the client as is: write it for the client. |
options.details | a JSON value | Sent with the error; { issues } for VALIDATION_FAILED. |
options.cause | unknown | The error it comes from, kept on the server. |
Codes
| Code | When | REST status | details |
|---|---|---|---|
VALIDATION_FAILED | A value or an input its schema refused. | 400 | { issues } |
ACCESS_DENIED | An access rule refused. | 403 | what refused |
NOT_FOUND | No row matches, or the caller cannot read it. | 404 | none |
UNIQUE_VIOLATION | A second row with a unique value. | 409 | { model, fields } |
RELATION_RESTRICTED | A delete a restrict relation refused. | 409 | { model, field } |
UNAUTHENTICATED | A session is needed, or it ended. | 401 | none |
INVALID_CREDENTIALS | A wrong email or password. | 401 | none |
TOO_MANY_ATTEMPTS | Too many sign-ins; Retry-After says when to try again. | 429 | { retryAfter }, in seconds |
BAD_USER_INPUT | A client's query, parameter or body the API refuses. | 400 | { path }, from the kernel |
INTERNAL | Any error that is not a PublicError. | 500 | correlationId, beside it |
| the application's own | A PublicError it threw. | 400 | its own |
An issue is { path, message }: path lists the keys to the refused
value, as ['days'].
The answer
| Transport | Shape |
|---|---|
| REST | { code, message, details? }, or { code, message, correlationId } |
| GraphQL | errors: [{ 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
InvalidQueryin server code, with the codeINVALID_QUERYand itspath. Sent by a client, it answersBAD_USER_INPUTwith the same message; thrown by server code and left uncaught, it is a bug, masked asINTERNAL.