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
Copy
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
Copy
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
Copy
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
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Success (GET, PUT) |
| 201 | Created | Resource created (POST) |
| 204 | No Content | Success, no response (DELETE) |
| 400 | Bad Request | Invalid request format |
| 401 | Unauthorized | Authentication required |
| 403 | Forbidden | No permission |
| 404 | Not Found | Resource not found |
| 409 | Conflict | Duplicate, conflict |
| 422 | Unprocessable Entity | Validation failed |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Internal server error |
Structured Error Responses
Error Response Format
Copy
interface ErrorResponse {
error: {
code: string;
message: string;
details?: any;
};
timestamp: Date;
requestId?: string;
}
Custom Error Class
Copy
// 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
Copy
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
Copy
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
Copy
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
Copy
// 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
Copy
// 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
Copy
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
Copy
// 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
Copy
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:
- Never expose sensitive information (stack traces, DB errors, etc.)
- Use appropriate HTTP status codes
- Provide clear and consistent error messages
- Always log errors
- Limit detailed information in production