Error Handling
mion uses RpcError for every error in routes and middleware. How you raise it decides whether the client gets it typed and whether the rest of the chain runs.
| What you write | Typed on the client | The rest of the chain |
|---|---|---|
return new RpcError(...) | Yes, as the route's error or at the middleware's path | Keeps running |
return new FatalError(...) | Yes, as the route's error or at the middleware's path | Stops |
throw anything | No, only the public message in @thrownErrors | Stops |
Return Errors
Return application errors. A returned error is part of the function signature, so the client knows every error type a route can answer, its errorData included. With several error types, the client gets a discriminated union of all of them. A returned error keeps the chain running, so one route answering user-not-found does not drop the other routes in the same request.
export const getPet = mion.route(
async (ctx, id: string): Promise<Pet | RpcError<'pet-not-found'>> => {
const pet = await myApp.db.getPet(id);
if (!pet) {
return new RpcError({
publicMessage: `Pet with id ${id} can't be found`,
type: 'pet-not-found',
});
}
return pet;
}
) satisfies Route;
Stopping the Request
Return a FatalError when an error must also stop the request, like a failed auth check in a middleware. It is an RpcError, so it stays in the signature and reaches the client typed. Nothing after it runs except the middleware with alwaysRun, and in a batch the routes after it are skipped.
// ends the request: the route behind this middleware never runs
export const auth = mion.middleware(
(ctx): void | FatalError<'not-authorized'> => {
if (!myApp.auth.getSessionUser(ctx.request.headers.get('cookie')))
return new FatalError({
publicMessage: 'Not Authorized',
type: 'not-authorized',
});
}
);
FatalError like any declared error: at the middleware's path and in its onError listeners, never in @thrownErrors. It is not told the route was skipped: a route that returns a value simply gives undefined.HTTP Status Codes
A FatalError sets the response's HTTP status from its statusCode, such as 401. Without one it answers 400, a declared application error, like the auth gate above. A thrown error without a statusCode answers 422, something the server did not expect.
Identifying Error Types
The client gets returned errors fully typed. Tell them apart by error.type, never by the HTTP status code, which is only a transport detail in an RPC API.
import {isRpcError} from '@mionjs/core';
import {initClient} from '@mionjs/client';
import type {MyApi} from './auth-user.routes.ts';
const {routes} = initClient<MyApi>({
baseURL: 'http://localhost:3000',
});
const [user, error, response] = await routes.users.getById('USER-404').call();
const thrown = response['@thrownErrors'];
// error.type is the discriminator, never the HTTP status code
if (error) {
switch (error.type) {
case 'user-not-found':
// declared payloads survive narrowing: errorData is UserNotFoundData
console.log('missing user:', error.errorData?.requestedId);
break;
case 'validation-error':
console.log('type errors:', error.errorData?.typeErrors.length);
break;
}
} else if (isRpcError(response.auth)) {
// the middleware's declared error; its onError hook gets it strongly typed
console.log('auth failed:', response.auth.type);
} else if (thrown) {
// transport, platform or any undeclared error lands here
console.log('request failed:', thrown[0].type);
} else {
console.log(user?.name); // John
}
// typeErrors() is the only method that can throw
try {
const errors = await routes.users.getById(null as any).typeErrors();
console.log(errors); // [] (empty array if no errors)
} catch (validationError: any) {
// { type: 'validation-error', message: `Invalid params ...` }
console.log(validationError);
}
Throwing Errors (Discouraged)
Throw only for truly unexpected situations, like bugs or infrastructure failures. A thrown error ends the request like a FatalError, but it is not in the function signature, so the client gets only its publicMessage, in @thrownErrors.
export const updatePet = mion.route(async (ctx, pet: Pet): Promise<Pet> => {
try {
return await myApp.db.updatePet(pet);
} catch (dbError) {
// a logger still reads the full error on ctx.request.thrownErrors and ctx.response.fatalError
throw new RpcError({
publicMessage: `Cant update the pet.`,
message: (dbError as Error).message,
originalError: dbError as Error,
type: 'db-error',
});
}
}) satisfies Route;
export const alwaysError = mion.route((): void => {
throw new Error('the client gets a 422 unknown-error, never this message');
}) satisfies Route;
The router itself throws for what nobody declared: a missing route, an unreadable body, a payload over the limit. The exception is validation-error: it is thrown on the server but is part of every route's contract, so the client still gets it typed.
throw inside a handler as rpc-handler-throws. To keep a throw, add // @mion-expect-error rpc-handler-throws above it, and a comment saying why.Error Response Format
- Returned errors (strongly typed), a
FatalErrorincluded, appear under the route or middleware id with their type information. - Thrown errors (untyped) go in the
@thrownErrorsfield.
{
"getUser": [0, {
"type": "user-not-found",
"publicMessage": "User not found",
"errorData": {"userId": "123"}
}],
"@thrownErrors": {
"someMiddleware": {
"type": "unknown-error",
"publicMessage": "Unknown error in handler \"someMiddleware\" of route ExecutionChain.",
"statusCode": 422
}
}
}
getUser returns User | RpcError, so its value is sent as an [index, value] pair.
message, the fatal flag and the stack stay on the server.Always Run Middleware
A middleware with alwaysRun: true runs even after an error ended the request, see Always Run.