16 KiB
16 KiB
Error Handling
Centralized error handling with HTTPException and onError.
HTTPException
Throw typed HTTP errors with status codes and optional metadata.
Basic Usage
import { HTTPException } from 'hono/http-exception';
app.get('/users/:id', (c) => {
const user = findUser(c.req.param('id'));
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({ user });
});
With Cause
app.get('/users/:id', (c) => {
const id = c.req.param('id');
const user = findUser(id);
if (!user) {
throw new HTTPException(404, {
message: 'User not found',
cause: { userId: id, timestamp: Date.now() }
});
}
return c.json({ user });
});
Status Codes
// 400 Bad Request
throw new HTTPException(400, { message: 'Invalid request' });
// 401 Unauthorized
throw new HTTPException(401, { message: 'Missing or invalid token' });
// 403 Forbidden
throw new HTTPException(403, { message: 'Insufficient permissions' });
// 404 Not Found
throw new HTTPException(404, { message: 'Resource not found' });
// 409 Conflict
throw new HTTPException(409, { message: 'Email already registered' });
// 422 Unprocessable Entity
throw new HTTPException(422, { message: 'Validation failed' });
// 429 Too Many Requests
throw new HTTPException(429, { message: 'Rate limit exceeded' });
// 500 Internal Server Error
throw new HTTPException(500, { message: 'Internal server error' });
// 503 Service Unavailable
throw new HTTPException(503, { message: 'Service temporarily unavailable' });
Custom Error Classes
Extend HTTPException for domain-specific errors.
Common Error Classes
import { HTTPException } from 'hono/http-exception';
export class ValidationError extends HTTPException {
constructor(message: string, issues?: Record<string, string>) {
super(400, {
message,
cause: issues,
});
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}
export class ForbiddenError extends HTTPException {
constructor(message = 'Forbidden') {
super(403, { message });
}
}
export class NotFoundError extends HTTPException {
constructor(resource: string, id?: string) {
super(404, {
message: `${resource} not found`,
cause: id ? { [resource.toLowerCase() + 'Id']: id } : undefined,
});
}
}
export class ConflictError extends HTTPException {
constructor(message: string, details?: Record<string, any>) {
super(409, {
message,
cause: details,
});
}
}
export class RateLimitError extends HTTPException {
constructor(retryAfter: number) {
super(429, {
message: 'Too many requests',
cause: { retryAfter },
});
}
}
Usage
// Not found
app.get('/posts/:id', (c) => {
const post = findPost(c.req.param('id'));
if (!post) {
throw new NotFoundError('Post', c.req.param('id'));
}
return c.json({ post });
});
// Unauthorized
app.use('/api/*', (c, next) => {
const token = c.req.header('authorization');
if (!token) {
throw new UnauthorizedError('Missing authorization header');
}
return next();
});
// Forbidden
app.delete('/posts/:id', (c) => {
const user = c.get('user');
const post = findPost(c.req.param('id'));
if (post.authorId !== user.id && user.role !== 'admin') {
throw new ForbiddenError('You can only delete your own posts');
}
deletePost(post.id);
return c.json({ deleted: true });
});
// Conflict
app.post('/users', async (c) => {
const { email } = await c.req.json();
const existing = findUserByEmail(email);
if (existing) {
throw new ConflictError('Email already registered', { email });
}
const user = createUser({ email });
return c.json({ user }, 201);
});
Centralized Error Handler
Use onError to handle all errors in one place.
Basic Handler
import { HTTPException } from 'hono/http-exception';
import { ZodError } from 'zod';
app.onError((err, c) => {
console.error('Error:', err);
// HTTPException (includes custom classes)
if (err instanceof HTTPException) {
return c.json({
error: err.message,
...(err.cause && { details: err.cause })
}, err.status);
}
// Zod validation errors
if (err instanceof ZodError) {
return c.json({
error: 'Validation failed',
issues: err.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
}))
}, 400);
}
// Generic errors
return c.json({
error: 'Internal server error'
}, 500);
});
Production-Safe Handler
app.onError((err, c) => {
const isDev = Bun.env.NODE_ENV !== 'production';
// Log error
console.error('Error:', {
message: err.message,
stack: err.stack,
path: c.req.path,
method: c.req.method,
});
// HTTPException
if (err instanceof HTTPException) {
return c.json({
error: err.message,
...(err.cause && { details: err.cause })
}, err.status);
}
// Zod validation
if (err instanceof ZodError) {
return c.json({
error: 'Validation failed',
issues: err.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
}))
}, 400);
}
// Generic errors — sanitize in production
return c.json({
error: isDev ? err.message : 'Internal server error',
...(isDev && { stack: err.stack })
}, 500);
});
Structured Error Logging
interface ErrorLog {
timestamp: string;
level: 'error' | 'warn';
message: string;
stack?: string;
context: {
path: string;
method: string;
headers?: Record<string, string>;
user?: string;
};
}
app.onError((err, c) => {
const log: ErrorLog = {
timestamp: new Date().toISOString(),
level: err instanceof HTTPException && err.status < 500 ? 'warn' : 'error',
message: err.message,
stack: err.stack,
context: {
path: c.req.path,
method: c.req.method,
user: c.get('user')?.id,
},
};
// Log to external service (e.g., Sentry, LogRocket)
if (log.level === 'error') {
logToExternalService(log);
} else {
console.warn(JSON.stringify(log));
}
// Return response
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
return c.json({ error: 'Internal server error' }, 500);
});
Validation Errors
Handle Zod validation errors with detailed messages.
Basic Zod Error Handling
import { zValidator } from '@hono/zod-validator';
import { ZodError, z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
content: z.string().min(1, 'Content is required'),
tags: z.array(z.string()).max(5, 'Maximum 5 tags allowed'),
});
app.post('/posts', zValidator('json', CreatePostSchema), (c) => {
const data = c.req.valid('json');
// Data is validated
return c.json({ post: createPost(data) }, 201);
});
// Handle validation errors in onError
app.onError((err, c) => {
if (err instanceof ZodError) {
return c.json({
error: 'Validation failed',
issues: err.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
}, 400);
}
// Other errors...
});
Custom Validation Messages
const EmailSchema = z.object({
email: z.string()
.email('Invalid email address')
.refine(
(email) => email.endsWith('@example.com'),
'Email must be from example.com domain'
),
});
app.post('/validate-email', zValidator('json', EmailSchema), (c) => {
const { email } = c.req.valid('json');
return c.json({ valid: true, email });
});
Field-Level Error Formatting
app.onError((err, c) => {
if (err instanceof ZodError) {
// Group errors by field
const fieldErrors: Record<string, string[]> = {};
for (const issue of err.issues) {
const field = issue.path.join('.');
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(issue.message);
}
return c.json({
error: 'Validation failed',
fields: fieldErrors,
}, 400);
}
// Other errors...
});
// Example response:
// {
// "error": "Validation failed",
// "fields": {
// "email": ["Invalid email address"],
// "password": ["Password must be at least 8 characters"],
// "tags": ["Maximum 5 tags allowed"]
// }
// }
Not Found Handler
Handle 404 errors for undefined routes.
app.notFound((c) => {
return c.json({
error: 'Not found',
path: c.req.path,
}, 404);
});
Error Recovery
Graceful Degradation
app.get('/data', async (c) => {
try {
// Try primary data source
const data = await fetchFromPrimaryAPI();
return c.json({ data, source: 'primary' });
} catch (primaryErr) {
console.warn('Primary API failed, trying backup:', primaryErr);
try {
// Fall back to secondary source
const data = await fetchFromBackupAPI();
return c.json({ data, source: 'backup' });
} catch (backupErr) {
console.error('Both APIs failed:', backupErr);
// Return cached data if available
const cached = getCachedData();
if (cached) {
return c.json({ data: cached, source: 'cache' });
}
throw new HTTPException(503, {
message: 'Service temporarily unavailable',
});
}
}
});
Retry Logic
async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (attempt === maxRetries) {
throw err;
}
console.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Retry logic failed');
}
app.get('/external-data', async (c) => {
try {
const data = await retryOperation(() => fetchExternalAPI());
return c.json({ data });
} catch (err) {
throw new HTTPException(503, {
message: 'External service unavailable',
});
}
});
Circuit Breaker
class CircuitBreaker {
private failures = 0;
private lastFailure = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold = 5,
private timeout = 60000 // 1 minute
) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.lastFailure > this.timeout) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker is open');
}
}
try {
const result = await operation();
if (this.state === 'half-open') {
this.state = 'closed';
this.failures = 0;
}
return result;
} catch (err) {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = 'open';
}
throw err;
}
}
}
const apiCircuitBreaker = new CircuitBreaker();
app.get('/api/data', async (c) => {
try {
const data = await apiCircuitBreaker.execute(() => fetchExternalAPI());
return c.json({ data });
} catch (err) {
if (err.message === 'Circuit breaker is open') {
throw new HTTPException(503, {
message: 'Service temporarily unavailable',
});
}
throw err;
}
});
Database Error Handling
SQLite Errors
app.post('/users', async (c) => {
const { email, name } = await c.req.json();
const db = c.get('db');
try {
const user = db.query(
'INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'
).get(crypto.randomUUID(), email, name);
return c.json({ user }, 201);
} catch (err: any) {
// SQLite unique constraint violation
if (err.message.includes('UNIQUE constraint failed')) {
throw new ConflictError('Email already registered', { email });
}
// SQLite foreign key constraint
if (err.message.includes('FOREIGN KEY constraint failed')) {
throw new ValidationError('Invalid reference');
}
// Generic database error
console.error('Database error:', err);
throw new HTTPException(500, { message: 'Database error' });
}
});
Transaction Rollback
app.post('/transfer', async (c) => {
const { fromId, toId, amount } = await c.req.json();
const db = c.get('db');
try {
db.transaction(() => {
// Deduct from sender
const sender = db.query(
'UPDATE accounts SET balance = balance - ? WHERE id = ? RETURNING balance'
).get(amount, fromId);
if (!sender || sender.balance < 0) {
throw new ValidationError('Insufficient funds');
}
// Add to recipient
db.query(
'UPDATE accounts SET balance = balance + ? WHERE id = ?'
).run(amount, toId);
})();
return c.json({ success: true });
} catch (err) {
if (err instanceof ValidationError) {
throw err;
}
console.error('Transfer failed:', err);
throw new HTTPException(500, { message: 'Transfer failed' });
}
});
Async Error Handling
Promise Rejection
// ❌ Unhandled promise rejection
app.get('/data', (c) => {
fetchData().then(data => {
// This won't work — response already sent
return c.json({ data });
});
return c.json({ loading: true }); // Wrong!
});
// ✅ Await async operations
app.get('/data', async (c) => {
const data = await fetchData();
return c.json({ data });
});
// ✅ Explicit error handling
app.get('/data', async (c) => {
try {
const data = await fetchData();
return c.json({ data });
} catch (err) {
throw new HTTPException(500, { message: 'Failed to fetch data' });
}
});
Parallel Operations
app.get('/dashboard', async (c) => {
try {
const [user, posts, stats] = await Promise.all([
fetchUser(c.get('user').id),
fetchUserPosts(c.get('user').id),
fetchUserStats(c.get('user').id),
]);
return c.json({ user, posts, stats });
} catch (err) {
console.error('Dashboard fetch failed:', err);
throw new HTTPException(500, { message: 'Failed to load dashboard' });
}
});
Timeout Handling
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error('Operation timed out')), timeoutMs)
),
]);
}
app.get('/slow-api', async (c) => {
try {
const data = await withTimeout(fetchSlowAPI(), 5000); // 5s timeout
return c.json({ data });
} catch (err) {
if (err.message === 'Operation timed out') {
throw new HTTPException(504, { message: 'Gateway timeout' });
}
throw err;
}
});
Error Response Format
Consistent Structure
interface ErrorResponse {
error: string;
details?: Record<string, any>;
timestamp?: string;
requestId?: string;
}
app.onError((err, c) => {
const response: ErrorResponse = {
error: err.message,
timestamp: new Date().toISOString(),
requestId: c.get('requestId'),
};
if (err instanceof HTTPException && err.cause) {
response.details = err.cause;
}
const status = err instanceof HTTPException ? err.status : 500;
return c.json(response, status);
});
API-Specific Formats
// JSON:API format
app.onError((err, c) => {
return c.json({
errors: [{
status: err instanceof HTTPException ? err.status.toString() : '500',
title: err.message,
detail: err instanceof HTTPException ? err.cause : undefined,
}]
}, err instanceof HTTPException ? err.status : 500);
});
// RFC 7807 Problem Details
app.onError((err, c) => {
return c.json({
type: 'about:blank',
title: err.message,
status: err instanceof HTTPException ? err.status : 500,
detail: err instanceof HTTPException ? JSON.stringify(err.cause) : undefined,
instance: c.req.path,
}, err instanceof HTTPException ? err.status : 500);
});