Guide/guides

Error Handling

Guren provides multiple layers of error handling, from a centralized ExceptionHandler to controller-level exception catching. Built on Hono's robust error handling primitives, you can customize how errors are displayed to users in both development and production.

Error Handling

Guren provides multiple layers of error handling, from a centralized ExceptionHandler to controller-level exception catching. Built on Hono's robust error handling primitives, you can customize how errors are displayed to users in both development and production.

ExceptionHandler

The ExceptionHandler class provides a centralized place to handle all exceptions thrown by your application. Register it as a service provider or configure it during application boot:

import { ExceptionHandler } from '@guren/core'

class AppExceptionHandler extends ExceptionHandler {
  // Exceptions that should not be reported to your logging system
  dontReport = [
    'ValidationException',
    'AuthenticationException',
  ]

  // Register custom exception reporting
  register(): void {
    this.reportable('DatabaseException', (error) => {
      // Send to external error tracking service
      errorTracker.captureException(error)
    })
  }

  // Customize the response for specific exceptions
  render(error: Error, ctx: Context): Response {
    if (error instanceof ValidationException) {
      return ctx.json({
        message: 'Validation failed',
        errors: error.errors,
      }, 422)
    }

    return super.render(error, ctx)
  }
}

Global Error Handler

Register a global error handler using Hono's onError method on the underlying app:

Register handlers in src/app.ts right after createApp(...), or inside the boot(hono) callback. Make sure it is set before app.boot().

import { createApp } from '@guren/core'
import { HTTPException } from 'hono/http-exception'

const app = createApp()

app.hono.onError((error, ctx) => {
  console.error('Unhandled error:', error)

  // Handle HTTP exceptions
  if (error instanceof HTTPException) {
    return ctx.json({
      error: error.message,
      status: error.status,
    }, error.status)
  }

  // Handle all other errors
  const isDev = process.env.NODE_ENV !== 'production'

  return ctx.json({
    error: isDev ? error.message : 'Internal Server Error',
    ...(isDev && { stack: error.stack }),
  }, 500)
})

Built-in Exception Classes

Guren provides typed exception classes for common HTTP error scenarios:

import {
  HttpException,
  NotFoundHttpException,
  AuthenticationException,
  AuthorizationException,
  ValidationException,
  MethodNotAllowedException,
} from '@guren/core'
import { ModelNotFoundException } from '@guren/orm'

// 404 Not Found
throw new NotFoundHttpException('Post not found')

// 404 Not Found (thrown automatically by Model.findOrFail())
throw new ModelNotFoundException('Post', 42)

// 401 Unauthorized (thrown automatically by auth.userOrFail())
throw new AuthenticationException('Authentication required')

// 403 Forbidden
throw new AuthorizationException('You do not have permission to edit this post')

// 422 Unprocessable Entity (thrown automatically by validateBody/Query/Params)
throw new ValidationException({ title: ['Title is required'] })

// 405 Method Not Allowed
throw new MethodNotAllowedException('GET method is not allowed for this endpoint')

// Generic HTTP exception with custom status
throw new HttpException(429, 'Rate limit exceeded')

Duck-typed statusCode

The ExceptionHandler supports any error with a numeric statusCode property, not just HttpException subclasses. This means ModelNotFoundException (from @guren/orm, which has statusCode: 404) is automatically rendered as a 404 response without any extra configuration. For errors with statusCode >= 500, the message is hidden in production (replaced with "Internal Server Error").

HTTP Exceptions

Throw HTTPException to return specific HTTP status codes:

import { HTTPException } from 'hono/http-exception'
import { Router } from '@guren/core'

const router = new Router()

router.get('/posts/:id', async (ctx) => {
  const post = await Post.find(ctx.req.param('id'))

  if (!post) {
    throw new HTTPException(404, { message: 'Post not found' })
  }

  return ctx.json({ post })
})

Common HTTP exceptions:

// 400 Bad Request
throw new HTTPException(400, { message: 'Invalid request data' })

// 401 Unauthorized
throw new HTTPException(401, { message: 'Authentication required' })

// 403 Forbidden
throw new HTTPException(403, { message: 'Access denied' })

// 404 Not Found
throw new HTTPException(404, { message: 'Resource not found' })

// 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: 'Server error' })

Not Found Handler

Customize 404 responses:

app.hono.notFound((ctx) => {
  // Return JSON for API requests
  if (ctx.req.header('Accept')?.includes('application/json')) {
    return ctx.json({ error: 'Not found' }, 404)
  }

  // Return HTML for browser requests
  return ctx.html('<h1>Page Not Found</h1>', 404)
})

Debug Error Page

In development, Guren renders a detailed error page when an unhandled exception occurs. The debug page includes:

  • The exception message and class name
  • A full stack trace with source file links
  • The request details (method, URL, headers)
  • Environment and route information

The debug page is enabled automatically when NODE_ENV is not set to production. To customize its behavior:

import { createApp } from '@guren/core'

const app = createApp({
  debug: process.env.NODE_ENV !== 'production',
})

In production, the debug page is replaced with a generic error response that does not expose sensitive information. You can customize the production error page by overriding the render() method in your ExceptionHandler.

Controller Error Handling

With validateBody/validateQuery/validateParams, findOrFail, and userOrFail, most error handling is automatic — no try-catch needed:

import { Controller } from '@guren/core'
import { z } from 'zod'
import { PostResource } from '@/app/Http/Resources/PostResource'
import { pages } from '@/.guren/pages.gen'

const StorePostSchema = z.object({ title: z.string().min(1), content: z.string().min(10) })
const PostIdSchema = z.object({ id: z.coerce.number().int().positive() })

export default class PostController extends Controller {
  async store(): Promise<Response> {
    const data = await this.validateBody(StorePostSchema)  // 422 on failure
    const user = await this.auth.userOrFail()              // 401 if not logged in
    const post = await Post.create({ ...data, authorId: user.id })
    return this.redirect(`/posts/${post.id}`)
  }

  async show(): Promise<Response> {
    const { id } = this.validateParams(PostIdSchema)       // 422 on invalid params
    const post = await Post.findOrFail(id)                  // 404 if missing
    return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
  }
}

The ExceptionHandler catches and renders all thrown exceptions automatically. Use try-catch only when you need custom error recovery logic within a specific controller method.

Validation Errors

Use formatValidationErrors to convert Zod errors to a flat object:

import { formatValidationErrors } from '@guren/core'
import { z } from 'zod'

const schema = z.object({
  email: z.email(),
  password: z.string().min(8),
})

const result = schema.safeParse(data)

if (!result.success) {
  const errors = formatValidationErrors(result.error)
  // { email: 'Invalid email', password: 'String must contain at least 8 character(s)' }
}

With a fallback message:

const errors = formatValidationErrors(result.error, 'Please check your input')

Error Middleware

Create reusable error handling middleware:

import { defineMiddleware } from '@guren/core'
import { HTTPException } from 'hono/http-exception'

export const errorHandler = defineMiddleware(async (ctx, next) => {
  try {
    await next()
  } catch (error) {
    if (error instanceof HTTPException) {
      throw error // Let the global handler deal with HTTP exceptions
    }

    // Log unexpected errors
    console.error('Unexpected error:', error)

    // Return a generic error response
    return ctx.json({
      error: 'Something went wrong',
    }, 500)
  }
})

// Register globally
app.use('*', errorHandler)

Inertia Error Pages

For Inertia applications, render error components:

// Global error handler for Inertia
app.hono.onError(async (error, ctx) => {
  const isInertia = ctx.req.header('X-Inertia') === 'true'

  if (isInertia) {
    const status = error instanceof HTTPException ? error.status : 500

    // Return Inertia error page
    return ctx.json({
      component: 'Error',
      props: {
        status,
        message: error.message,
      },
      url: ctx.req.path,
    }, status)
  }

  // Non-Inertia error handling
  return ctx.json({ error: error.message }, 500)
})

React error component:

// resources/pages/Error.tsx
export default function Error({ status, message }: { status: number; message: string }) {
  const titles: Record<number, string> = {
    404: 'Page Not Found',
    403: 'Forbidden',
    500: 'Server Error',
    503: 'Service Unavailable',
  }

  return (
    <div className="error-page">
      <h1>{status}</h1>
      <h2>{titles[status] ?? 'Error'}</h2>
      <p>{message}</p>
      <a href="/">Go Home</a>
    </div>
  )
}

Database Errors

Use Model.findOrFail() instead of manual null-checking. It throws ModelNotFoundException (404) automatically:

// Before — manual null check
async show(): Promise<Response> {
  const post = await Post.find(Number(this.request.param('id')))
  if (!post) {
    throw new HTTPException(404, { message: 'Post not found' })
  }
  return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}

// After — automatic 404
async show(): Promise<Response> {
  const { id } = this.validateParams(PostIdSchema)
  const post = await Post.findOrFail(id)  // throws ModelNotFoundException (404)
  return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}

Async Error Boundaries

Wrap async operations with error boundaries:

async function withErrorBoundary<T>(
  operation: () => Promise<T>,
  fallback: T,
  onError?: (error: unknown) => void
): Promise<T> {
  try {
    return await operation()
  } catch (error) {
    onError?.(error)
    return fallback
  }
}

// Usage
const posts = await withErrorBoundary(
  () => Post.all(),
  [],
  (error) => console.error('Failed to fetch posts:', error)
)

Development vs Production

Customize error output based on environment:

app.hono.onError((error, ctx) => {
  const isDev = process.env.NODE_ENV !== 'production'

  if (isDev) {
    // Show full error in development (or use the built-in debug page)
    return ctx.json({
      error: error.message,
      stack: error.stack,
      name: error.name,
    }, 500)
  }

  // Hide details in production
  return ctx.json({
    error: 'An unexpected error occurred',
    requestId: ctx.get('requestId'), // If using request ID middleware
  }, 500)
})

Best Practices

  1. Use the ExceptionHandler - Centralize error handling logic instead of scattering try-catch blocks.
  2. Always catch async errors - Unhandled promise rejections can crash your server.
  3. Log errors with context - Include request ID, user ID, and relevant data.
  4. Use appropriate status codes - 4xx for client errors, 5xx for server errors.
  5. Don't expose sensitive data - Hide stack traces and internal details in production.
  6. Provide user-friendly messages - Technical errors should be translated to helpful messages.
  7. Monitor errors - Use error tracking services in production.
  8. Use built-in exceptions - Prefer NotFoundHttpException, AuthorizationException, etc. over generic HTTPException.