Errors

Returning the right status from an app handler — HttpError and its helpers, how a constraint violation becomes a 409 or a 400, the response body, and declaring the statuses in your contract.

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

src/main.js
const {
  createApp, mountCrud,
  HttpError, notFound, badRequest, forbidden, conflict,
} = require('@revenexx/app-sdk/router');
HelperStatusMeans
badRequest(message)400The request is malformed or fails a guard. Do not retry unchanged.
forbidden(message)403Authenticated, but this caller may not do this.
notFound(message)404No such resource — or it belongs to another tenant.
conflict(message)409The request collides with existing state.
new HttpError(status, message, code?)anyAnything the four helpers do not cover, e.g. 422.

Throw them. The router catches an HttpError, serialises it, and answers with its status:

src/main.js
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.

src/main.js
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:

400
{
  "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:

src/main.js
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 saysYour API answers
Unique index or unique constraint violated409 Conflict
check constraint violated400 Bad Request
notNull violated400 Bad Request
Foreign key violated400 Bad Request
Value out of range, or wrong type for the column400 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:

manifest.capabilities.json
"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, and 400 wherever 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 2xx replaces the assumed 200 and moves response onto 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

SituationStatus
A required field is missing or the wrong type400
A value is well-formed but not allowed here400, or 422 via HttpError if you want to distinguish it
The caller may not perform this operation403
The id does not exist, or belongs to another tenant404
The path exists but not with this method405 — the router answers it for you
The write collides with existing state409
Your own dependency failedLet 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

Was this page helpful?