Spraxium logoSpraxium

Exceptions and Error Handling

Model predictable HTTP failures with the HttpError hierarchy and validation details. Keep error responses stable across controllers, middleware, and bridge-backed endpoints.

Why explicit exceptions matter

HTTP clients depend on stable status codes and stable response shapes. Throwing explicit HttpError classes makes failures observable, testable, and easier to monitor in production. Every error class exported by the package carries a semantic status code and a default message, so the server can normalize responses without conditional logic in each handler.

When controllers return generic errors, clients lose semantic context and retry behavior becomes fragile. The package avoids this by giving each failure scenario a dedicated class that extends the same base, ensuring consistent serialization across the entire API surface.

HttpError base class

Every error in the hierarchy extends HttpError, which itself extends the native Error class. It adds a read-only statusCode property and overrides the constructor to accept a message and a numeric HTTP status. This shape is the only contract the server normalization layer reads, so any class that extends HttpError participates automatically in centralized error handling without additional wiring.

The base class also sets this.name to the concrete class name, which makes stack traces and monitoring dashboards immediately distinguishable between error types. Subclasses only need to call super(message, statusCode) with their own defaults to maintain that contract.

Error hierarchy from package exports

ValidationError and structured details

ValidationError is the only error class that carries more than a message. It includes a details array where each entry maps a property name to the constraints that failed. This structure is produced automatically by ValidationPipe when body payload validation runs through class-validator rules.

The ValidationDetail interface exposes two fields: property (the DTO field name) and constraints (a record of failed rule names to human-readable messages). Because the pipeline uses whitelist: true and forbidNonWhitelisted: true, any unexpected field in the body also triggers a validation error, which prevents payload pollution from reaching business logic.

Clients receive a predictable shape where details is always an array, even for a single invalid field. This lets API consumers iterate the array and map each entry to inline form errors or error summaries without branching on response shape.

Controller exception patterns

import {
  HttpBody,
  HttpController,
  HttpGet,
  HttpParam,
  HttpPatch,
  HttpStatus,
} from '@spraxium/http';
import {
  NotFoundError,
  ForbiddenError,
  ConflictError,
  ServiceUnavailableError,
} from '@spraxium/http';
import { UpdateGuildConfigDto } from '../dto/update-guild-config.dto';

@HttpController('/guilds')
export class GuildHttpController {
  @HttpGet('/:guildId')
  async getGuild(@HttpParam('guildId') guildId: string) {
    const guild = await this.findGuild(guildId);

    if (!guild) {
      throw new NotFoundError('Guild not found');
    }

    if (!guild.available) {
      throw new ServiceUnavailableError('Guild is temporarily unavailable');
    }

    return { ok: true, guildId: guild.id, name: guild.name };

  }

  @HttpPatch('/:guildId/config')
  @HttpStatus(200)
  async updateConfig(
    @HttpParam('guildId') guildId: string,
    @HttpBody(UpdateGuildConfigDto) body: UpdateGuildConfigDto,
  ) {
    const guild = await this.findGuild(guildId);
    if (!guild) throw new NotFoundError('Guild not found');

    if (guild.locked) {
      throw new ForbiddenError('Guild configuration is locked by an admin');
    }

    if (guild.prefix === body.prefix) {
      throw new ConflictError('Prefix is already set to that value');
    }

    return { ok: true, guildId, applied: body };

  }

  private async findGuild(\_guildId: string) {
    return null;
  }
}

Server-level normalization behavior

HttpServer registers two terminal handlers at the end of the Hono pipeline: notFound and onError. Together they guarantee that every possible exit path produces a JSON response with the same shape.

The notFound handler fires for any request that does not match a registered route and returns { ok: false, error: 'Not Found' } with status 404. The onError handler covers thrown exceptions using this precedence:

  1. ValidationError returns status 400 with { ok: false, error: 'Validation failed', details: [...] };
  2. any other HttpError subclass returns its own status code with { ok: false, error: message };
  3. unknown exceptions are logged and returned as { ok: false, error: 'Internal server error' } with status 500.

Because this mapping is centralized, controllers should throw the most specific error class available and never build ad-hoc JSON error payloads in handler code. The normalization layer handles the rest.

Response shape contract

Every error response follows the same envelope regardless of status code. The ok field is always false, the error field carries the message string, and details appears only on ValidationError responses. This lets API consumers check ok first and then branch on error or details without special casing per endpoint.

ScenarioStatusResponse body
Unmatched route404Object with ok: false and error Not Found
DTO validation failure400Object with ok: false, error Validation failed, and a details list with property and constraints
Any HttpError subclassVariesObject with ok: false and the class default or custom message
Unknown exception500Object with ok: false and error Internal server error

Custom error classes

You can create your own error classes by extending HttpError or any existing subclass. The only requirement is calling super with a message and status code. Any custom class that extends HttpError is automatically caught and serialized by the server normalization layer without changes to middleware or configuration.

import { ForbiddenError } from '@spraxium/http';

export class GuildLockedError extends ForbiddenError {
  constructor(guildId: string) {
    super(`Guild ${guildId} is locked and cannot be modified`);
    this.name = 'GuildLockedError';
  }
}

Guard rejection as error boundary

GuardExecutor runs global guards sequentially before any controller handler. When a guard returns false, the executor short-circuits execution and returns { ok: false, error: 'Unauthorized' } with status 401. This response is injected directly, not through the onError handler, which means guard rejections are final and cannot be caught or modified by downstream middleware.

Because guard behavior is outside the HttpError normalization path, guard implementations should keep their rejection semantics simple. The 401 response shape matches the same envelope as all other errors, so clients do not need special handling for guard failures.

Exception design checklist

Throw semantic errors close to business rules

Raise specific HttpError classes where domain decisions are made, not only at the controller edge. Throwing a ConflictError inside service logic is clearer than returning a flag and converting it to an error in the handler later.

Keep messages actionable and stable

Error messages should help clients and operators understand next actions without leaking sensitive internals. Stable wording also improves alert deduplication and dashboard quality over time.

Use DTO validation for shape correctness

Let ValidationPipe and class-validator enforce structural guarantees before domain logic executes. The pipeline uses whitelist: true and forbidNonWhitelisted: true by default, which means unexpected properties are rejected automatically.

Prefer custom subclasses over raw HttpError

Create subclasses with descriptive names like GuildLockedError or BridgeUnavailableError instead of throwing raw ForbiddenError with a string. Named classes are easier to search in logs, test in assertions, and filter in monitoring dashboards.

Rely on centralized response mapping

Allow HttpServer to normalize errors into JSON consistently instead of building ad-hoc error payloads in each handler. Unified mapping is easier to test and reason about across teams and reduces the surface area for accidental response shape drift.

Never expose internal error details to clients

Unknown exceptions are caught by the normalization layer and returned as a generic 500 response. The original error message is only logged server-side. Do not override this behavior to forward raw stack traces or internal messages, as that leaks implementation details to external consumers.