Errors
An app that answers 500 for every unhappy path is an app nobody can integrate against. The router gives you the statuses; your job is to pick the right one and to declare it.
The helpers
const {
createApp, mountCrud,
HttpError, notFound, badRequest, forbidden, conflict,
} = require('@revenexx/app-sdk/router');
| Helper | Status | Means |
|---|---|---|
badRequest(message) | 400 | The request is malformed or fails a guard. Do not retry unchanged. |
forbidden(message) | 403 | Authenticated, but this caller may not do this. |
notFound(message) | 404 | No such resource — or it belongs to another tenant. |
conflict(message) | 409 | The request collides with existing state. |
new HttpError(status, message, code?) | any | Anything the four helpers do not cover, e.g. 422. |
Throw them. The router catches an HttpError, serialises it, and answers with its status:
app.post('/serials/{id}/sold', async (c) => {
const row = await db.device_serials.get(c.params.id);
if (!row) throw notFound('no serial with that id');
if (row.status === 'sold') throw conflict('serial is already sold');
return c.json(await db.device_serials.update(row.id, {
status: 'sold',
sold_at: new Date().toISOString(),
}));
});
get() returns null for a missing row rather than throwing, so the 404 is yours to raise. That is deliberate: only your route knows whether a missing row is an error or an empty case.
Guard first, work second
Every guard in a handler belongs before the first read or write, so that by the time real work starts the input is clean.
const { badRequest } = require('@revenexx/app-sdk/router');
function parseItems(body) {
const items = Array.isArray(body?.items) ? body.items : null;
if (!items || items.length === 0) throw badRequest("'items' array is required");
if (items.length > 200) throw badRequest('at most 200 items per call');
return items.map((raw, i) => {
if (!raw.product_id && !raw.sku) throw badRequest(`items[${i}] needs 'product_id' or 'sku'`);
return { product_id: raw.product_id ?? null, sku: raw.sku ?? null, quantity: Number(raw.quantity ?? 1) };
});
}
app.post('/serials/availability', async (c) => {
const items = parseItems(c.body);
// …from here the data is known-good
});
Point the message at the field: items[3] needs 'product_id' or 'sku' is actionable, invalid request is not. The message travels to the caller, so write it for them.
The response body
An HttpError is serialised to its status with a small JSON body:
{
"error": "at most 200 items per call",
"code": "bad_request"
}
error carries the message you passed. code is a stable machine-readable string, so a client can branch on it without matching prose. Pass your own third argument to HttpError when a caller needs to distinguish two failures that share a status:
throw new HttpError(409, 'serial is already registered', 'serial_exists');
throw new HttpError(409, 'the registry is locked for this market', 'registry_locked');
Errors the gateway raises before your function is invoked — a missing tenant header, a body that fails contract validation, a rate limit — use the gateway's own error shape, documented in API usage: errors. A robust client reads the status code first and treats the body as detail either way.
Constraint violations map themselves
A write that violates the database answers with the right status without you writing a check for it:
| The database says | Your API answers |
|---|---|
| Unique index or unique constraint violated | 409 Conflict |
check constraint violated | 400 Bad Request |
notNull violated | 400 Bad Request |
| Foreign key violated | 400 Bad Request |
| Value out of range, or wrong type for the column | 400 Bad Request |
So a unique serial gives you idempotency-ish behaviour for free: a duplicate registration is a 409, not a duplicate row.
Two things follow.
A declared constraint is a documented behaviour. If serial is unique, 409 is part of your contract and belongs in responses — otherwise a generated SDK has no case for it.
Mocks do not do this. An in-memory store has no unique index, so the 409 path cannot be tested against a mock. Test the guard you wrote against a mock, and the constraint you declared against a real tenant. See Adapters.
Where the message matters more than the status, raise the error yourself before the write — a conflict('serial is already registered') reads better to a partner than whatever a constraint name looks like.
Declare the statuses
The status your handler returns is only half the job. A caller reads the contract, and the contract claims a bare 200 unless you say otherwise:
"responses": {
"201": { "description": "Created. The body is the stored serial." },
"400": { "description": "The serial or product id is missing or malformed." },
"404": { "description": "No serial with that id, or it belongs to another tenant." },
"409": { "description": "That serial is already registered for this tenant." }
}
- The gateway already declares
401,403,429,502, and400wherever there is a request body. You do not repeat those; a status you declare keeps your wording and the standard ones fill in the rest. - Declaring any
2xxreplaces the assumed200and movesresponseonto the status you declared. - Write each description in the caller's terms — "that serial is already registered", not "Conflict".
See responses.
Do not out-strict your own contract
The gateway validates the request body against the declared request schema before your function runs. So a schema stricter than your handler produces a 400 your code never sees and never logs, on a request your handler would have accepted.
Keep required, enum and anyOf aligned with the guards you actually enforce, and when you loosen a guard, loosen the schema in the same commit. This is the trap that costs the most debugging time, because the failing request never reaches your logs at all.
Which status, in practice
| Situation | Status |
|---|---|
| A required field is missing or the wrong type | 400 |
| A value is well-formed but not allowed here | 400, or 422 via HttpError if you want to distinguish it |
| The caller may not perform this operation | 403 |
| The id does not exist, or belongs to another tenant | 404 |
| The path exists but not with this method | 405 — the router answers it for you |
| The write collides with existing state | 409 |
| Your own dependency failed | Let it throw; do not dress an upstream failure up as a 400 |
That last row matters for calling another app: a 4xx from your own contract is your caller's problem to fix, and a failure behind you is not something they can act on by editing their request.
Next steps
- The router — where handlers and statuses meet.
- Capability reference — declaring
responses. - Calling another app — whose fault a failure is.
- API usage: errors — the gateway's own error shape and status codes.