Skip to content

Callspec error handling

Design reference for the callspec error contract, mountSpec runtime, and client Result shape.

  • defineErrors() — domain error maps; shorthand err is builtins-only.
  • Return failures from resolversreturn err.NOT_FOUND() / return registerErr.USER_EXISTS({ … }); success is a plain route output object.
  • RouteFailure{ ok: false, code, status, data? } from resolvers and from defineErrors / err handles.
  • Builtins on every route — merged at route time; automatic in OpenAPI, callspec.json, and every client *Result union. 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 on code when !result.ok. Every failure union includes client-only UNKNOWN_ERROR (HTTP response outside the route contract) and NETWORK_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).

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 middleware

After executeRoute returns or throws:

StepConditionHTTP responseDefault error log
1Resolver returns RouteFailureWire failure (sendRouteFailureResponse)None
2Resolver throws RouteFailureWire failureNone
3CallspecValidationError (input validation)400 VALIDATION_ERROR + errorsNone
4CallspecUnauthorizedError (private route, bad/missing token)401 UNAUTHORIZEDNone
5handleUnhandledError(err, req) returns RouteFailureWire failureYou choose (mountSpec skips default error log)
6Anything else (bug, rejected promise, unknown throw)500 INTERNAL_ERRORjsout 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.

EventWhoWhenDefault
RPC requestmountSpec → jsout-express logRequestEvery request on the mounted router (on response finish)On when logging !== false
Unhandled buglogUnhandledErrorCatch step 6 onlylogger.error(undefined, err, { url, method })
Infra / known throwYour handleUnhandledErrorCatch step 5Your level — e.g. logger.warn for query timeout, no log for benign cases
Intentional failureSteps 1–4No error log

MountSpecOptions:

OptionDefaultPurpose
loggingtruefalse silences request logging and default error logging (use in tests)
handleUnhandledErrorMap known throws to RouteFailure before step 6
logUnhandledErrorjsout logger.errorOverride 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.

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.

TierDeclared on route?In every *Result?Production
BuiltinNo (merged at route)Yesreturn err.NOT_FOUND() etc.
DomainYes (errors: defineErrors({ … }))Only that routereturn registerErr.USER_EXISTS(…)
CodeTypical HTTP statusSource
VALIDATION_ERROR400mountSpec (input validation)
UNAUTHORIZED401mountSpec (missing/invalid auth)
ROUTE_NOT_FOUND404mountSpec (unknown RPC method)
NOT_FOUND404resolver (return err.NOT_FOUND())
FORBIDDEN403resolver or middleware
CONFLICT409resolver
TOO_MANY_REQUESTS429rate-limit middleware
SERVICE_UNAVAILABLE503resolver or middleware
INTERNAL_ERROR500mountSpec (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.

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:

  1. Sending the response (RouteFailure.status or mountSpec defaults)
  2. OpenAPI documentation (grouping schemas by status)
  3. 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 preddata always present on validated domain failures; builtins like VALIDATION_ERROR and ROUTE_NOT_FOUND require wire payloads when typed
  • Optional pred (p.optional(...)) — { code } alone is valid; include data only when the wire payload validates (TOO_MANY_REQUESTS, NOT_FOUND, etc.)
  • No pred — no data property

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.

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.

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).

  1. Exact callspec JSON{ error: "CODE", data? } (and errors on VALIDATION_ERROR). Builtin codes and route-declared domain codes map to typed failures when the wire shape validates. Domain payloads are checked against callspec.json schemas (codegen passes domainErrors). An { error } field that fails validation or is undeclared becomes UNKNOWN_ERROR (preserves raw body).
  2. Exact body phrases — case-insensitive literals such as Unauthorized, Forbidden, Bad Gateway, Service Unavailable.
  3. 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.
  4. 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.
  5. UNKNOWN_ERROR (client-only, not in callspec.json) — { code: 'UNKNOWN_ERROR', data: { body, headers? } }. body is the raw parsed response (string or JSON) for operator debugging; headers are response headers when present. Do not show UNKNOWN_ERROR.data to 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).

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 are UNKNOWN_ERROR; transport failures are NETWORK_ERROR.