Skip to main content
Learn how to consistently handle errors in APIs and provide clear responses.

Error Handling Overview

Consistent Responses

Standardized error formatClient-friendly

Status Codes

HTTP status codesRESTful standard

Clear Messages

Developer-friendlyUser-friendly

Logging

Error trackingDebugging support

Basic Error Handling

throw Error

class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const rdb = this.getPuri("r");
    
    const user = await rdb
      .table("users")
      .where("id", id)
      .first();
    
    if (!user) {
      // Default error (HTTP 500)
      throw new Error("User not found");
    }
    
    return user;
  }
}

Setting Status Code with Context

class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");
    
    const user = await rdb
      .table("users")
      .where("id", id)
      .first();
    
    if (!user) {
      // 404 Not Found
      context.reply.status(404);
      throw new Error("User not found");
    }
    
    return user;
  }
}

HTTP Status Codes

Common Status Codes

class ApiModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async handleRequest(params: any): Promise<any> {
    const context = Sonamu.getContext();
    
    // 400 Bad Request - Invalid request
    if (!params.required) {
      context.reply.status(400);
      throw new Error("Required field missing");
    }
    
    // 401 Unauthorized - Authentication required
    if (!context.user) {
      context.reply.status(401);
      throw new Error("Authentication required");
    }
    
    // 403 Forbidden - No permission
    if (context.user.role !== "admin") {
      context.reply.status(403);
      throw new Error("Admin permission required");
    }
    
    // 404 Not Found - Resource not found
    const resource = await this.findResource(params.id);
    if (!resource) {
      context.reply.status(404);
      throw new Error("Resource not found");
    }
    
    // 409 Conflict - Conflict
    const existing = await this.checkDuplicate(params.email);
    if (existing) {
      context.reply.status(409);
      throw new Error("Email already exists");
    }
    
    // 429 Too Many Requests - Rate limited
    if (await this.isRateLimited(context.user.id)) {
      context.reply.status(429);
      throw new Error("Too many requests");
    }
    
    // 422 Unprocessable Entity - Validation failed
    const validationError = this.validate(params);
    if (validationError) {
      context.reply.status(422);
      throw new Error(`Validation failed: ${validationError}`);
    }
    
    // 500 Internal Server Error - Server error (default)
    try {
      return await this.process(params);
    } catch (error) {
      context.reply.status(500);
      throw new Error("Internal server error");
    }
  }
}

Status Code Reference

CodeNameWhen to Use
200OKSuccess (GET, PUT)
201CreatedResource created (POST)
204No ContentSuccess, no response (DELETE)
400Bad RequestInvalid request format
401UnauthorizedAuthentication required
403ForbiddenNo permission
404Not FoundResource not found
409ConflictDuplicate, conflict
422Unprocessable EntityValidation failed
429Too Many RequestsRate limited
500Internal Server ErrorInternal server error

Structured Error Responses

Error Response Format

interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: any;
  };
  timestamp: Date;
  requestId?: string;
}

Custom Error Class

// errors/api-error.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any
  ) {
    super(message);
    this.name = "ApiError";
  }
  
  toJSON(): ErrorResponse {
    return {
      error: {
        code: this.code,
        message: this.message,
        details: this.details,
      },
      timestamp: new Date(),
    };
  }
}

// Convenience methods
export class BadRequestError extends ApiError {
  constructor(message: string, details?: any) {
    super(400, "BAD_REQUEST", message, details);
  }
}

export class UnauthorizedError extends ApiError {
  constructor(message: string = "Authentication required") {
    super(401, "UNAUTHORIZED", message);
  }
}

export class ForbiddenError extends ApiError {
  constructor(message: string = "Permission denied") {
    super(403, "FORBIDDEN", message);
  }
}

export class NotFoundError extends ApiError {
  constructor(resource: string) {
    super(404, "NOT_FOUND", `${resource} not found`);
  }
}

export class ConflictError extends ApiError {
  constructor(message: string, details?: any) {
    super(409, "CONFLICT", message, details);
  }
}

export class ValidationError extends ApiError {
  constructor(message: string, details?: any) {
    super(422, "VALIDATION_ERROR", message, details);
  }
}

Usage Example

import {
  NotFoundError,
  UnauthorizedError,
  ForbiddenError,
  ConflictError,
  ValidationError,
} from "./errors/api-error";

class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const rdb = this.getPuri("r");
    
    const user = await rdb
      .table("users")
      .where("id", id)
      .first();
    
    if (!user) {
      throw new NotFoundError("User");
    }
    
    return user;
  }
  
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    const context = Sonamu.getContext();
    
    // Check authentication
    if (!context.user) {
      throw new UnauthorizedError();
    }
    
    // Check permission
    if (context.user.role !== "admin") {
      throw new ForbiddenError("Admin permission required");
    }
    
    const rdb = this.getPuri("r");
    
    // Check duplicate
    const existing = await rdb
      .table("users")
      .where("email", params.email)
      .first();
    
    if (existing) {
      throw new ConflictError("Email already exists", {
        field: "email",
        value: params.email,
      });
    }
    
    // Validation
    if (params.password.length < 8) {
      throw new ValidationError("Password too short", {
        field: "password",
        minLength: 8,
      });
    }
    
    const wdb = this.getPuri("w");
    const [user] = await wdb
      .table("users")
      .insert(params)
      .returning({ id: "id" });
    
    return { userId: user.id };
  }
}

Zod Validation Error Handling

Converting Zod Errors

import { z } from "zod";
import { ValidationError } from "./errors/api-error";

function handleZodError(error: z.ZodError): never {
  const details = error.errors.map((e) => ({
    field: e.path.join("."),
    message: e.message,
    code: e.code,
  }));
  
  throw new ValidationError(
    "Validation failed",
    details
  );
}

// Usage
class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    try {
      const validated = CreateUserSchema.parse(params);
      
      // Create logic
      // ...
    } catch (error) {
      if (error instanceof z.ZodError) {
        handleZodError(error);
      }
      throw error;
    }
  }
}

Using safeParse

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    const result = CreateUserSchema.safeParse(params);
    
    if (!result.success) {
      const details = result.error.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      }));
      
      throw new ValidationError("Validation failed", details);
    }
    
    const validated = result.data;
    
    // Create logic
    // ...
  }
}

Error Handler Middleware

Global Error Handler

// middleware/error-handler.ts
import { FastifyError, FastifyRequest, FastifyReply } from "fastify";
import { ApiError } from "../errors/api-error";
import { ZodError } from "zod";

export async function errorHandler(
  error: FastifyError | Error,
  request: FastifyRequest,
  reply: FastifyReply
) {
  console.error("Error:", error);
  
  // ApiError
  if (error instanceof ApiError) {
    return reply.status(error.statusCode).send(error.toJSON());
  }
  
  // Zod Validation Error
  if (error instanceof ZodError) {
    const details = error.errors.map((e) => ({
      field: e.path.join("."),
      message: e.message,
    }));
    
    return reply.status(422).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Validation failed",
        details,
      },
      timestamp: new Date(),
    });
  }
  
  // Database Error
  if (error.message.includes("violates foreign key constraint")) {
    return reply.status(400).send({
      error: {
        code: "REFERENCE_ERROR",
        message: "Referenced resource does not exist",
      },
      timestamp: new Date(),
    });
  }
  
  if (error.message.includes("duplicate key value")) {
    return reply.status(409).send({
      error: {
        code: "DUPLICATE_ERROR",
        message: "Resource already exists",
      },
      timestamp: new Date(),
    });
  }
  
  // Default error
  const statusCode = (error as any).statusCode || 500;
  
  return reply.status(statusCode).send({
    error: {
      code: "INTERNAL_ERROR",
      message:
        process.env.NODE_ENV === "production"
          ? "Internal server error"
          : error.message,
    },
    timestamp: new Date(),
  });
}

// server.ts
import fastify from "fastify";
import { errorHandler } from "./middleware/error-handler";

const app = fastify();

app.setErrorHandler(errorHandler);

Error Logging

Logging Service

// services/logger.service.ts
export class Logger {
  static error(
    error: Error,
    context?: {
      userId?: number;
      requestId?: string;
      endpoint?: string;
      params?: any;
    }
  ): void {
    console.error({
      timestamp: new Date(),
      level: "ERROR",
      message: error.message,
      stack: error.stack,
      context,
    });
    
    // Send to external logging service (optional)
    // Sentry, CloudWatch, etc.
  }
  
  static warn(message: string, context?: any): void {
    console.warn({
      timestamp: new Date(),
      level: "WARN",
      message,
      context,
    });
  }
  
  static info(message: string, context?: any): void {
    console.log({
      timestamp: new Date(),
      level: "INFO",
      message,
      context,
    });
  }
}

// Usage
class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    try {
      const wdb = this.getPuri("w");
      const [user] = await wdb
        .table("users")
        .insert(params)
        .returning({ id: "id" });
      
      Logger.info("User created", { userId: user.id });
      
      return { userId: user.id };
    } catch (error) {
      const context = Sonamu.getContext();
      
      Logger.error(error as Error, {
        userId: context.user?.id,
        requestId: context.requestId,
        endpoint: context.request.url,
        params,
      });
      
      throw error;
    }
  }
}

Retryable Errors

export class RetryableError extends ApiError {
  constructor(message: string, public retryAfter?: number) {
    super(503, "SERVICE_UNAVAILABLE", message);
  }
}

class ExternalApiModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async fetchData(): Promise<any> {
    try {
      const response = await axios.get("https://external-api.com/data", {
        timeout: 5000,
      });
      
      return response.data;
    } catch (error) {
      if (axios.isAxiosError(error)) {
        if (error.code === "ECONNABORTED") {
          throw new RetryableError("Request timeout", 10);
        }
        
        if (error.response?.status === 429) {
          const retryAfter = parseInt(
            error.response.headers["retry-after"] || "60"
          );
          throw new RetryableError("Rate limited", retryAfter);
        }
      }
      
      throw new ApiError(
        502,
        "BAD_GATEWAY",
        "External service unavailable"
      );
    }
  }
}

Internationalized Error Messages

// i18n/errors.ts
const errorMessages: Record<string, Record<string, string>> = {
  ko: {
    USER_NOT_FOUND: "사용자를 찾을 수 없습니다",
    EMAIL_EXISTS: "이미 사용 중인 이메일입니다",
    INVALID_PASSWORD: "비밀번호가 올바르지 않습니다",
    PERMISSION_DENIED: "권한이 없습니다",
  },
  en: {
    USER_NOT_FOUND: "User not found",
    EMAIL_EXISTS: "Email already exists",
    INVALID_PASSWORD: "Invalid password",
    PERMISSION_DENIED: "Permission denied",
  },
};

export function getErrorMessage(code: string, locale: string = "en"): string {
  return errorMessages[locale]?.[code] || code;
}

// Usage
class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");
    
    const user = await rdb.table("users").where("id", id).first();
    
    if (!user) {
      const locale = context.locale || "en";
      throw new NotFoundError(
        getErrorMessage("USER_NOT_FOUND", locale)
      );
    }
    
    return user;
  }
}

Practical Pattern

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    const context = Sonamu.getContext();
    
    try {
      // 1. Check authentication
      if (!context.user) {
        throw new UnauthorizedError();
      }
      
      // 2. Zod validation
      const validated = CreateUserSchema.safeParse(params);
      if (!validated.success) {
        const details = validated.error.errors.map((e) => ({
          field: e.path.join("."),
          message: e.message,
        }));
        throw new ValidationError("Validation failed", details);
      }
      
      const data = validated.data;
      
      // 3. Business rule validation
      const rdb = this.getPuri("r");
      const existing = await rdb
        .table("users")
        .where("email", data.email)
        .first();
      
      if (existing) {
        throw new ConflictError("Email already exists", {
          field: "email",
        });
      }
      
      // 4. Create
      const wdb = this.getPuri("w");
      const [user] = await wdb
        .table("users")
        .insert(data)
        .returning({ id: "id" });
      
      // 5. Logging
      Logger.info("User created", {
        userId: user.id,
        email: data.email,
      });
      
      return { userId: user.id };
    } catch (error) {
      // Error logging
      Logger.error(error as Error, {
        userId: context.user?.id,
        endpoint: context.request.url,
      });
      
      throw error;
    }
  }
}

Cautions

Cautions for error handling:
  1. Never expose sensitive information (stack traces, DB errors, etc.)
  2. Use appropriate HTTP status codes
  3. Provide clear and consistent error messages
  4. Always log errors
  5. Limit detailed information in production

Next Steps