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
});
| Property | Default | Description |
|---|---|---|
id | generated CUID2 | Returned to the client as error.id and logged |
code | API_ERROR | Machine-readable code |
status | 400 | HTTP status |
category | unknown | One of ErrorCategory |
severity | medium | One of ErrorSeverity |
details | undefined | Extra data; see the masking rules below |
cause | undefined | The 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
| Status | Factory | Code | Default message |
|---|---|---|---|
| 400 | badRequest(message?, details?) | BAD_REQUEST | Invalid request |
| 400 | businessLogicError(message, code?, details?) | BUSINESS_LOGIC_ERROR | (required) |
| 401 | unauthorized(message?, details?) | UNAUTHORIZED | Authentication required |
| 401 | invalidCredentials(details?) | INVALID_CREDENTIALS | Invalid credentials provided |
| 401 | tokenExpired(details?) | TOKEN_EXPIRED | Authentication token has expired |
| 403 | forbidden(message?, details?) | FORBIDDEN | Access denied |
| 403 | insufficientPermissions(details?) | INSUFFICIENT_PERMISSIONS | Insufficient permissions for this action |
| 404 | notFound(resource?, details?) | NOT_FOUND | Resource not found (notFound('User') gives User not found) |
| 409 | conflict(message?, details?) | CONFLICT | Resource conflict |
| 409 | duplicateEntry(resource?, details?) | DUPLICATE_ENTRY | Resource already exists |
| 422 | validationError(message?, details?) | VALIDATION_ERROR | Validation failed |
| 429 | rateLimited(message?, details?) | RATE_LIMITED | Too many requests |
| 500 | internalError(message?, details?, cause?) | INTERNAL_ERROR | Internal server error |
| 500 | databaseError(message?, details?, cause?) | DATABASE_ERROR | Database operation failed |
| 503 | serviceUnavailable(service?, details?) | SERVICE_UNAVAILABLE | Service is currently unavailable |
| 504 | timeout(operation?, details?) | TIMEOUT | Operation timed out |
Converting and checking errors
ApiError.fromError(value, defaultMessage?, options?)wraps anything:- an
ApiError(from any copy) is returned unchanged; - a Hono
HTTPExceptionkeeps its status and gets a matching code (UNAUTHORIZED,PAYLOAD_TOO_LARGE, ...); - any other
ErrorbecomesINTERNAL_ERROR(500); - a non-Error value becomes
UNKNOWN_ERROR(500).
- an
- 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 error | Message in production | details in production |
|---|---|---|
ApiError, Hono HTTPException or chanfana error with a 4xx status | its message | included |
Any other error that a transformer maps to a 4xx status | the transformer's message, or Request failed without one | the transformer's details, if any |
| Any error with a 5xx status, including unmapped errors (500) | Internal server error | omitted |
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 loggedincludeDetails 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.
| Option | Default | Description |
|---|---|---|
logErrors | true | Log errors through pino, or customLogger |
includeStackTrace | true | Add stack traces (thrown error and cause chain) to ErrorInfo; logs and reports only |
includeDetails | not production | Expose original messages and details to clients, including 5xx |
customLogger(info) | none | Replaces the built-in pino logging; may be async |
errorReporter(info) | none | Runs 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.includeClientInfo | true | false omits ip and userAgent and redacts the client IP and user-agent headers |
maxMessageLength | 1000 | Truncate messages (adds …) |
classifier(error, c) | none | Returns {category, severity} to override the classification |
transformer(error, c) | none | For errors that are not ApiErrors: returns ApiErrorOptions plus an optional client-safe message |
rateLimit.maxErrorsPerMinute | 100 | Above this rate, logging and reporting of non-critical 4xx errors are throttled |
rateLimit.throttleDurationSeconds | 60 | How 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:
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).
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 aterrorlevel (Server error) and 4xx atwarn(Client error), through the request-scoped logger, so each line carriesrequestId. - Throttling never hides server errors. During error storms only non-critical 4xx logs and reports are throttled. 5xx and
criticalerrors 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_codeorauthcode. For exampleX-Amz-Signature,page_tokenandSAMLResponse.
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 422VALIDATION_ERRORwithdetails: [{field: 'body.email', message, code: '7001'}], matchingjsonValidationError. This works with the defaultfromHono(app)and withpassthroughErrors: true. - Other chanfana
ApiExceptions, such asNotFoundException, 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 examplebasicAuthwith aWWW-Authenticateheader), that response is returned as-is after logging. - Non-Error throws (
throw 'x') produce a 500 envelope and a log line instead of escapingapp.fetch.