Skip to main content

Error handling

Throw errors anywhere in a route, datasource or middleware. The global error handler that createApp installs turns them into the error envelope, logs them with the request id, runs your reporter and masks anything unsafe in production.

ApiError

import {ApiError, ErrorCategory, ErrorSeverity} from '@ltv/cwb';

throw new ApiError('Quota exceeded', {
code: 'QUOTA_EXCEEDED',
status: 402,
category: ErrorCategory.BUSINESS_LOGIC,
severity: ErrorSeverity.MEDIUM,
details: {limit: 100},
cause: originalError, // an Error, for the logged cause chain
});
PropertyDefaultDescription
idgenerated CUID2Returned to the client as error.id and logged
codeAPI_ERRORMachine-readable code
status400HTTP status
categoryunknownOne of ErrorCategory
severitymediumOne of ErrorSeverity
detailsundefinedExtra data; see the masking rules below
causeundefinedThe underlying Error

toJSON() returns ApiErrorJSON (id, name, code, message, status, category, severity, details) and never includes the stack. The stack stays on error.stack and reaches logs and reporters.

ApiError is recognized by a brand (Symbol.for('@ltv/cwb/ApiError')), so an ApiError created by a second installed copy of the library still keeps its status. ApiError.isApiError(value) performs the same check; instanceof works within one copy.

Factories

StatusFactoryCodeDefault message
400badRequest(message?, details?)BAD_REQUESTInvalid request
400businessLogicError(message, code?, details?)BUSINESS_LOGIC_ERROR(required)
401unauthorized(message?, details?)UNAUTHORIZEDAuthentication required
401invalidCredentials(details?)INVALID_CREDENTIALSInvalid credentials provided
401tokenExpired(details?)TOKEN_EXPIREDAuthentication token has expired
403forbidden(message?, details?)FORBIDDENAccess denied
403insufficientPermissions(details?)INSUFFICIENT_PERMISSIONSInsufficient permissions for this action
404notFound(resource?, details?)NOT_FOUNDResource not found (notFound('User') gives User not found)
409conflict(message?, details?)CONFLICTResource conflict
409duplicateEntry(resource?, details?)DUPLICATE_ENTRYResource already exists
422validationError(message?, details?)VALIDATION_ERRORValidation failed
429rateLimited(message?, details?)RATE_LIMITEDToo many requests
500internalError(message?, details?, cause?)INTERNAL_ERRORInternal server error
500databaseError(message?, details?, cause?)DATABASE_ERRORDatabase operation failed
503serviceUnavailable(service?, details?)SERVICE_UNAVAILABLEService is currently unavailable
504timeout(operation?, details?)TIMEOUTOperation timed out

Converting and checking errors

  • ApiError.fromError(value, defaultMessage?, options?) wraps anything:
    • an ApiError (from any copy) is returned unchanged;
    • a Hono HTTPException keeps its status and gets a matching code (UNAUTHORIZED, PAYLOAD_TOO_LARGE, ...);
    • any other Error becomes INTERNAL_ERROR (500);
    • a non-Error value becomes UNKNOWN_ERROR (500).
  • Type guards: ApiError.isApiError(err), ApiError.is(err, code), ApiError.isCategory(err, category), ApiError.isSeverity(err, severity).
try {
await chargeCard(order);
} catch (err) {
if (ApiError.is(err, 'CARD_DECLINED')) return c.respondConflict('Card declined');
throw ApiError.fromError(err, 'Payment failed');
}

What clients see

Production is the default. Only NODE_ENV values development, dev, test or local (case-insensitive) count as non-production. The handler reads c.env.NODE_ENV first, then the Worker env, so staging, a typo or an empty value are all production.

Thrown errorMessage in productiondetails in production
ApiError, Hono HTTPException or chanfana error with a 4xx statusits messageincluded
Any other error that a transformer maps to a 4xx statusthe transformer's message, or Request failed without onethe transformer's details, if any
Any error with a 5xx status, including unmapped errors (500)Internal server erroromitted

In non-production environments (or with includeDetails: true), original messages and details are returned for every error. Stack traces are never returned to clients.

details are always logged

includeDetails controls responses only. ErrorInfo.details always reaches logs, customLogger and errorReporter, without redaction (only headers and query parameters are redacted). Never put secrets or tokens in details.

Configuring the handler

createApp({errors}) passes options to createErrorHandler. The same options work for a plain Hono app with app.onError(createErrorHandler(options)); errorHandler is a ready-made instance with default options.

OptionDefaultDescription
logErrorstrueLog errors through pino, or customLogger
includeStackTracetrueAdd stack traces (thrown error and cause chain) to ErrorInfo; logs and reports only
includeDetailsnot productionExpose original messages and details to clients, including 5xx
customLogger(info)noneReplaces the built-in pino logging; may be async
errorReporter(info)noneRuns in the background through executionCtx.waitUntil; its failures never affect the response
excludeHeaders[]Extra header names to redact in ErrorInfo.context
excludeQueryParams[]Extra query parameter names to redact
context.includeClientInfotruefalse omits ip and userAgent and redacts the client IP and user-agent headers
maxMessageLength1000Truncate messages (adds )
classifier(error, c)noneReturns {category, severity} to override the classification
transformer(error, c)noneFor errors that are not ApiErrors: returns ApiErrorOptions plus an optional client-safe message
rateLimit.maxErrorsPerMinute100Above this rate, logging and reporting of non-critical 4xx errors are throttled
rateLimit.throttleDurationSeconds60How long throttling lasts

A classifier, transformer, customLogger or errorReporter that throws is logged and ignored, so hooks can't break the error response.

Map database errors with a transformer

Raw D1 or driver messages never reach clients. A transformer can map known failures to a proper status and a safe message:

src/index.ts
createApp({
errors: {
transformer: (error) =>
String(error).includes('UNIQUE constraint failed')
? {status: 409, code: 'DUPLICATE', message: 'Resource already exists'}
: {},
},
// ...
});

Report errors

errorReporter receives ErrorInfo: id, code, message (original, unmasked), status, category, severity, timestamp, details, stack, cause (serialized chain, at most 5 levels) and context (requestId, userId, tenantId, method, path, ip, userAgent, redacted headers and query).

src/index.ts
import {createApp, env, type ErrorInfo} from '@ltv/cwb';

const webhookUrl = env.default.string('ERROR_WEBHOOK_URL', '');

async function reportError(info: ErrorInfo): Promise<void> {
if (!webhookUrl || info.status < 500) return;
// with an SDK such as Sentry, you would capture `info` here instead
await fetch(webhookUrl, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({
id: info.id,
code: info.code,
message: info.message,
requestId: info.context.requestId,
path: info.context.path,
stack: info.stack,
}),
});
}

const app = createApp({
errors: {errorReporter: reportError},
// ...
});

The reporter runs in the background and is kept alive with waitUntil, so a slow or failing webhook never delays or breaks the response.

Logging and throttling

  • Without customLogger, 5xx errors are logged at error level (Server error) and 4xx at warn (Client error), through the request-scoped logger, so each line carries requestId.
  • Throttling never hides server errors. During error storms only non-critical 4xx logs and reports are throttled. 5xx and critical errors are always logged and reported, and still count toward the rate. Responses are never throttled.
  • After a throttle window ends, the next error logs a warning: N errors suppressed by error log throttling.
  • Metrics and throttling state are per Worker isolate (ErrorMetricsTracker), not global.

When you create the handler yourself, getMetrics() returns ErrorMetrics: {total, byCategory, bySeverity, byStatusCode, rate} (rate is errors in the last minute).

import {Hono} from 'hono';
import {contextHelpers, createErrorHandler} from '@ltv/cwb';

const handler = createErrorHandler({excludeHeaders: ['x-internal-token']});
const app = new Hono();
app.onError(handler);
app.use('*', contextHelpers);
app.get('/internal/error-metrics', (c) => c.respond(handler.getMetrics()));

createApp does not expose the handler it creates; use customLogger or errorReporter for observability there.

Redaction

Headers and query parameters in ErrorInfo.context (and in logger req serialization) are redacted to [REDACTED]:

  • Headers, exact names: authorization, proxy-authorization, cookie, set-cookie, x-api-key, x-auth-token, x-csrf-token, cf-access-jwt-assertion, cf-access-client-secret, ocp-apim-subscription-key, x-functions-key.
  • Query parameters, exact names: key, code, sig, auth, authorization, access_key, private_key, pwd, pass, otp, client_assertion.
  • Name fragments (headers and query parameters): any name containing token, secret, password, passwd, signature, credential, session, jwt, apikey, api_key, api-key, cookie, hmac, private, cert, saml, assertion, auth_code or authcode. For example X-Amz-Signature, page_token and SAMLResponse.

Add names for one handler with excludeHeaders / excludeQueryParams, or app-wide (logger included) at module scope:

import {addSensitiveHeaders, addSensitiveQueryParams} from '@ltv/cwb';

addSensitiveHeaders('x-tenant-secret-key');
addSensitiveQueryParams('invite');

To keep personal data out of error context, set errors: {context: {includeClientInfo: false}}. It omits ip and userAgent and redacts cf-connecting-ip, cf-connecting-ipv6, true-client-ip, x-forwarded-for, x-real-ip and user-agent.

chanfana and Hono errors

  • chanfana request validation (and a thrown InputValidationException) becomes 422 VALIDATION_ERROR with details: [{field: 'body.email', message, code: '7001'}], matching jsonValidationError. This works with the default fromHono(app) and with passthroughErrors: true.
  • Other chanfana ApiExceptions, such as NotFoundException, keep their status and message in the envelope.
  • Hono HTTPExceptions keep their status and a matching code. If the exception carries its own response (for example basicAuth with a WWW-Authenticate header), that response is returned as-is after logging.
  • Non-Error throws (throw 'x') produce a 500 envelope and a log line instead of escaping app.fetch.