1. Latar Belakang
Memasuki minggu keempat, hari keempat belajar Express.js di Perwira Learning Center kita akan membahas Error Handling Terpusat dan Validasi Data. Ini adalah sistem keamanan dan kontrol kualitas dalam aplikasi:
Analogi Pabrik Mobil:
- Validasi Data = Pemeriksaan kualitas bahan baku sebelum masuk jalur produksi
- "Apakah bahan baku sesuai spesifikasi?"
- Cek dimensi, kekerasan, komposisi material
- Tolak bahan yang tidak memenuhi standar
- Error Handling Terpusat = Pusat kendali masalah di pabrik"
- Ada masalah di jalur produksi, bagaimana menanganinya?"
- Klasifikasi error (ringan, sedang, kritis)
- Prosedur penanganan standar untuk setiap jenis error
- Dokumentasi semua insiden
Mengapa Error Handling Terpusat?
┌─────────────────────────────────────────────────────────┐ │ TANPA TERPUSAT │ ├─────────────────────────────────────────────────────────┤ │ │ │ Controller 1: try/catch → res.status(400).json(...) │ │ Controller 2: if(error) → res.status(500).send(...) │ │ Controller 3: throw error → crash! │ │ Controller 4: console.log → res.json({err: true}) │ │ │ │ ❌ INKONSISTEN ❌ TIDAK TERSTRUKTUR ❌ SULIT DEBUG │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ │ DENGAN TERPUSAT │ ├─────────────────────────────────────────────────────────┤ │ │ │ Controller 1: throw new ValidationError(...) │ │ Controller 2: throw new DatabaseError(...) │ │ Controller 3: throw new NotFoundError(...) │ │ Controller 4: throw new AuthError(...) │ │ ↓ │ │ ┌─────────────────────┐ │ │ │ ERROR HANDLER │ │ │ │ TERPUSAT │ │ │ └─────────────────────┘ │ │ ↓ │ │ ✅ KONSISTEN ✅ TERSTRUKTUR ✅ MUDAH DEBUG │ └─────────────────────────────────────────────────────────┘
2. Alat dan Bahan
a. Perangkat Lunak
- Node.js & npm - Runtime dan package manager
- Express.js - Framework utama
- Joi - Schema validation library
- express-validator - Validasi untuk Express
- http-errors - Generate HTTP errors
- celebrate - Joi integration untuk Express
- winston - Logging library
- Postman - Testing error scenarios
b. Perangkat Keras
- Laptop/PC dengan spesifikasi standar
3. Pembahasan
3.1 Arsitektur Error Handling Terpusat
/** * ARSITEKTUR ERROR HANDLING TERPUSAT * * ┌─────────────────┐ * │ REQUEST │ * └────────┬────────┘ * │ * ┌────────▼────────┐ * │ VALIDATION │ ◄────┐ * │ MIDDLEWARE │ │ * └────────┬────────┘ │ * │ │ * ┌────────▼────────┐ │ * │ CONTROLLER │ │ Error * └────────┬────────┘ │ Thrown * │ │ * ┌────────▼────────┐ │ * │ SERVICE │ │ * └────────┬────────┘ │ * │ │ * ┌────────▼────────┐ │ * │ REPOSITORY │ │ * └────────┬────────┘ │ * │ │ * ▼ │ * ┌─────────┐ │ * │ SUCCESS │ │ * └─────────┘ │ * │ │ * ▼ ▼ * ┌─────────────────────────┐ * │ GLOBAL ERROR HANDLER │ * └───────────┬─────────────┘ * │ * ├────► Log Error * ├────► Classify Error * ├────► Format Response * └────► Send Response * * ┌─────────────────────────────────────────────────────┐ * │ FORMAT RESPONSE ERROR │ * ├─────────────────────────────────────────────────────┤ * │ { │ * │ "success": false, │ * │ "error": { │ * │ "code": "VALIDATION_ERROR", │ * │ "message": "Validasi data gagal", │ * │ "details": [...], │ * │ "timestamp": "2026-02-12T10:30:00Z", │ * │ "path": "/api/users", │ * │ "requestId": "req_123456" │ * │ } │ * │ } │ * └─────────────────────────────────────────────────────┘ */
3.2 Praktik Lengkap: Error Handling & Validation System
Mari kita bangun sistem error handling dan validasi yang komprehensif:
# 1. Buat folder project mkdir error-handling-system cd error-handling-system # 2. Inisialisasi project npm init -y # 3. Install dependencies npm install express dotenv cors helmet morgan compression npm install joi celebrate express-validator npm install http-errors boom npm install winston winston-daily-rotate-file npm install uuid npm install -D nodemon # 4. Buat struktur folder mkdir -p src/{errors,middleware,validators,controllers,services,routes,config,utils,models}
A. Custom Error Classes
src/errors/AppError.js
/** * CUSTOM ERROR CLASSES * Base class untuk semua error dalam aplikasi */ class AppError extends Error { constructor(message, statusCode, errorCode, details = null) { super(message); this.name = this.constructor.name; this.statusCode = statusCode || 500; this.errorCode = errorCode || 'INTERNAL_ERROR'; this.details = details; this.timestamp = new Date().toISOString(); this.isOperational = true; // Operational error (vs programming error) Error.captureStackTrace(this, this.constructor); } toJSON() { return { success: false, error: { code: this.errorCode, message: this.message, details: this.details, timestamp: this.timestamp } }; } } // ============= 4xx Client Errors ============= class ValidationError extends AppError { constructor(message = 'Validasi data gagal', details = null) { super(message, 400, 'VALIDATION_ERROR', details); } } class BadRequestError extends AppError { constructor(message = 'Bad request', details = null) { super(message, 400, 'BAD_REQUEST', details); } } class UnauthorizedError extends AppError { constructor(message = 'Unauthorized access', details = null) { super(message, 401, 'UNAUTHORIZED', details); } } class ForbiddenError extends AppError { constructor(message = 'Forbidden access', details = null) { super(message, 403, 'FORBIDDEN', details); } } class NotFoundError extends AppError { constructor(resource = 'Resource', id = null) { const message = id ? `${resource} dengan ID ${id} tidak ditemukan` : `${resource} tidak ditemukan`; super(message, 404, 'NOT_FOUND', { resource, id }); } } class ConflictError extends AppError { constructor(message = 'Resource conflict', details = null) { super(message, 409, 'CONFLICT', details); } } class TooManyRequestsError extends AppError { constructor(message = 'Too many requests', retryAfter = 60) { super(message, 429, 'TOO_MANY_REQUESTS', { retryAfter }); this.retryAfter = retryAfter; } } // ============= 5xx Server Errors ============= class DatabaseError extends AppError { constructor(message = 'Database operation failed', originalError = null) { super(message, 503, 'DATABASE_ERROR', { original: originalError?.message, code: originalError?.code }); } } class ExternalServiceError extends AppError { constructor(service = 'External service', message = 'Service unavailable') { super(`${service}: ${message}`, 502, 'EXTERNAL_SERVICE_ERROR', { service }); } } class InternalServerError extends AppError { constructor(message = 'Internal server error', originalError = null) { super(message, 500, 'INTERNAL_SERVER_ERROR', { original: originalError?.message }); this.isOperational = false; // Programming error } } // ============= Business Logic Errors ============= class BusinessError extends AppError { constructor(message, code = 'BUSINESS_ERROR', statusCode = 422, details = null) { super(message, statusCode, code, details); } } class InsufficientStockError extends BusinessError { constructor(productId, requested, available) { super( `Stok tidak mencukupi untuk produk ${productId}`, 'INSUFFICIENT_STOCK', 422, { productId, requested, available } ); } } class DuplicateEntryError extends BusinessError { constructor(field, value, resource = 'Resource') { super( `${resource} dengan ${field} '${value}' sudah terdaftar`, 'DUPLICATE_ENTRY', 409, { field, value, resource } ); } } module.exports = { AppError, ValidationError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, DatabaseError, ExternalServiceError, InternalServerError, BusinessError, InsufficientStockError, DuplicateEntryError };
B. Error Handler Middleware Terpusat
src/middleware/errorHandler.middleware.js
/** * GLOBAL ERROR HANDLER MIDDLEWARE * Menangani semua error secara terpusat */ const { v4: uuidv4 } = require('uuid'); const logger = require('../utils/logger'); const config = require('../config/env'); const { AppError, ValidationError, DatabaseError, NotFoundError } = require('../errors/AppError'); class ErrorHandlerMiddleware { constructor() { this.isDevelopment = config.NODE_ENV === 'development'; this.isTest = config.NODE_ENV === 'test'; } /** * 404 Not Found Handler * Middleware untuk route yang tidak ditemukan */ notFoundHandler() { return (req, res, next) => { const error = new NotFoundError( 'Route', `${req.method} ${req.originalUrl}` ); next(error); }; } /** * Joi Validation Error Handler * Mengubah error Joi menjadi ValidationError */ joiErrorHandler() { return (err, req, res, next) => { if (err && err.isJoi) { const details = err.details.map(detail => ({ field: detail.path.join('.'), message: detail.message.replace(/"/g, ''), type: detail.type, context: detail.context })); const validationError = new ValidationError( 'Validasi data gagal', details ); return next(validationError); } next(err); }; } /** * Celebrate Validation Error Handler * Untuk error dari library celebrate */ celebrateErrorHandler() { return (err, req, res, next) => { if (err && err.joi) { const details = err.joi.details.map(detail => ({ field: detail.path.join('.'), message: detail.message.replace(/"/g, ''), type: detail.type })); const validationError = new ValidationError( 'Validasi data gagal', details ); return next(validationError); } next(err); }; } /** * MongoDB/Mongoose Error Handler * Mengubah error database menjadi DatabaseError */ databaseErrorHandler() { return (err, req, res, next) => { // MongoDB duplicate key error if (err.code === 11000) { const field = Object.keys(err.keyPattern)[0]; const value = err.keyValue[field]; const duplicateError = new ValidationError( `Duplicate value for ${field}`, [{ field, message: `${field} dengan nilai '${value}' sudah digunakan`, type: 'duplicate_key' }] ); return next(duplicateError); } // MongoDB validation error if (err.name === 'ValidationError') { const details = Object.values(err.errors).map(e => ({ field: e.path, message: e.message, type: e.kind })); const validationError = new ValidationError( 'Database validation failed', details ); return next(validationError); } // MongoDB cast error (invalid ObjectId) if (err.name === 'CastError') { const notFoundError = new NotFoundError( err.model?.modelName || 'Resource', err.value ); return next(notFoundError); } // Other database errors if (err.name === 'MongoError' || err.name === 'MongooseError') { const dbError = new DatabaseError(err.message, err); return next(dbError); } next(err); }; } /** * HTTP Error Handler * Mengubah error dari library http-errors */ httpErrorHandler() { return (err, req, res, next) => { if (err.statusCode) { const error = new AppError( err.message, err.statusCode, err.code || 'HTTP_ERROR', err.details ); return next(error); } next(err); }; } /** * Multer Error Handler * Mengubah error upload file */ multerErrorHandler() { return (err, req, res, next) => { if (err.name === 'MulterError') { let message = 'File upload error'; let code = 'UPLOAD_ERROR'; switch (err.code) { case 'LIMIT_FILE_SIZE': message = 'File terlalu besar'; code = 'FILE_TOO_LARGE'; break; case 'LIMIT_FILE_COUNT': message = 'Terlalu banyak file'; code = 'TOO_MANY_FILES'; break; case 'LIMIT_UNEXPECTED_FILE': message = 'Field file tidak valid'; code = 'INVALID_FILE_FIELD'; break; } const uploadError = new AppError(message, 400, code, { field: err.field, code: err.code }); return next(uploadError); } next(err); }; } /** * Async Error Wrapper * Wrapper untuk async route handlers */ static asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } /** * MAIN ERROR HANDLER * Semua error akan berakhir di sini */ mainErrorHandler() { return (err, req, res, next) => { // Generate request ID if not exists req.requestId = req.requestId || uuidv4(); // Ensure error is AppError instance if (!(err instanceof AppError)) { err = new AppError( err.message || 'Internal server error', err.statusCode || 500, err.code || 'INTERNAL_ERROR', this.isDevelopment ? { stack: err.stack } : null ); } // Log error this.logError(err, req); // Prepare error response const errorResponse = this.formatErrorResponse(err, req); // Send response res.status(err.statusCode || 500).json(errorResponse); }; } /** * Format error response */ formatErrorResponse(err, req) { const response = { success: false, error: { code: err.errorCode || err.code || 'INTERNAL_ERROR', message: err.message, timestamp: err.timestamp || new Date().toISOString(), path: req.originalUrl, method: req.method, requestId: req.requestId } }; // Add details if exists if (err.details) { response.error.details = err.details; } // Add stack trace in development if (this.isDevelopment && err.stack) { response.error.stack = err.stack; response.error.fullError = err; } // Add validation errors specifically if (err instanceof ValidationError && err.details) { response.error.validation = err.details; } // Add retry after for rate limiting if (err.retryAfter) { response.error.retryAfter = err.retryAfter; } return response; } /** * Log error dengan konteks lengkap */ logError(err, req) { const logData = { requestId: req.requestId, userId: req.user?.id, email: req.user?.email, method: req.method, url: req.originalUrl, ip: req.ip || req.connection.remoteAddress, userAgent: req.headers['user-agent'], statusCode: err.statusCode, errorCode: err.errorCode, message: err.message, stack: err.stack, details: err.details }; // Log berdasarkan severity if (err.statusCode >= 500) { logger.error('Server Error:', logData); } else if (err.statusCode >= 400) { logger.warn('Client Error:', logData); } else { logger.info('Error:', logData); } // Log to separate file for errors only if (err.statusCode >= 500) { logger.errorToFile(logData); } } /** * Unhandled Rejection Handler */ static unhandledRejectionHandler() { process.on('unhandledRejection', (reason, promise) => { logger.error('Unhandled Rejection at:', { promise, reason: reason?.stack || reason, timestamp: new Date().toISOString() }); // Don't exit in production, but log extensively if (process.env.NODE_ENV === 'development') { process.exit(1); } }); } /** * Uncaught Exception Handler */ static uncaughtExceptionHandler() { process.on('uncaughtException', (error) => { logger.error('Uncaught Exception:', { error: error.stack || error, timestamp: new Date().toISOString() }); // Always exit on uncaught exception process.exit(1); }); } } module.exports = new ErrorHandlerMiddleware();
C. Advanced Validation System
src/validators/BaseValidator.js
/** * BASE VALIDATOR * Foundation untuk semua validator */ const Joi = require('joi'); class BaseValidator { constructor() { this.joi = Joi; } /** * Common validation patterns */ patterns = { email: Joi.string() .email() .lowercase() .trim() .max(255) .messages({ 'string.email': 'Format email tidak valid', 'string.empty': 'Email wajib diisi', 'string.max': 'Email maksimal 255 karakter' }), password: Joi.string() .min(8) .max(100) .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) .messages({ 'string.min': 'Password minimal 8 karakter', 'string.max': 'Password maksimal 100 karakter', 'string.pattern.base': 'Password harus mengandung huruf besar, huruf kecil, angka, dan karakter khusus', 'string.empty': 'Password wajib diisi' }), name: Joi.string() .min(2) .max(100) .trim() .pattern(/^[a-zA-Z\s'-]+$/) .messages({ 'string.min': 'Nama minimal 2 karakter', 'string.max': 'Nama maksimal 100 karakter', 'string.pattern.base': 'Nama hanya boleh mengandung huruf, spasi, tanda petik, dan strip', 'string.empty': 'Nama wajib diisi' }), phone: Joi.string() .pattern(/^(\+62|62|0)[2-9][0-9]{7,11}$/) .messages({ 'string.pattern.base': 'Format nomor telepon tidak valid', 'string.empty': 'Nomor telepon wajib diisi' }), id: Joi.number() .integer() .positive() .messages({ 'number.base': 'ID harus berupa angka', 'number.integer': 'ID harus bilangan bulat', 'number.positive': 'ID harus positif' }), uuid: Joi.string() .uuid() .messages({ 'string.uuid': 'Format UUID tidak valid', 'string.empty': 'UUID wajib diisi' }), boolean: Joi.boolean() .messages({ 'boolean.base': 'Nilai harus boolean (true/false)' }), date: Joi.date() .iso() .messages({ 'date.base': 'Format tanggal tidak valid', 'date.iso': 'Gunakan format ISO (YYYY-MM-DD)' }), url: Joi.string() .uri() .messages({ 'string.uri': 'Format URL tidak valid', 'string.empty': 'URL wajib diisi' }) }; /** * Pagination validator */ pagination = Joi.object({ page: Joi.number() .integer() .min(1) .default(1) .messages({ 'number.base': 'Page harus berupa angka', 'number.integer': 'Page harus bilangan bulat', 'number.min': 'Page minimal 1' }), limit: Joi.number() .integer() .min(1) .max(100) .default(10) .messages({ 'number.base': 'Limit harus berupa angka', 'number.integer': 'Limit harus bilangan bulat', 'number.min': 'Limit minimal 1', 'number.max': 'Limit maksimal 100' }), sort: Joi.string() .pattern(/^[a-zA-Z0-9_]+:(asc|desc)$/) .default('createdAt:desc') .messages({ 'string.pattern.base': 'Format sort harus "field:asc" atau "field:desc"' }), search: Joi.string() .trim() .max(100) .allow('', null) }); /** * Date range validator */ dateRange = Joi.object({ startDate: Joi.date().iso(), endDate: Joi.date().iso().min(Joi.ref('startDate')), period: Joi.string() .valid('day', 'week', 'month', 'year', 'custom') .messages({ 'any.only': 'Period harus day, week, month, year, atau custom' }) }).custom((value, helpers) => { if (value.period === 'custom' && (!value.startDate || !value.endDate)) { return helpers.error('dateRange.period.custom.required'); } return value; }, 'Date range validation').messages({ 'dateRange.period.custom.required': 'Untuk custom period, startDate dan endDate wajib diisi' }); /** * Base validate method */ validate(schema, data, options = {}) { const { abortEarly = false, stripUnknown = true, allowUnknown = false, convert = true } = options; return schema.validate(data, { abortEarly, stripUnknown, allowUnknown, convert }); } /** * Async validate with custom error handling */ async validateAsync(schema, data, options = {}) { try { const value = await schema.validateAsync(data, { abortEarly: false, stripUnknown: true, ...options }); return { value, error: null }; } catch (error) { return { value: null, error }; } } /** * Create custom validator */ createValidator(validatorFn, message) { return Joi.custom((value, helpers) => { const isValid = validatorFn(value); if (!isValid) { return helpers.error('any.custom', { message }); } return value; }, 'Custom validation'); } } module.exports = BaseValidator;
src/validators/UserValidator.js
/** * USER VALIDATOR * Validasi untuk semua operasi terkait user */ const Joi = require('joi'); const BaseValidator = require('./BaseValidator'); class UserValidator extends BaseValidator { constructor() { super(); } /** * Register validation schema */ register = Joi.object({ email: this.patterns.email.required(), password: this.patterns.password.required(), name: this.patterns.name.required(), phone: this.patterns.phone.optional(), birthDate: this.patterns.date.max('now').optional(), address: Joi.object({ street: Joi.string().max(200).optional(), city: Joi.string().max(100).optional(), province: Joi.string().max(100).optional(), postalCode: Joi.string().pattern(/^\d{5}$/).optional(), country: Joi.string().default('Indonesia') }).optional(), role: Joi.string() .valid('user', 'moderator') .default('user') .messages({ 'any.only': 'Role harus user atau moderator' }) }); /** * Login validation schema */ login = Joi.object({ email: this.patterns.email.required(), password: Joi.string().required().messages({ 'string.empty': 'Password wajib diisi' }), rememberMe: Joi.boolean().default(false) }); /** * Update profile validation schema */ updateProfile = Joi.object({ name: this.patterns.name.optional(), phone: this.patterns.phone.optional(), birthDate: this.patterns.date.max('now').optional(), address: Joi.object({ street: Joi.string().max(200).optional(), city: Joi.string().max(100).optional(), province: Joi.string().max(100).optional(), postalCode: Joi.string().pattern(/^\d{5}$/).optional(), country: Joi.string().optional() }).optional(), avatar: Joi.string().uri().optional() }).min(1).messages({ 'object.min': 'Minimal satu field harus diupdate' }); /** * Change password validation schema */ changePassword = Joi.object({ currentPassword: Joi.string().required().messages({ 'string.empty': 'Password saat ini wajib diisi' }), newPassword: this.patterns.password.required(), confirmPassword: Joi.string() .valid(Joi.ref('newPassword')) .required() .messages({ 'any.only': 'Konfirmasi password tidak cocok', 'string.empty': 'Konfirmasi password wajib diisi' }) }); /** * Forgot password validation schema */ forgotPassword = Joi.object({ email: this.patterns.email.required() }); /** * Reset password validation schema */ resetPassword = Joi.object({ token: Joi.string().required().messages({ 'string.empty': 'Token reset password wajib diisi' }), newPassword: this.patterns.password.required(), confirmPassword: Joi.string() .valid(Joi.ref('newPassword')) .required() .messages({ 'any.only': 'Konfirmasi password tidak cocok', 'string.empty': 'Konfirmasi password wajib diisi' }) }); /** * User ID param validator */ userIdParam = Joi.object({ id: this.patterns.id.required() }); /** * Query users validator */ queryUsers = Joi.object({ ...this.pagination, role: Joi.string() .valid('user', 'moderator', 'admin') .optional(), isActive: Joi.boolean().optional(), isEmailVerified: Joi.boolean().optional(), search: Joi.string().trim().max(100).optional() }); } module.exports = new UserValidator();
src/validators/ProductValidator.js
/** * PRODUCT VALIDATOR * Validasi untuk semua operasi terkait produk */ const Joi = require('joi'); const BaseValidator = require('./BaseValidator'); class ProductValidator extends BaseValidator { constructor() { super(); } /** * Product categories */ categories = [ 'electronics', 'fashion', 'home', 'sports', 'books', 'automotive', 'health', 'beauty', 'food', 'other' ]; /** * Create product validation schema */ create = Joi.object({ name: Joi.string() .min(3) .max(200) .required() .messages({ 'string.min': 'Nama produk minimal 3 karakter', 'string.max': 'Nama produk maksimal 200 karakter', 'string.empty': 'Nama produk wajib diisi' }), description: Joi.string() .min(10) .max(5000) .required() .messages({ 'string.min': 'Deskripsi produk minimal 10 karakter', 'string.max': 'Deskripsi produk maksimal 5000 karakter', 'string.empty': 'Deskripsi produk wajib diisi' }), price: Joi.number() .positive() .precision(2) .required() .messages({ 'number.base': 'Harga harus berupa angka', 'number.positive': 'Harga harus lebih dari 0', 'any.required': 'Harga wajib diisi' }), stock: Joi.number() .integer() .min(0) .default(0) .messages({ 'number.base': 'Stok harus berupa angka', 'number.integer': 'Stok harus bilangan bulat', 'number.min': 'Stok tidak boleh negatif' }), category: Joi.string() .valid(...this.categories) .required() .messages({ 'any.only': `Kategori harus salah satu: ${this.categories.join(', ')}`, 'string.empty': 'Kategori wajib diisi' }), images: Joi.array() .items( Joi.object({ url: this.patterns.url.required(), alt: Joi.string().max(100).optional(), isPrimary: Joi.boolean().default(false) }) ) .max(10) .default([]) .messages({ 'array.max': 'Maksimal 10 gambar per produk' }), variants: Joi.array() .items( Joi.object({ name: Joi.string().required(), sku: Joi.string().optional(), price: Joi.number().positive().optional(), stock: Joi.number().integer().min(0).default(0) }) ) .optional(), tags: Joi.array() .items(Joi.string().max(50)) .max(20) .optional(), weight: Joi.number() .positive() .unit('gram') .optional(), dimensions: Joi.object({ length: Joi.number().positive().optional(), width: Joi.number().positive().optional(), height: Joi.number().positive().optional(), unit: Joi.string().valid('cm', 'inch').default('cm') }).optional(), isActive: Joi.boolean().default(true) }); /** * Update product validation schema */ update = Joi.object({ name: Joi.string().min(3).max(200).optional(), description: Joi.string().min(10).max(5000).optional(), price: Joi.number().positive().precision(2).optional(), stock: Joi.number().integer().min(0).optional(), category: Joi.string().valid(...this.categories).optional(), images: Joi.array().items( Joi.object({ url: this.patterns.url, alt: Joi.string().max(100), isPrimary: Joi.boolean() }) ).max(10).optional(), variants: Joi.array().items( Joi.object({ name: Joi.string(), sku: Joi.string(), price: Joi.number().positive(), stock: Joi.number().integer().min(0) }) ).optional(), tags: Joi.array().items(Joi.string().max(50)).max(20).optional(), weight: Joi.number().positive().optional(), dimensions: Joi.object({ length: Joi.number().positive(), width: Joi.number().positive(), height: Joi.number().positive(), unit: Joi.string().valid('cm', 'inch') }).optional(), isActive: Joi.boolean().optional() }).min(1).messages({ 'object.min': 'Minimal satu field harus diupdate' }); /** * Product ID param validator */ productIdParam = Joi.object({ id: this.patterns.id.required() }); /** * Query products validator */ queryProducts = Joi.object({ ...this.pagination, category: Joi.string() .valid(...this.categories) .optional(), minPrice: Joi.number().positive().optional(), maxPrice: Joi.number().positive().min(Joi.ref('minPrice')).optional(), inStock: Joi.boolean().optional(), search: Joi.string().trim().max(100).optional(), tags: Joi.array().items(Joi.string()).single().optional() }); } module.exports = new ProductValidator();
D. Validation Middleware
src/middleware/validation.middleware.js
/** * VALIDATION MIDDLEWARE * Menangani validasi request secara terpusat */ const { ValidationError } = require('../errors/AppError'); const logger = require('../utils/logger'); class ValidationMiddleware { /** * Validate request body against schema */ validateBody(schema, options = {}) { return async (req, res, next) => { try { const { value, error } = await schema.validateAsync(req.body, { abortEarly: false, stripUnknown: true, ...options }); if (error) { throw this.formatJoiError(error); } // Replace body with validated and sanitized data req.body = value; next(); } catch (error) { next(error); } }; } /** * Validate request query against schema */ validateQuery(schema, options = {}) { return async (req, res, next) => { try { const { value, error } = await schema.validateAsync(req.query, { abortEarly: false, stripUnknown: true, convert: true, ...options }); if (error) { throw this.formatJoiError(error); } req.query = value; next(); } catch (error) { next(error); } }; } /** * Validate request params against schema */ validateParams(schema, options = {}) { return async (req, res, next) => { try { const { value, error } = await schema.validateAsync(req.params, { abortEarly: false, stripUnknown: true, ...options }); if (error) { throw this.formatJoiError(error); } req.params = value; next(); } catch (error) { next(error); } }; } /** * Format Joi error to ValidationError */ formatJoiError(error) { const details = error.details.map(detail => ({ field: detail.path.join('.'), message: detail.message.replace(/"/g, ''), type: detail.type, context: detail.context })); return new ValidationError('Validasi data gagal', details); } /** * Custom validation function */ validate(validator, property = 'body') { return async (req, res, next) => { try { const data = req[property]; const result = await validator(data); if (result === true) { next(); } else { const error = new ValidationError( 'Validasi gagal', Array.isArray(result) ? result : [{ message: result }] ); next(error); } } catch (error) { next(error); } }; } /** * Conditional validation * Only validate if condition is met */ validateIf(condition, schema, property = 'body') { return async (req, res, next) => { const shouldValidate = typeof condition === 'function' ? condition(req) : condition; if (shouldValidate) { const validator = this.validateBody(schema); return validator(req, res, next); } next(); }; } /** * Validate file upload */ validateFile(options = {}) { const { maxSize = 5 * 1024 * 1024, // 5MB allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], maxFiles = 5, required = false } = options; return (req, res, next) => { const files = req.files || (req.file ? [req.file] : []); const errors = []; // Check if file is required but not provided if (required && files.length === 0) { errors.push({ field: 'file', message: 'File wajib diupload', type: 'required' }); } // Validate each file files.forEach((file, index) => { // Check file size if (file.size > maxSize) { errors.push({ field: file.fieldname || 'file', index, message: `Ukuran file terlalu besar. Maksimal ${maxSize / 1024 / 1024}MB`, type: 'file_size', max: maxSize, current: file.size }); } // Check file type if (!allowedTypes.includes(file.mimetype)) { errors.push({ field: file.fieldname || 'file', index, message: `Tipe file tidak didukung. Format yang diizinkan: ${allowedTypes.join(', ')}`, type: 'file_type', allowed: allowedTypes, current: file.mimetype }); } }); // Check max files if (files.length > maxFiles) { errors.push({ field: 'files', message: `Maksimal ${maxFiles} file dapat diupload`, type: 'max_files', max: maxFiles, current: files.length }); } if (errors.length > 0) { const error = new ValidationError('Validasi file gagal', errors); return next(error); } next(); }; } } module.exports = new ValidationMiddleware();
E. Advanced Error Tracking
src/utils/errorTracker.js
/** * ERROR TRACKING & ANALYTICS * Melacak error patterns untuk improvement */ const fs = require('fs').promises; const path = require('path'); const logger = require('./logger'); class ErrorTracker { constructor() { this.stats = { totalErrors: 0, errorsByType: {}, errorsByEndpoint: {}, errorsByStatusCode: {}, errorsByHour: Array(24).fill(0), topErrors: [], recentErrors: [] }; this.alertThresholds = { errorRate: 100, // errors per minute serverErrors: 10, // 5xx errors per minute criticalErrors: 5 // critical errors per minute }; this.startTime = Date.now(); this.errorCounts = { lastMinute: [], lastHour: [] }; } /** * Track error */ trackError(error, req) { const timestamp = new Date(); const hour = timestamp.getHours(); const errorType = error.name || 'UnknownError'; const statusCode = error.statusCode || 500; const endpoint = req?.originalUrl || 'unknown'; const method = req?.method || 'unknown'; // Update stats this.stats.totalErrors++; this.stats.errorsByType[errorType] = (this.stats.errorsByType[errorType] || 0) + 1; this.stats.errorsByStatusCode[statusCode] = (this.stats.errorsByStatusCode[statusCode] || 0) + 1; this.stats.errorsByHour[hour]++; const endpointKey = `${method}:${endpoint}`; this.stats.errorsByEndpoint[endpointKey] = (this.stats.errorsByEndpoint[endpointKey] || 0) + 1; // Track recent errors const errorRecord = { timestamp: timestamp.toISOString(), type: errorType, code: error.errorCode || error.code, message: error.message, statusCode, endpoint, method, userId: req?.user?.id, requestId: req?.requestId }; this.stats.recentErrors.unshift(errorRecord); if (this.stats.recentErrors.length > 100) { this.stats.recentErrors.pop(); } // Update top errors this.updateTopErrors(errorType, error.message); // Check thresholds and alert this.checkThresholds(error, timestamp); } /** * Update top errors */ updateTopErrors(errorType, errorMessage) { const key = `${errorType}: ${errorMessage.substring(0, 50)}`; const existingError = this.stats.topErrors.find(e => e.key === key); if (existingError) { existingError.count++; } else { this.stats.topErrors.push({ key, type: errorType, message: errorMessage, count: 1 }); } // Sort by count descending this.stats.topErrors.sort((a, b) => b.count - a.count); // Keep top 20 if (this.stats.topErrors.length > 20) { this.stats.topErrors.pop(); } } /** * Check error thresholds */ checkThresholds(error, timestamp) { const now = timestamp.getTime(); const oneMinuteAgo = now - 60000; const oneHourAgo = now - 3600000; // Update error counts this.errorCounts.lastMinute = this.errorCounts.lastMinute.filter( t => t > oneMinuteAgo ); this.errorCounts.lastMinute.push(now); this.errorCounts.lastHour = this.errorCounts.lastHour.filter( t => t > oneHourAgo ); this.errorCounts.lastHour.push(now); const errorsPerMinute = this.errorCounts.lastMinute.length; const serverErrorsPerMinute = this.errorCounts.lastMinute.filter( (_, i) => this.stats.recentErrors[i]?.statusCode >= 500 ).length; // Check thresholds if (errorsPerMinute > this.alertThresholds.errorRate) { this.sendAlert('HIGH_ERROR_RATE', { rate: errorsPerMinute, threshold: this.alertThresholds.errorRate, timestamp: timestamp.toISOString() }); } if (serverErrorsPerMinute > this.alertThresholds.serverErrors) { this.sendAlert('HIGH_SERVER_ERROR_RATE', { rate: serverErrorsPerMinute, threshold: this.alertThresholds.serverErrors, timestamp: timestamp.toISOString() }); } if (error.critical && this.errorCounts.lastMinute.filter( (_, i) => this.stats.recentErrors[i]?.critical ).length > this.alertThresholds.criticalErrors) { this.sendAlert('CRITICAL_ERRORS', { error: error.message, timestamp: timestamp.toISOString() }); } } /** * Send alert (implement with email/Slack/PagerDuty) */ sendAlert(type, data) { logger.error(`🚨 ALERT: ${type}`, data); // Implement actual alerting here // - Send email // - Send Slack message // - Call PagerDuty API // - Send to monitoring service (Datadog, NewRelic) } /** * Get error statistics */ getStats(timeRange = 'all') { const stats = { ...this.stats }; // Add uptime stats.uptime = Date.now() - this.startTime; stats.uptimeFormatted = this.formatUptime(stats.uptime); // Add error rate stats.errorsPerMinute = this.errorCounts.lastMinute.length; stats.errorsPerHour = this.errorCounts.lastHour.length; // Add health score (0-100) stats.healthScore = this.calculateHealthScore(); return stats; } /** * Calculate health score */ calculateHealthScore() { const baseScore = 100; // Deduct points for server errors const serverErrorCount = Object.entries(this.stats.errorsByStatusCode) .filter(([code]) => parseInt(code) >= 500) .reduce((sum, [, count]) => sum + count, 0); // Deduct points for recent errors const recentErrorCount = this.errorCounts.lastHour.length; // Deduct points for unique error types const errorTypeCount = Object.keys(this.stats.errorsByType).length; let score = baseScore; score -= serverErrorCount * 0.1; score -= recentErrorCount * 0.05; score -= errorTypeCount * 0.2; return Math.max(0, Math.min(100, Math.round(score))); } /** * Format uptime */ formatUptime(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); return { days, hours: hours % 24, minutes: minutes % 60, seconds: seconds % 60, milliseconds: ms % 1000 }; } /** * Reset stats */ resetStats() { this.stats = { totalErrors: 0, errorsByType: {}, errorsByEndpoint: {}, errorsByStatusCode: {}, errorsByHour: Array(24).fill(0), topErrors: [], recentErrors: [] }; this.errorCounts = { lastMinute: [], lastHour: [] }; } /** * Save stats to file */ async saveStatsToFile(filePath = './logs/error-stats.json') { try { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); const statsWithTimestamp = { ...this.getStats(), savedAt: new Date().toISOString() }; await fs.writeFile( filePath, JSON.stringify(statsWithTimestamp, null, 2) ); logger.info(`Error stats saved to ${filePath}`); } catch (error) { logger.error('Failed to save error stats:', error); } } } module.exports = new ErrorTracker();
F. Complete Error Handling Setup
src/app.js
/** * EXPRESS APP WITH COMPLETE ERROR HANDLING */ const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const compression = require('compression'); const { v4: uuidv4 } = require('uuid'); const config = require('./config/env'); const logger = require('./utils/logger'); const errorTracker = require('./utils/errorTracker'); const errorHandler = require('./middleware/errorHandler.middleware'); const validationMiddleware = require('./middleware/validation.middleware'); // Import validators const userValidator = require('./validators/UserValidator'); const productValidator = require('./validators/ProductValidator'); // Import routes const authRoutes = require('./routes/auth.routes'); const userRoutes = require('./routes/user.routes'); const productRoutes = require('./routes/product.routes'); const app = express(); // ============= GLOBAL MIDDLEWARE ============= // Request ID app.use((req, res, next) => { req.requestId = req.headers['x-request-id'] || uuidv4(); res.setHeader('X-Request-ID', req.requestId); next(); }); // Security & parsing app.use(helmet()); app.use(cors(config.CORS_OPTIONS)); app.use(compression()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Request logging app.use((req, res, next) => { logger.info(`${req.method} ${req.originalUrl}`, { requestId: req.requestId, ip: req.ip, userAgent: req.headers['user-agent'] }); next(); }); // ============= API ROUTES ============= // Health check app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), requestId: req.requestId }); }); // API Routes app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); app.use('/api/products', productRoutes); // ============= EXAMPLE: CONTROLLER WITH ERROR HANDLING ============= const { asyncHandler } = require('./middleware/errorHandler.middleware'); const { ValidationError, NotFoundError } = require('./errors/AppError'); // Contoh controller dengan proper error handling app.post('/api/examples/validation', validationMiddleware.validateBody(userValidator.register), asyncHandler(async (req, res) => { // Business logic here const { email, password, name } = req.body; // Simulasi duplicate check const existingUser = await checkUserExists(email); if (existingUser) { throw new ValidationError('Email sudah terdaftar', [ { field: 'email', message: 'Email sudah digunakan' } ]); } res.status(201).json({ success: true, message: 'User created successfully', data: { email, name } }); }) ); app.get('/api/examples/:id', validationMiddleware.validateParams( Joi.object({ id: Joi.number().required() }) ), asyncHandler(async (req, res) => { const { id } = req.params; // Simulasi find by id const user = await findUserById(id); if (!user) { throw new NotFoundError('User', id); } res.json({ success: true, data: user }); }) ); // ============= ERROR HANDLING PIPELINE ============= /** * ORDER OF ERROR HANDLERS IS CRITICAL: * 1. Celebrate/Joi error handlers (convert to AppError) * 2. Database error handlers * 3. HTTP error handlers * 4. Multer error handlers * 5. 404 handler (if no route matches) * 6. Main error handler (final) */ // 1. Joi validation error handler app.use(errorHandler.joiErrorHandler()); // 2. Celebrate validation error handler app.use(errorHandler.celebrateErrorHandler()); // 3. Database error handler app.use(errorHandler.databaseErrorHandler()); // 4. HTTP error handler app.use(errorHandler.httpErrorHandler()); // 5. Multer error handler app.use(errorHandler.multerErrorHandler()); // 6. 404 handler - must be after all routes app.use(errorHandler.notFoundHandler()); // 7. Main error handler - must be LAST app.use(errorHandler.mainErrorHandler()); // ============= UNCAUGHT EXCEPTIONS ============= // Handle unhandled rejections errorHandler.unhandledRejectionHandler(); // Handle uncaught exceptions errorHandler.uncaughtExceptionHandler(); // ============= GRACEFUL SHUTDOWN ============= process.on('SIGTERM', () => { logger.info('SIGTERM received. Starting graceful shutdown...'); // Save error stats before exit errorTracker.saveStatsToFile() .finally(() => { process.exit(0); }); }); process.on('SIGINT', () => { logger.info('SIGINT received. Starting graceful shutdown...'); // Save error stats before exit errorTracker.saveStatsToFile() .finally(() => { process.exit(0); }); }); module.exports = app;
3.3 Validation Patterns & Techniques
/** * VALIDATION PATTERNS & BEST PRACTICES */ // ============= 1. LAYERED VALIDATION ============= const layeredValidation = { /** * Layer 1: Client-side validation (UX) * - Instant feedback * - Reduce server load * - Not security boundary! */ clientSide: ` // React/Vue example if (email.length === 0) { setError('Email is required'); return; } `, /** * Layer 2: API Gateway validation (if exists) * - Rate limiting * - Basic format checks * - Request size limits */ gateway: ` # Nginx/AWS API Gateway client_max_body_size 10M; limit_req zone=auth burst=5; `, /** * Layer 3: Express middleware validation * - Schema validation * - Sanitization * - Authentication/Authorization */ middleware: ` app.post('/users', validateBody(userSchema), authenticate, userController.create ); `, /** * Layer 4: Service layer validation * - Business rules * - Complex logic * - Cross-field validation */ service: ` async createUser(data) { // Business rule: Email domain must be allowed if (!allowedDomains.includes(data.email.split('@')[1])) { throw new BusinessError('Email domain not allowed'); } } `, /** * Layer 5: Database validation * - Unique constraints * - Foreign key constraints * - Not null constraints */ database: ` CREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); ` }; // ============= 2. SANITIZATION TECHNIQUES ============= const sanitization = { /** * XSS Prevention */ xssPrevention: (input) => { if (typeof input !== 'string') return input; return input .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/'); }, /** * SQL Injection Prevention */ sqlInjectionPrevention: (input) => { if (typeof input !== 'string') return input; // Remove SQL keywords (basic) const sqlKeywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'UNION', '--']; let sanitized = input; sqlKeywords.forEach(keyword => { const regex = new RegExp(keyword, 'gi'); sanitized = sanitized.replace(regex, ''); }); return sanitized; }, /** * Trim and normalize */ normalize: (input) => { if (typeof input === 'string') { return input .trim() .replace(/\s+/g, ' ') .normalize('NFKC'); } return input; }, /** * Email normalization */ normalizeEmail: (email) => { if (!email || typeof email !== 'string') return email; return email .trim() .toLowerCase() .replace(/\s+/g, ''); } }; // ============= 3. CUSTOM VALIDATORS ============= const customValidators = { /** * Indonesian phone number */ indonesianPhone: (value) => { const regex = /^(\+62|62|0)[2-9][0-9]{7,11}$/; if (!regex.test(value)) { throw new Error('Nomor HP Indonesia tidak valid'); } return value; }, /** * NIK (Indonesian ID Card) */ nik: (value) => { const regex = /^[0-9]{16}$/; if (!regex.test(value)) { throw new Error('NIK harus 16 digit angka'); } return value; }, /** * Postal code (Indonesia) */ postalCode: (value) => { const regex = /^[0-9]{5}$/; if (!regex.test(value)) { throw new Error('Kode pos harus 5 digit'); } return value; }, /** * URL with custom scheme */ customUrl: (value, allowedSchemes = ['http', 'https']) => { try { const url = new URL(value); if (!allowedSchemes.includes(url.protocol.slice(0, -1))) { throw new Error(`Protocol harus ${allowedSchemes.join(' atau ')}`); } return value; } catch { throw new Error('URL tidak valid'); } }, /** * Age validator (13+ years) */ minimumAge: (birthDate, minAge = 13) => { const today = new Date(); const birth = new Date(birthDate); let age = today.getFullYear() - birth.getFullYear(); const monthDiff = today.getMonth() - birth.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { age--; } if (age < minAge) { throw new Error(`Minimal umur ${minAge} tahun`); } return age; } };
3.4 Error Response Standards
/** * ERROR RESPONSE STANDARDS */ // ============= 1. RFC 7807 PROBLEM DETAILS ============= const rfc7807Format = { /** * RFC 7807: Problem Details for HTTP APIs * https://tools.ietf.org/html/rfc7807 */ problemDetails: (err, req) => ({ type: `https://api.example.com/errors/${err.errorCode || 'general'}`, title: err.name || 'Error', status: err.statusCode || 500, detail: err.message, instance: req.originalUrl, timestamp: new Date().toISOString(), requestId: req.requestId }), /** * Validation Problem Details */ validationProblem: (err, req) => ({ type: 'https://api.example.com/errors/validation', title: 'Validation Error', status: 400, detail: 'Request validation failed', instance: req.originalUrl, errors: err.details, timestamp: new Date().toISOString() }) }; // ============= 2. JSON:API ERROR FORMAT ============= const jsonApiFormat = { /** * JSON:API Error Format * https://jsonapi.org/format/#errors */ jsonApiError: (err, req) => ({ jsonapi: { version: '1.0' }, errors: [{ id: req.requestId, code: err.errorCode, status: err.statusCode?.toString(), title: err.name, detail: err.message, source: { pointer: req.path, parameter: err.field }, meta: { timestamp: new Date().toISOString(), environment: process.env.NODE_ENV } }] }) }; // ============= 3. GRAPHQL ERROR FORMAT ============= const graphqlFormat = { /** * GraphQL Error Format */ graphqlError: (err) => ({ errors: [{ message: err.message, extensions: { code: err.errorCode, statusCode: err.statusCode, timestamp: new Date().toISOString() }, locations: err.locations, path: err.path }] }) }; // ============= 4. CUSTOM ENTERPRISE FORMAT ============= const enterpriseFormat = { /** * Enterprise error format with correlation */ enterpriseError: (err, req) => ({ success: false, error: { correlationId: req.requestId, timestamp: new Date().toISOString(), service: process.env.SERVICE_NAME || 'api-service', version: process.env.APP_VERSION || '1.0.0', code: err.errorCode || 'UNKNOWN_ERROR', message: err.message, details: err.details, path: req.originalUrl, method: req.method }, meta: { environment: process.env.NODE_ENV, api: { version: req.headers['accept-version'] || 'v1', format: 'json' } } }) };
3.5 Testing Error Handling & Validation
tests/error-handling/validation.test.js
/** * TESTING VALIDATION & ERROR HANDLING */ const request = require('supertest'); const app = require('../../src/app'); const { ValidationError } = require('../../src/errors/AppError'); describe('Validation System', () => { describe('User Registration Validation', () => { it('should reject invalid email format', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'not-an-email', password: 'Test123!', name: 'Test User' }); expect(response.statusCode).toBe(400); expect(response.body.error.code).toBe('VALIDATION_ERROR'); expect(response.body.error.validation).toBeDefined(); expect(response.body.error.validation[0].field).toBe('email'); }); it('should reject weak password', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'test@example.com', password: 'weak', name: 'Test User' }); expect(response.statusCode).toBe(400); expect(response.body.error.validation[0].field).toBe('password'); }); it('should reject missing required fields', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'test@example.com' }); expect(response.statusCode).toBe(400); expect(response.body.error.validation.length).toBeGreaterThan(1); }); }); describe('Product Validation', () => { it('should reject negative price', async () => { const response = await request(app) .post('/api/products') .set('Authorization', 'Bearer valid-token') .send({ name: 'Test Product', description: 'This is a test product', price: -1000, category: 'electronics' }); expect(response.statusCode).toBe(400); expect(response.body.error.validation[0].field).toBe('price'); }); it('should reject invalid category', async () => { const response = await request(app) .post('/api/products') .set('Authorization', 'Bearer valid-token') .send({ name: 'Test Product', description: 'This is a test product', price: 100000, category: 'invalid-category' }); expect(response.statusCode).toBe(400); expect(response.body.error.validation[0].field).toBe('category'); }); }); }); describe('Error Handling System', () => { describe('404 Handler', () => { it('should return 404 for non-existent route', async () => { const response = await request(app) .get('/api/non-existent-route'); expect(response.statusCode).toBe(404); expect(response.body.error.code).toBe('NOT_FOUND'); }); }); describe('Authentication Errors', () => { it('should return 401 for missing token', async () => { const response = await request(app) .get('/api/auth/me'); expect(response.statusCode).toBe(401); expect(response.body.error.code).toBe('NO_TOKEN'); }); it('should return 401 for invalid token', async () => { const response = await request(app) .get('/api/auth/me') .set('Authorization', 'Bearer invalid.token.here'); expect(response.statusCode).toBe(401); expect(response.body.error.code).toBe('INVALID_TOKEN'); }); }); describe('Database Errors', () => { it('should handle duplicate key error', async () => { // Register first user await request(app) .post('/api/auth/register') .send({ email: 'duplicate@test.com', password: 'Test123!', name: 'Test User' }); // Try to register same email const response = await request(app) .post('/api/auth/register') .send({ email: 'duplicate@test.com', password: 'Test123!', name: 'Another User' }); expect(response.statusCode).toBe(409); expect(response.body.error.code).toBe('VALIDATION_ERROR'); expect(response.body.error.validation[0].field).toBe('email'); }); }); describe('Error Response Format', () => { it('should have consistent error structure', async () => { const response = await request(app) .get('/api/non-existent'); expect(response.body).toHaveProperty('success', false); expect(response.body.error).toHaveProperty('code'); expect(response.body.error).toHaveProperty('message'); expect(response.body.error).toHaveProperty('timestamp'); expect(response.body.error).toHaveProperty('path'); expect(response.body.error).toHaveProperty('method'); expect(response.body.error).toHaveProperty('requestId'); }); it('should not expose stack trace in production', async () => { const originalEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const response = await request(app) .get('/api/non-existent'); expect(response.body.error).not.toHaveProperty('stack'); process.env.NODE_ENV = originalEnv; }); }); });
3.6 Error Monitoring Dashboard API
src/controllers/monitoring.controller.js
/** * MONITORING CONTROLLER * API untuk melihat error statistics */ const errorTracker = require('../utils/errorTracker'); const { asyncHandler } = require('../middleware/errorHandler.middleware'); const { ForbiddenError } = require('../errors/AppError'); class MonitoringController { /** * GET /api/monitoring/errors/stats * Get error statistics (admin only) */ getErrorStats = asyncHandler(async (req, res) => { // Only admin can access if (req.user?.role !== 'admin') { throw new ForbiddenError('Admin access required'); } const { timeRange = 'all' } = req.query; const stats = errorTracker.getStats(timeRange); res.json({ success: true, data: stats, timestamp: new Date().toISOString() }); }); /** * GET /api/monitoring/errors/recent * Get recent errors */ getRecentErrors = asyncHandler(async (req, res) => { if (req.user?.role !== 'admin') { throw new ForbiddenError('Admin access required'); } const { limit = 50 } = req.query; const recentErrors = errorTracker.stats.recentErrors.slice(0, limit); res.json({ success: true, data: recentErrors, total: errorTracker.stats.totalErrors, timestamp: new Date().toISOString() }); }); /** * GET /api/monitoring/errors/top * Get top errors */ getTopErrors = asyncHandler(async (req, res) => { if (req.user?.role !== 'admin') { throw new ForbiddenError('Admin access required'); } const { limit = 20 } = req.query; const topErrors = errorTracker.stats.topErrors.slice(0, limit); res.json({ success: true, data: topErrors, timestamp: new Date().toISOString() }); }); /** * POST /api/monitoring/errors/reset * Reset error statistics */ resetStats = asyncHandler(async (req, res) => { if (req.user?.role !== 'admin') { throw new ForbiddenError('Admin access required'); } errorTracker.resetStats(); res.json({ success: true, message: 'Error statistics reset successfully', timestamp: new Date().toISOString() }); }); /** * GET /api/monitoring/health * Detailed health check */ healthCheck = asyncHandler(async (req, res) => { const health = { status: 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), memory: process.memoryUsage(), cpu: process.cpuUsage(), version: process.version, platform: process.platform, errorStats: errorTracker.getStats('hour') }; res.json({ success: true, data: health }); }); } module.exports = new MonitoringController();
3.7 Best Practices Summary
/** * ERROR HANDLING & VALIDATION BEST PRACTICES */ const bestPractices = { /** * 1. THROW EARLY, CATCH LATE * Validasi sedini mungkin, tangani error di satu tempat */ throwEarly: ` // ✅ GOOD: Validasi di middleware app.post('/users', validateBody(userSchema), // Throw early controller.createUser // Catch late in global handler ); // ❌ BAD: Validasi di controller app.post('/users', (req, res) => { if (!req.body.email) { // Manual validation in controller return res.status(400).json({...}); } }); `, /** * 2. USE STANDARDIZED ERROR FORMAT * Konsistensi format error */ standardFormat: ` // ✅ GOOD: Consistent format { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...], "timestamp": "..." } } // ❌ BAD: Different format per endpoint { "err": true, "msg": "..." } { "error": "..." } { "status": "fail", "data": {...} } `, /** * 3. DISTINGUISH OPERATIONAL VS PROGRAMMING ERRORS * Operational: Expected errors (validation, 404) * Programming: Bugs in code (undefined, null reference) */ errorTypes: ` // ✅ Operational Error - Expected throw new ValidationError('Invalid input'); // ❌ Programming Error - Unexpected user.profile.name // TypeError if user is null // Handle appropriately if (err.isOperational) { // Send to client } else { // Restart process, alert developer } `, /** * 4. DON'T EXPOSE INTERNAL DETAILS * Hide stack traces, internal paths */ hideInternals: ` // ✅ GOOD: Development if (process.env.NODE_ENV === 'development') { response.error.stack = err.stack; } // ✅ GOOD: Production response.error.message = 'Internal server error'; `, /** * 5. VALIDATE EVERY INPUT * Never trust client data */ validateAllInputs: ` // ✅ GOOD: Validate everything validateBody(schema) // POST/PUT body validateQuery(schema) // Query parameters validateParams(schema) // URL parameters validateFile(options) // File uploads // Also validate at service layer // Validate business rules // Validate database constraints `, /** * 6. USE ASYNC/AWAIT WITH TRY-CATCH * Or use asyncHandler wrapper */ asyncErrorHandling: ` // ✅ GOOD: asyncHandler wrapper app.get('/users', asyncHandler(async (req, res) => { const users = await User.find(); res.json(users); })); // ✅ GOOD: try-catch app.get('/users', async (req, res, next) => { try { const users = await User.find(); res.json(users); } catch (error) { next(error); } }); `, /** * 7. LOG COMPREHENSIVELY * Include context for debugging */ comprehensiveLogging: ` // ✅ GOOD: Include context logger.error('Error', { requestId: req.id, userId: req.user?.id, path: req.path, method: req.method, error: err.message, stack: err.stack }); `, /** * 8. GRACEFUL DEGRADATION * Fail gracefully, don't crash */ gracefulDegradation: ` // ✅ GOOD: Fallback try { await sendEmail(); } catch (error) { logger.error('Email failed', error); // Queue for retry, don't fail the request emailQueue.add({ to, subject }); } `, /** * 9. USE HTTP STATUS CODES CORRECTLY * 4xx: Client errors * 5xx: Server errors */ statusCodes: { 400: 'Bad Request - Invalid input', 401: 'Unauthorized - Authentication required', 403: 'Forbidden - Insufficient permissions', 404: 'Not Found - Resource does not exist', 409: 'Conflict - Duplicate/State conflict', 422: 'Unprocessable Entity - Business rule violation', 429: 'Too Many Requests - Rate limit', 500: 'Internal Server Error - Unexpected', 503: 'Service Unavailable - Database down' }, /** * 10. DOCUMENT ERROR CODES * Maintain error code catalog */ errorCatalog: { VALIDATION_ERROR: { description: 'Request validation failed', action: 'Check request body/query/params' }, AUTHENTICATION_ERROR: { description: 'User not authenticated', action: 'Provide valid token' }, AUTHORIZATION_ERROR: { description: 'Insufficient permissions', action: 'Request higher privileges' } } };
3.8 Latihan Praktikum
Exercise 1: Custom Validation Rules
/** * TODO: Implement custom validators untuk: * 1. Indonesian Tax ID (NPWP) * 2. Vehicle Registration Plate * 3. Bank Account Number * 4. Credit Card Number (Luhn algorithm) */ class IndonesianValidator { static npwp(npwp) { // Format: XX.XXX.XXX.X-XXX.XXX // Implementasi } static licensePlate(plate) { // Format: B 1234 XYZ // Implementasi } static bankAccount(accountNumber) { // Validasi untuk beberapa bank // Implementasi } }
Exercise 2: Error Alert System
/** * TODO: Implement error alert system: * 1. Email alerts for critical errors * 2. Slack webhook for warnings * 3. SMS for production outages * 4. Rate-limited alerts */ class AlertSystem { async sendEmailAlert(error, recipients) { // Implementasi } async sendSlackAlert(error, channel) { // Implementasi } async sendSmsAlert(error, phoneNumber) { // Implementasi } }
Exercise 3: Request/Response Logging Middleware
/** * TODO: Build comprehensive logging middleware: * 1. Log all requests with timing * 2. Log all errors with context * 3. Rotate log files * 4. Searchable log format (JSON) * 5. Sensitive data masking */ class LoggingMiddleware { requestLogger() { // Implementasi } errorLogger() { // Implementasi } maskSensitiveData(data) { // Implementasi } }
Exercise 4: API Validation Testing Suite
/** * TODO: Create validation test suite: * 1. Test all validation rules * 2. Test edge cases * 3. Test error messages * 4. Test performance */ describe('Validation Suite', () => { test('should handle maximum field lengths', () => { // Implementasi }); test('should handle Unicode characters', () => { // Implementasi }); test('should handle SQL injection attempts', () => { // Implementasi }); test('should handle XSS attempts', () => { // Implementasi }); });
4. Daftar Pustaka
- W3Schools (n.d). Node.js Error Handling. https://www.w3schools.com
- Express.js Documentation (n.d). Error Handling. https://expressjs.com
- DevTo (2025). Handling Custom Errors in Node.js with a Custom ApiError Class. https://dev.to/nurulislamrimon
- Toptal (2024). How to Build a Node.js Error-Handling System. https://www.toptal.com
- GeeksForGeekss (2025). How to implement validation in Express JS?. https://www.geeksforgeeks.org

0 Komentar