Error Handling

Throw typed HTTP exceptions, register global reporters, customise error pages, and control per-exception rendering and reporting.

Introduction

Any exception thrown inside a handler or middleware is caught by tekir's built-in error pipeline. You do not need try/catch everywhere: just throw and let tekir handle the response.

// When anything throws inside a handler or middleware, tekir:
//
// 1. Calls exception.handle() if it exists (per-exception override)
// 2. Runs configured reporters (logging, Sentry, etc.)
// 3. Calls exception.report() if it exists (per-exception override)
// 4. Renders a status page if one is registered AND the client accepts HTML
// 5. Falls back to a JSON error response:
//    { error: { message, code, statusCode } }

HttpException Classes

HttpException (base class)

All tekir exceptions extend HttpException, which is exported from @tekir/core. It stores a status code, an error code string, and an optional details payload. Its toJSON() method produces the standard error envelope.

import { HttpException } from '@tekir/core'

// Constructor signature:
// new HttpException(message, statusCode, code?, details?)

const err = new HttpException('Resource locked', 423, 'LOCKED', { lockedBy: 'admin' })

err.message    // 'Resource locked'
err.statusCode // 423
err.code       // 'LOCKED'
err.details    // { lockedBy: 'admin' }

err.toJSON()
// {
//   error: {
//     message:    'Resource locked',
//     code:       'LOCKED',
//     statusCode: 423,
//     details:    { lockedBy: 'admin' }
//   }
// }

Built-in Exceptions

tekir ships a typed exception for every common HTTP error. Import whichever you need from @tekir/core:

import {
  BadRequestException,         // 400 BAD_REQUEST
  UnauthorizedException,       // 401 UNAUTHORIZED
  PaymentRequiredException,    // 402 PAYMENT_REQUIRED
  ForbiddenException,          // 403 FORBIDDEN
  NotFoundException,           // 404 NOT_FOUND
  MethodNotAllowedException,   // 405 METHOD_NOT_ALLOWED
  NotAcceptableException,      // 406 NOT_ACCEPTABLE
  RequestTimeoutException,     // 408 REQUEST_TIMEOUT
  ConflictException,           // 409 CONFLICT
  GoneException,               // 410 GONE
  PreconditionFailedException, // 412 PRECONDITION_FAILED
  PayloadTooLargeException,    // 413 PAYLOAD_TOO_LARGE
  UnsupportedMediaTypeException, // 415 UNSUPPORTED_MEDIA_TYPE
  UnprocessableEntityException, // 422 UNPROCESSABLE_ENTITY
  TooManyRequestsException,    // 429 TOO_MANY_REQUESTS
  InternalServerException,     // 500 INTERNAL_SERVER_ERROR
  NotImplementedException,     // 501 NOT_IMPLEMENTED
  BadGatewayException,         // 502 BAD_GATEWAY
  ServiceUnavailableException, // 503 SERVICE_UNAVAILABLE
  GatewayTimeoutException     // 504 GATEWAY_TIMEOUT
} from '@tekir/core'

// All constructors take (message?, details?)
throw new NotFoundException('Post not found')
throw new ConflictException('Email already in use', { field: 'email' })
throw new TooManyRequestsException('Slow down', 60)  // retryAfter seconds

Throwing Exceptions

Throw from anywhere: a handler, a service function, a model method, or middleware. tekir catches it and converts it to the appropriate HTTP response.

import { NotFoundException, ForbiddenException } from '@tekir/core'
import type { HttpContext } from '@tekir/core'
import { db } from '#services'

export async function show({ params, store, response }: HttpContext) {
  const post = await db.queryOne('SELECT * FROM posts WHERE id = ?', [params.id])

  // Throwing works from anywhere: handler, service, model method
  if (!post) throw new NotFoundException('Post not found')

  if (post.user_id !== store.user?.id) {
    throw new ForbiddenException('You do not own this post')
  }

  return response.ok(post)
}

Custom Exceptions

Create a subclass of HttpException in core/exceptions/ to represent domain-specific errors with their own status code, error code, and extra properties.

core/exceptions/resource_not_found_exception.ts
import { HttpException } from '@tekir/core'

export class ResourceNotFoundException extends HttpException {
  public readonly resource: string
  public readonly resourceId: string | number

  constructor(resource: string, id: string | number) {
    super(
      `${resource} with id ${id} was not found`,
      404,
      'RESOURCE_NOT_FOUND',
      { resource, id }
    )
    this.resource   = resource
    this.resourceId = id
  }
}

// Usage
throw new ResourceNotFoundException('User', 42)
// → HTTP 404, { error: { message: 'User with id 42 was not found', code: 'RESOURCE_NOT_FOUND', ... } }

Per-exception handle()

Add a handle(error, ctx) method to a custom exception class and tekir will call it instead of its default JSON response. This is the right place for exceptions that need to render HTML or return a non-standard body shape.

core/exceptions/payment_wall_exception.ts
import { HttpException } from '@tekir/core'
import type { HttpContext } from '@tekir/core'

export class PaymentWallException extends HttpException {
  constructor(public readonly planRequired: string) {
    super('Upgrade your plan to access this feature', 402, 'PAYMENT_WALL')
  }

  // If this method exists, tekir calls it instead of the default JSON response.
  // You have full control: return a Response, an HTML string, or a plain object.
  async handle(error: this, ctx: HttpContext) {
    // If the client wants HTML, render a page
    if (ctx.request.accepts(['text/html'])) {
      return ctx.response.html(
        `<h1>Upgrade Required</h1><p>You need the <b>${error.planRequired}</b> plan.</p>`
      )
    }
    // Otherwise fall back to JSON
    return ctx.response.paymentRequired({
      message:     error.message,
      planRequired: error.planRequired
    })
  }
}

Per-exception report()

Add a report(error, ctx) method to trigger exception-specific alerting (page an on-call engineer, send a Slack message) in addition to the global reporters registered on the ExceptionHandler.

core/exceptions/critical_exception.ts
import { HttpException } from '@tekir/core'
import type { HttpContext } from '@tekir/core'

export class CriticalException extends HttpException {
  constructor(message: string, details?: any) {
    super(message, 500, 'CRITICAL', details)
  }

  // Called by tekir AFTER the ExceptionHandler's global reporters.
  // Use this to add exception-specific alerting (pagerduty, email, etc.)
  async report(error: this, ctx: HttpContext) {
    await alertOpsTeam({
      message: error.message,
      path:    ctx.request.path,
      details: error.details
    })
  }
}

ExceptionHandler

The ExceptionHandler is the global orchestrator for all unhandled errors. You configure it in start/boot.ts (or anywhere before the server starts).

start/boot.ts
import type { TekirApp } from '@tekir/core'

export default function({ server }: TekirApp) {
  const handler = server.getExceptionHandler()

  // Silence 404 and 401: no logs, no Sentry events
  handler.ignoreStatuses(404, 401)

  // Silence specific error codes
  handler.ignoreCodes('VALIDATION_ERROR', 'NOT_FOUND')

  // Register a global reporter: runs for every unignored exception
  handler.report(async (error, ctx) => {
    logger.error({
      message:    error.message,
      statusCode: (error as any).statusCode ?? 500,
      path:       ctx.request?.path,
      stack:      error.stack
    })
  })

  // Send to Sentry
  handler.report(async (error, ctx) => {
    Sentry.captureException(error, {
      extra: { path: ctx.request?.path }
    })
  })
}

ignoreStatuses(...codes)

Suppress reporting for exceptions whose statusCode matches any of the given values. 404s and 401s are typically not actionable; silence them to keep your logs clean.

const handler = server.getExceptionHandler()

// These statuses will not be passed to any reporter
handler.ignoreStatuses(404, 422, 401)

ignoreCodes(...codes)

Suppress reporting for exceptions with specific error codes. Useful for silencing validation errors (VALIDATION_ERROR) that flood logs with user-input noise.

const handler = server.getExceptionHandler()

// Exceptions with these codes will not be reported
handler.ignoreCodes('VALIDATION_ERROR', 'NOT_FOUND', 'UNAUTHORIZED')

report(reporter)

Register one or more global reporter functions. Each receives the error and the HttpContext. Reporters are called for every exception not silenced by ignoreStatuses or ignoreCodes. Register as many as you need.

const handler = server.getExceptionHandler()

handler.report(async (error, ctx) => {
  // error : the thrown Error (may be HttpException or a plain Error)
  // ctx   : HttpContext for the failed request
  await myLogger.error({
    message: error.message,
    code:    (error as any).code,
    path:    ctx.request?.path,
    stack:   error.stack
  })
})

// Register as many reporters as you like: all are called in order
handler.report(sentryReporter)
handler.report(slackAlerter)

statusPages(pages)

Register custom HTML pages for specific status codes or ranges. Pages are only served when the request's Accept header includes text/html (browsers). JSON API clients always receive the standard JSON error envelope.

Keys can be an exact code ("404") or a range in the form "min..max" ("500..599"). Exact matches take priority.

import type { TekirApp } from '@tekir/core'

export default function({ server }: TekirApp) {
  const handler = server.getExceptionHandler()

  handler.statusPages({
    // Exact status code
    '404': (ctx) => {
      return ctx.response.html(`<h1>404, Page not found</h1>`)
    },

    // Range: covers 400–499
    '400..499': (ctx, error) => {
      return ctx.response.status(error.statusCode).html(
        `<h1>${error.statusCode} Error</h1><p>${error.message}</p>`
      )
    },

    // Range: covers 500–599 (server errors)
    '500..599': (ctx, error) => {
      return ctx.response.status(500).html(
        `<h1>Something went wrong</h1><p>We have been notified.</p>`
      )
    }
  })

  // Status pages are only rendered when the request Accept header includes text/html.
  // JSON API clients receive the normal JSON error response.
}

Accessing the ExceptionHandler

Retrieve the singleton ExceptionHandler from the server instance:

// In start/boot.ts or start/kernel.ts
import type { TekirApp } from '@tekir/core'

export default function({ server }: TekirApp) {
  const handler = server.getExceptionHandler()

  // Chain calls
  handler
    .ignoreStatuses(404, 401, 422)
    .report(logger.error.bind(logger))
    .statusPages({
      '404': (ctx) => ctx.response.html('<h1>Not Found</h1>')
    })
}

Debug Mode

In development, tekir includes the full stack trace in the error response so you can debug without leaving your HTTP client. In production, generic errors show only a safe message with no internal details.

// config/app.ts
import env from '#env'

export default {
  debug: env.NODE_ENV === 'development'
}

// In production, generic errors return:
// { error: { message: 'Internal Server Error', code: 'INTERNAL_SERVER_ERROR', statusCode: 500 } }

// In debug mode, the full stack trace is included:
// { error: { message: '...', code: '...', statusCode: 500, stack: 'Error: ...' } }

// Debug mode is enabled automatically when you call server.configure({ development: true })
// which the framework does when NODE_ENV=development