Skip to content

Exception Filters

In complex applications, error handling is often a source of duplicated code and inconsistent behavior. Without a centralized strategy, developers tend to wrap every method in try-catch blocks, manually formatting error responses (sometimes JSON, sometimes text, sometimes crashing).

Karin adopts the "Let it Crash" philosophy (within the scope of a request). Developers should focus on the happy path. If an error occurs (Database down, Invalid ID, Permission denied), the code should simply throw an exception.

Exception Filters form the global safety net. They are responsible for catching these thrown exceptions, analyzing them, and converting them into a standardized, user-friendly HTTP response.

Concept: Crisis Management

The Exception Filter is the Crisis Management Team.

  • If the Chef drops a pan or the stove catches fire (Exception Thrown), the customer shouldn't see the panic.
  • The Team intercepts the disaster and calmly walks out to the table to apologize and offer a coupon (Standard JSON Error Response).

The Safety Net Architecture

When an exception is thrown in Karin—by a Guard, a Pipe, a Controller, or a Service—it bubbles up the stack. If no explicit try-catch handles it, it eventually hits the Global Exception Layer.

By default, Karin provides a basic filter that returns a generic 500 or 400 error. However, for a professional API, you want full control over the error shape. You want to ensure that every error, whether it's a 404 Not Found or a 500 SQL failure, returns a consistent JSON structure (e.g., { success: false, code: ... }). This allows frontend clients to implement a single error parsing strategy.

Creating a Global "Catch-All" Filter

A global filter implements the ExceptionFilter interface. The @Catch() decorator with no arguments indicates this filter should capture every exception type.

typescript
import { Catch, ExceptionFilter, ArgumentsHost, HttpException, Logger } from "@project-karin/core";

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private logger = new Logger("ExceptionFilter");

  catch(exception: unknown, host: ArgumentsHost) {
    // 1. Context Switching
    // Filters can run over HTTP, VoIP, etc. We must assert HTTP context.
    const ctx = host.switchToHttp();
    const request = ctx.getRequest();

    // 2. Exception Analysis
    // We need to distinguish between "expected" errors (HttpException)
    // and "unexpected" crashes (unknown/System errors).
    let status = 500;
    let message = "Internal Server Error";

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      message = exception.getResponse();
    }

    // 3. Logging Strategy
    // We generally ignore 4xx errors (client faults) to keep logs clean.
    // But 500s (server faults) are critical and must be logged with stacks.
    if (status === 500) {
      this.logger.error(`Critical Failure on ${request.url}`, exception);
    }

    // 4. Response Standardization
    // This payload schema constitutes your API's Error Contract.
    const responseBody = {
      success: false,
      statusCode: status,
      path: request.url,
      timestamp: new Date().toISOString(),
      error: message,
    };

    // 5. Transmission
    // Return a standard Web API Response.
    return new Response(JSON.stringify(responseBody), {
      status: status,
      headers: { "Content-Type": "application/json" }
    });
  }
}

Hierarchy and Precedence

Not all errors are equal. Sometimes you want specialized handling for specific errors. For example, if a Zod validation fails, you don't just want a generic "Bad Request"; you want to show exactly which fields failed.

Karin allows you to register multiple filters. Specificity determines precedence.

The Specialized Filter (Zod)

We create a filter decorated with @Catch(ZodError). This tells Karin: "If the error is an instance of ZodError, use ME, not the global one."

typescript
import { ZodError } from "zod";

@Catch(ZodError)
export class ValidationFilter implements ExceptionFilter {
  catch(exception: ZodError, host: ArgumentsHost) {
    // Transform Zod's complex internal error structure into a simple list
    const issues = exception.errors.map(err => ({
      field: err.path.join('.'),
      message: err.message
    }));

    return new Response(JSON.stringify({
      statusCode: 400,
      error: "Validation Failure",
      details: issues // Detailed breakdown for the frontend
    }), { status: 400 });
  }
}

The Specialized Filter (Rate Limiting)

Similarly, for throttling, we can intercept the specific exception to add retry headers.

typescript
@Catch(ThrottlerException)
export class ThrottlingFilter implements ExceptionFilter {
  catch(exception: ThrottlerException, host: ArgumentsHost) {
    return new Response(JSON.stringify({
      statusCode: 429,
      error: "Too Many Requests",
      message: "Please slow down."
    }), { 
      status: 429,
      headers: { "Retry-After": "60" } 
    });
  }
}

Global Registration

To ensure strict hierarchy, filters are typically registered in the application bootstrap. When multiple filters are registered, Karin usually evaluates them based on specificity or registration order (specific filters should be registered to ensure they trap their target exceptions before a catch-all sees them).

typescript
// main.ts
const app = await KarinFactory.create(adapter, {
  globalFilters: [
    new GlobalExceptionFilter(),   // The Catch-All (Fallback)
    new ValidationFilter(),        // Specialized (Zod)
    new ThrottlingFilter(),        // Specialized (Rate Limit)
  ]
});

By organizing filters this way, you decouple error handling logic from your business logic, keeping your services and controllers pristine and focused on the "Happy Path."


Next Steps

Now that you've mastered the HTTP request/response pipeline, learn how to handle persistent, real-time connections with WebSockets.

Released under the MIT License.