Callspec error handling
Callspec error handling
Section titled “Callspec error handling”Design reference for the callspec error contract, mountSpec runtime, and client Result shape.
Overview
Section titled “Overview”defineErrors()— domain error maps; shorthanderris builtins-only.- Return failures from resolvers —
return err.NOT_FOUND()/return registerErr.USER_EXISTS({ … }); success is a plain route output object. RouteFailure—{ ok: false, code, status, data? }from resolvers and fromdefineErrors/errhandles.- Builtins on every route — merged at
routetime; automatic in OpenAPI,callspec.json, and every client*Resultunion. Do not re-declare builtin codes on routes. - Strict domain registration — returned domain codes must appear on the route; TypeScript checks resolver return types against
errors:at compile time (no runtime allowlist). BUILTIN_ERROR— one constant namespace for all automatic codes (validation, auth, route-not-found, etc.).- Client Result —
{ ok: true, value } | { ok: false, status, code, data? }. Branch oncodewhen!result.ok. Every failure union includes client-onlyUNKNOWN_ERROR(HTTP response outside the route contract) andNETWORK_ERROR(no HTTP response — DNS, offline, abort;status: 0). - Codegen — after changing routes or error specs, rerun
npx callspec …and refresh generated client types.
Framework validation and auth throw CallspecValidationError / CallspecUnauthorizedError — mountSpec maps those inline. Any other unhandled error becomes INTERNAL_ERROR (see mountSpec runtime).
mountSpec runtime
Section titled “mountSpec runtime”For RPC routes mounted with mountSpec, errors and logging are owned by callspec — you do not wire jsout or jsout-express on that router for normal operation.
mountSpec(router, spec); // request log + catch path + INTERNAL_ERROR — zero extra middlewareCatch order (per request)
Section titled “Catch order (per request)”After executeRoute returns or throws:
| Step | Condition | HTTP response | Default error log |
|---|---|---|---|
| 1 | Resolver returns RouteFailure | Wire failure (sendRouteFailureResponse) | None |
| 2 | Resolver throws RouteFailure | Wire failure | None |
| 3 | CallspecValidationError (input validation) | 400 VALIDATION_ERROR + errors | None |
| 4 | CallspecUnauthorizedError (private route, bad/missing token) | 401 UNAUTHORIZED | None |
| 5 | handleUnhandledError(err, req) returns RouteFailure | Wire failure | You choose (mountSpec skips default error log) |
| 6 | Anything else (bug, rejected promise, unknown throw) | 500 INTERNAL_ERROR | jsout logger.error via logUnhandledError |
Success is step 0: HTTP 200 + route output JSON — no error log.
Steps 1–4 are intentional contract outcomes. Step 6 is for unexpected failures: synchronous throw new Error('…'), rejected async resolvers, driver/library throws, etc.
Logging
Section titled “Logging”| Event | Who | When | Default |
|---|---|---|---|
| RPC request | mountSpec → jsout-express logRequest | Every request on the mounted router (on response finish) | On when logging !== false |
| Unhandled bug | logUnhandledError | Catch step 6 only | logger.error(undefined, err, { url, method }) |
| Infra / known throw | Your handleUnhandledError | Catch step 5 | Your level — e.g. logger.warn for query timeout, no log for benign cases |
| Intentional failure | — | Steps 1–4 | No error log |
MountSpecOptions:
| Option | Default | Purpose |
|---|---|---|
logging | true | false silences request logging and default error logging (use in tests) |
handleUnhandledError | — | Map known throws to RouteFailure before step 6 |
logUnhandledError | jsout logger.error | Override only the step-6 error log |
Re-exported logRequest from callspec is the same jsout-express middleware — use it on other Express routers (upload, webhooks) so request logs match.
Known infrastructure throws
Section titled “Known infrastructure throws”Handle expected non-bug throws in handleUnhandledError. Return a RouteFailure to respond on the wire; return undefined to fall through to log + INTERNAL_ERROR.
import { err, mountSpec } from 'callspec';import { logger } from 'jsout';
mountSpec(router, spec, { handleUnhandledError(thrown, req) { if (isKnownTransientFailure(thrown)) { logger.warn('transient failure', thrown); return err.SERVICE_UNAVAILABLE({ message: 'Try again.' }); } },});Import err (builtins-only handle) or your domain handle — do not confuse the caught value with the callspec handle.
Two tiers
Section titled “Two tiers”| Tier | Declared on route? | In every *Result? | Production |
|---|---|---|---|
| Builtin | No (merged at route) | Yes | return err.NOT_FOUND() etc. |
| Domain | Yes (errors: defineErrors({ … })) | Only that route | return registerErr.USER_EXISTS(…) |
Builtin codes
Section titled “Builtin codes”| Code | Typical HTTP status | Source |
|---|---|---|
VALIDATION_ERROR | 400 | mountSpec (input validation) |
UNAUTHORIZED | 401 | mountSpec (missing/invalid auth) |
ROUTE_NOT_FOUND | 404 | mountSpec (unknown RPC method) |
NOT_FOUND | 404 | resolver (return err.NOT_FOUND()) |
FORBIDDEN | 403 | resolver or middleware |
CONFLICT | 409 | resolver |
TOO_MANY_REQUESTS | 429 | rate-limit middleware |
SERVICE_UNAVAILABLE | 503 | resolver or middleware |
INTERNAL_ERROR | 500 | mountSpec (unhandled throw or rejected promise in resolver) |
ROUTE_NOT_FOUND and NOT_FOUND both use HTTP 404 but mean different things — the code is the contract; status is a transport hint.
Wire format and HTTP status
Section titled “Wire format and HTTP status”Contract: { error: "CODE", data? } (plus errors on VALIDATION_ERROR).
- Success: HTTP 200 + route output JSON.
- Failure: HTTP 4xx/5xx + error JSON — never 200 with an error body.
HTTP status is not the semantic layer. It exists for:
- Sending the response (
RouteFailure.statusor mountSpec defaults) - OpenAPI documentation (grouping schemas by status)
- Client fallback when foreign Express middleware returns a bare status without
{ error }
Generated clients and app code should branch on result.code when !result.ok, not result.status. The client maps wire { error, data? } to { ok: false, status, code, data? }.
Codegen types each route’s {Route}Result so result.code is a fully exhaustive union — declared domain errors, builtins (VALIDATION_ERROR, UNAUTHORIZED, …), and client-only codes (NETWORK_ERROR, UNKNOWN_ERROR). A switch (result.code) with a never default (or equivalent) gets compile-time exhaustiveness checking.
data on the client Result mirrors the error spec’s data pred:
- Required pred —
dataalways present on validated domain failures; builtins likeVALIDATION_ERRORandROUTE_NOT_FOUNDrequire wire payloads when typed - Optional pred (
p.optional(...)) —{ code }alone is valid; includedataonly when the wire payload validates (TOO_MANY_REQUESTS,NOT_FOUND, etc.) - No pred — no
dataproperty
When the client cannot validate a declared domain error payload (missing/invalid data), the failure becomes UNKNOWN_ERROR with the raw body. The client never invents payload fields.
Domain and builtin specs use the same mechanism — declare data: p.optional(yourPred) for optional typed context.
Domain errors omit status to default to 400 (DEFAULT_ROUTE_ERROR_STATUS). Override status only when you care about HTTP/OpenAPI transport mapping.
Client error normalization
Section titled “Client error normalization”CallspecClient.callResult maps failures to typed { ok: false, status, code, data? } results. INTERNAL_ERROR is only used when the server sends that code on the wire — the client never invents it during HTTP normalization.
Transport failures
Section titled “Transport failures”If fetch throws (DNS failure, offline, abort, etc.) before any HTTP response, the client returns:
{ ok: false, status: 0, code: 'NETWORK_ERROR', data: { message, name? } }status: 0 means no response. data.message / data.name come from the thrown Error when available. This is client-only (not in callspec.json).
HTTP failure pipeline (in order)
Section titled “HTTP failure pipeline (in order)”- Exact callspec JSON —
{ error: "CODE", data? }(anderrorsonVALIDATION_ERROR). Builtin codes and route-declared domain codes map to typed failures when the wire shape validates. Domain payloads are checked againstcallspec.jsonschemas (codegen passesdomainErrors). An{ error }field that fails validation or is undeclared becomesUNKNOWN_ERROR(preserves raw body). - Exact body phrases — case-insensitive literals such as
Unauthorized,Forbidden,Bad Gateway,Service Unavailable. - HTTP status — takes priority over fuzzy body matching. Examples: 401 →
UNAUTHORIZED, 502/503/504 →SERVICE_UNAVAILABLE, 429 →TOO_MANY_REQUESTS(code only when the body has no validated payload). Unmapped statuses fall through. - Fuzzy body match — strip HTML for matching only; normalize case/spacing/underscores; map phrases (
badgateway,unauthorized, …) and code-like strings to known builtins or declared domain codes. UNKNOWN_ERROR(client-only, not incallspec.json) —{ code: 'UNKNOWN_ERROR', data: { body, headers? } }.bodyis the raw parsed response (string or JSON) for operator debugging;headersare response headers when present. Do not showUNKNOWN_ERROR.datato end users — log or devtools only.
HTML tag stripping applies only while matching (steps 2–4). It is not applied to UNKNOWN_ERROR.data.body.
For non-RPC / legacy routes, normalizeClientErrorBody(status, body, options?) from callspec/client runs the same HTTP pipeline (optional responseHeaders in options).
For fuzzy-matching implementation notes, see docs/internal/ in the repo (not published on the guide site).
Resolver pattern
Section titled “Resolver pattern”Preds once in a route def; helpers use RouteFailuresFrom:
import {route, defineErrors, err, isRouteFailure, type RouteFailuresFrom} from 'callspec';import {predicates as p} from 'runtyp';
const registerErr = defineErrors({USER_ALREADY_EXISTS: {}});
function ensureAvailable(email: string): void | RouteFailuresFrom<typeof registerErr> { if (taken) return registerErr.USER_ALREADY_EXISTS();}
export const register = route({ input: p.object({email: p.string()}), output: p.object({userId: p.string()}), errors: registerErr, meta: {summary: 'Register', tags: ['auth']}, resolver: async (input, _ctx) => { const blocked = ensureAvailable(input.email); if (isRouteFailure(blocked)) return blocked; return {userId: '…'}; },});
// anywhere in resolver or helper:return err.NOT_FOUND({message: '…'});Helpers return RouteFailuresFrom<typeof registerErr> (or void / domain data); callers propagate with if (isRouteFailure(x)) return x.
- Return failures via
defineErrors()handles (err,defineErrors({ DOMAIN: … })) - Builtins are always allowed — merged onto every route at definition time
- Undeclared domain returns are a compile error on the route resolver (routes without
errors:allow builtins only) CallspecClient.callResult— see Client error normalization. Mapped HTTP failures use builtins + route-declared codes; unmapped responses areUNKNOWN_ERROR; transport failures areNETWORK_ERROR.