-
Notifications
You must be signed in to change notification settings - Fork 1
Open
Description
π§ MEDIUM: Error Handling Inconsistencies - Poor User Experience and Debugging Difficulties
Problem Description
The NSX application has inconsistent error handling patterns across different routes and endpoints, leading to poor user experience, security issues, and debugging difficulties.
Current problematic patterns:
// server/routes/post.ts:10-28 - Inconsistent error handling
router.get('/post/:id', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findFirst({
where: { id: parseInt(req.params.id, 10) },
})
res.status(200).json(post) // β No null check
} catch (error: unknown) {
if (error instanceof Error) {
Logger.error(error)
res.status(500).json({ error: error.message }) // β Exposes internal errors
} else {
Logger.error(error)
res.status(500).json({
error: `something wrong: ${JSON.stringify(error)}`, // β Poor error message
})
}
}
})
// server/routes/user.ts:91-97 - Different error pattern
} else {
Logger.warn('Invalid Password')
res.status(200).json({ failed: 'Invalid Password' }) // β Wrong status code
} else {
Logger.warn('User does not exist')
res.status(200).json({ failed: 'User does not exist' }) // β Wrong status code
}Why This Is Dangerous
- Information Disclosure: Internal error details exposed to clients
- Poor User Experience: Inconsistent error messages and status codes
- Security Issues: Sensitive information leaked through error messages
- Debugging Difficulties: Inconsistent error logging and handling
- API Inconsistency: Different error formats across endpoints
Current Impact
- Severity: Medium
- User Experience: Poor - confusing error messages
- Security: Medium - potential information disclosure
- Maintainability: Low - difficult to debug and maintain
Comprehensive Solution
1. Create Centralized Error Classes
// server/lib/errors/AppError.ts
export class AppError extends Error {
public readonly statusCode: number
public readonly isOperational: boolean
public readonly code: string
public readonly details?: any
constructor(
message: string,
statusCode: number = 500,
code: string = 'INTERNAL_ERROR',
isOperational: boolean = true,
details?: any
) {
super(message)
this.statusCode = statusCode
this.isOperational = isOperational
this.code = code
this.details = details
Error.captureStackTrace(this, this.constructor)
}
}
// Specific error types
export class ValidationError extends AppError {
constructor(message: string, details?: any) {
super(message, 400, 'VALIDATION_ERROR', true, details)
}
}
export class AuthenticationError extends AppError {
constructor(message: string = 'Authentication failed') {
super(message, 401, 'AUTHENTICATION_ERROR')
}
}
export class AuthorizationError extends AppError {
constructor(message: string = 'Access denied') {
super(message, 403, 'AUTHORIZATION_ERROR')
}
}
export class NotFoundError extends AppError {
constructor(resource: string = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND')
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT')
}
}
export class RateLimitError extends AppError {
constructor(message: string = 'Too many requests') {
super(message, 429, 'RATE_LIMIT_EXCEEDED')
}
}
export class DatabaseError extends AppError {
constructor(message: string = 'Database operation failed') {
super(message, 500, 'DATABASE_ERROR')
}
}2. Create Error Handler Middleware
// server/lib/errors/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
import { AppError } from './AppError'
import Logger from '../Logger'
interface ErrorResponse {
success: false
error: string
code: string
details?: any
timestamp: string
requestId?: string
}
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction
) => {
let appError: AppError
// Convert known errors to AppError
if (error instanceof AppError) {
appError = error
} else if (error.name === 'ValidationError') {
appError = new ValidationError(error.message)
} else if (error.name === 'CastError') {
appError = new ValidationError('Invalid ID format')
} else if (error.name === 'MongoError' || error.name === 'PrismaClientKnownRequestError') {
appError = new DatabaseError('Database operation failed')
} else {
// Unknown error - don't expose details
appError = new AppError(
'An unexpected error occurred',
500,
'INTERNAL_ERROR',
false
)
}
// Log error appropriately
if (appError.statusCode >= 500) {
Logger.error('Server Error:', {
error: appError.message,
stack: appError.stack,
code: appError.code,
url: req.url,
method: req.method,
ip: req.ip,
userAgent: req.get('user-agent'),
body: req.body,
params: req.params,
query: req.query
})
} else {
Logger.warn('Client Error:', {
error: appError.message,
code: appError.code,
url: req.url,
method: req.method,
ip: req.ip,
details: appError.details
})
}
// Prepare error response
const errorResponse: ErrorResponse = {
success: false,
error: appError.message,
code: appError.code,
timestamp: new Date().toISOString(),
requestId: req.headers['x-request-id'] as string
}
// Add details only for validation errors in development
if (appError.details && process.env.NODE_ENV === 'development') {
errorResponse.details = appError.details
}
// Send error response
res.status(appError.statusCode).json(errorResponse)
}
// Async error wrapper
export const asyncHandler = (fn: Function) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}3. Create Success Response Helper
// server/lib/responses/successResponse.ts
import { Response } from 'express'
interface SuccessResponse<T = any> {
success: true
data?: T
message?: string
meta?: {
total?: number
page?: number
limit?: number
hasNext?: boolean
}
timestamp: string
requestId?: string
}
export const sendSuccess = <T>(
res: Response,
data?: T,
message?: string,
statusCode: number = 200,
meta?: SuccessResponse<T>['meta']
) => {
const response: SuccessResponse<T> = {
success: true,
data,
message,
meta,
timestamp: new Date().toISOString(),
requestId: res.get('x-request-id')
}
res.status(statusCode).json(response)
}
// Convenience methods
export const sendCreated = <T>(res: Response, data: T, message?: string) => {
sendSuccess(res, data, message || 'Resource created successfully', 201)
}
export const sendUpdated = (res: Response, message?: string) => {
sendSuccess(res, undefined, message || 'Resource updated successfully', 200)
}
export const sendDeleted = (res: Response, message?: string) => {
sendSuccess(res, undefined, message || 'Resource deleted successfully', 200)
}4. Update Route Implementations
// server/routes/post.ts
import { asyncHandler } from '../lib/errors/errorHandler'
import { sendSuccess, sendCreated, sendUpdated, sendDeleted } from '../lib/responses/successResponse'
import { NotFoundError, DatabaseError } from '../lib/errors/AppError'
import { validateBody, validateParams } from '../lib/validation/middleware'
import { postSchemas } from '../lib/validation/schemas'
// Get single post
router.get('/post/:id',
validateParams(z.object({ id: z.string().regex(/^\d+$/) })),
asyncHandler(async (req: Request, res: Response) => {
const postId = parseInt(req.params.id, 10)
const post = await prisma.post.findFirst({
where: { id: postId },
})
if (!post) {
throw new NotFoundError('Post')
}
sendSuccess(res, post)
})
)
// Create post
router.post('/create',
isAuthorized,
validateBody(postSchemas.create),
asyncHandler(async (req: Request, res: Response) => {
const { title, body } = req.body
try {
const post = await prisma.post.create({
data: {
title: title.trim(),
body: body.trim(),
},
})
sendCreated(res, post, 'Post created successfully')
} catch (error) {
throw new DatabaseError('Failed to create post')
}
})
)
// Update post
router.post('/update',
isAuthorized,
validateBody(postSchemas.update),
asyncHandler(async (req: Request, res: Response) => {
const { id, title, body } = req.body
// Check if post exists
const existingPost = await prisma.post.findFirst({
where: { id }
})
if (!existingPost) {
throw new NotFoundError('Post')
}
try {
await prisma.post.update({
where: { id },
data: {
title: title.trim(),
body: body.trim(),
},
})
sendUpdated(res, 'Post updated successfully')
} catch (error) {
throw new DatabaseError('Failed to update post')
}
})
)
// Delete post
router.delete('/post/:id',
isAuthorized,
validateParams(z.object({ id: z.string().regex(/^\d+$/) })),
asyncHandler(async (req: Request, res: Response) => {
const postId = parseInt(req.params.id, 10)
// Check if post exists
const existingPost = await prisma.post.findFirst({
where: { id: postId }
})
if (!existingPost) {
throw new NotFoundError('Post')
}
try {
await prisma.post.delete({
where: { id: postId }
})
sendDeleted(res, 'Post deleted successfully')
} catch (error) {
throw new DatabaseError('Failed to delete post')
}
})
)
// Get post list with pagination
router.get('/post_list',
validateQuery(z.object({
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
perPage: z.string().regex(/^\d+$/).transform(Number).default('10')
})),
asyncHandler(async (req: Request, res: Response) => {
const { page, perPage } = req.query as { page: number; perPage: number }
try {
const total = await prisma.post.count()
const offset = perPage * (page - 1)
const posts = await prisma.post.findMany({
take: perPage,
skip: offset,
orderBy: { id: 'desc' },
})
const meta = {
total,
page,
limit: perPage,
hasNext: offset + perPage < total
}
sendSuccess(res, posts, 'Posts retrieved successfully', 200, meta)
} catch (error) {
throw new DatabaseError('Failed to retrieve posts')
}
})
)5. Update User Routes
// server/routes/user.ts
import { asyncHandler } from '../lib/errors/errorHandler'
import { sendSuccess, sendCreated } from '../lib/responses/successResponse'
import { AuthenticationError, ConflictError, ValidationError } from '../lib/errors/AppError'
// Login
router.post('/login',
loginRateLimiter,
validateBody(userSchemas.login),
asyncHandler(async (req: Request, res: Response) => {
const { name, password } = req.body
const normalizedName = name.toLowerCase().trim()
// Check account lockout
const lockoutStatus = checkAccountLockout(normalizedName)
if (lockoutStatus.isLocked) {
throw new AuthenticationError('Account temporarily locked due to multiple failed attempts')
}
const [user, dummyHash] = await Promise.all([
prisma.user.findFirst({
where: { name: normalizedName },
select: { id: true, name: true, password: true, createdAt: true, updatedAt: true }
}),
bcrypt.hash('dummy', 10)
])
const passwordToCompare = user?.password || dummyHash
const isValidPassword = await bcrypt.compare(password, passwordToCompare)
await consistentDelay()
if (user && isValidPassword) {
clearFailedAttempts(normalizedName)
const token: JWTtoken = generateAccessToken(user)
res.cookie('token', token, getCookieOptions(token))
sendSuccess(res, {
user: {
id: user.id,
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt
}
}, 'Login successful')
} else {
const lockoutStatus = recordFailedAttempt(normalizedName)
if (lockoutStatus.isLocked) {
throw new AuthenticationError('Account temporarily locked due to multiple failed attempts')
}
throw new AuthenticationError('Invalid credentials')
}
})
)
// Signup
router.post('/signup',
validateBody(userSchemas.signup),
asyncHandler(async (req: Request, res: Response) => {
const { name, password } = req.body
const normalizedName = name.toLowerCase().trim()
// Check if user already exists
const existingUser = await prisma.user.findFirst({
where: { name: normalizedName }
})
if (existingUser) {
throw new ConflictError('Username already exists')
}
try {
const salt = await bcrypt.genSalt(12)
const hash = await bcrypt.hash(password, salt)
const user = await prisma.user.create({
data: {
name: normalizedName,
password: hash,
},
})
const token: JWTtoken = generateAccessToken(user)
res.cookie('token', token, getCookieOptions(token))
sendCreated(res, {
user: {
id: user.id,
name: user.name,
createdAt: user.createdAt,
updatedAt: user.updatedAt
}
}, 'Account created successfully')
} catch (error) {
throw new DatabaseError('Failed to create account')
}
})
)6. Global Error Handling Setup
// server/index.ts
import { errorHandler } from './lib/errors/errorHandler'
import { requestIdMiddleware } from './lib/middleware/requestId'
// Add request ID middleware
app.use(requestIdMiddleware)
// ... other middleware
// Apply error handler at the end
app.use(errorHandler)
// Handle 404 errors
app.use('*', (req: Request, res: Response) => {
res.status(404).json({
success: false,
error: 'Route not found',
code: 'NOT_FOUND',
timestamp: new Date().toISOString(),
requestId: req.headers['x-request-id']
})
})7. Request ID Middleware
// server/lib/middleware/requestId.ts
import { Request, Response, NextFunction } from 'express'
import { randomUUID } from 'crypto'
export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction) => {
const requestId = req.headers['x-request-id'] as string || randomUUID()
req.headers['x-request-id'] = requestId
res.set('x-request-id', requestId)
next()
}Testing the Implementation
1. Error Handling Tests
// tests/error-handling.test.ts
import request from 'supertest'
import app from '../server/index'
describe('Error Handling', () => {
it('should return consistent error format', async () => {
const response = await request(app)
.get('/api/post/999999')
.expect(404)
expect(response.body).toMatchObject({
success: false,
error: 'Post not found',
code: 'NOT_FOUND',
timestamp: expect.any(String)
})
})
it('should not expose internal error details', async () => {
const response = await request(app)
.post('/api/create')
.send({ title: '', body: '' })
.expect(400)
expect(response.body.success).toBe(false)
expect(response.body.code).toBe('VALIDATION_ERROR')
expect(response.body.error).toBeDefined()
expect(response.body.stack).toBeUndefined()
})
it('should include request ID in error responses', async () => {
const response = await request(app)
.get('/api/nonexistent')
.expect(404)
expect(response.body.requestId).toBeDefined()
expect(response.headers['x-request-id']).toBeDefined()
})
})2. Success Response Tests
// tests/success-responses.test.ts
describe('Success Responses', () => {
it('should return consistent success format', async () => {
const response = await request(app)
.get('/api/user_count')
.expect(200)
expect(response.body).toMatchObject({
success: true,
data: expect.any(Object),
timestamp: expect.any(String)
})
})
it('should include pagination meta for list endpoints', async () => {
const response = await request(app)
.get('/api/post_list?page=1&perPage=5')
.expect(200)
expect(response.body.meta).toMatchObject({
total: expect.any(Number),
page: 1,
limit: 5,
hasNext: expect.any(Boolean)
})
})
})Migration Strategy
Phase 1: Error Classes and Middleware
- Create error classes and handlers
- Add global error handling middleware
- Update critical routes
Phase 2: Response Helpers
- Create success response helpers
- Update all route responses
- Add request ID tracking
Phase 3: Testing and Monitoring
- Add comprehensive error tests
- Set up error monitoring
- Create error dashboards
Monitoring and Alerting
1. Error Rate Monitoring
// Track error rates by type
export const trackError = (error: AppError, req: Request) => {
const errorMetrics = {
code: error.code,
statusCode: error.statusCode,
url: req.url,
method: req.method,
timestamp: new Date().toISOString()
}
// Send to monitoring service
Logger.info('Error tracked:', errorMetrics)
}2. Error Alerting
# Example alerting rules
alerts:
- name: "High Error Rate"
condition: "error_rate > 5% in 5 minutes"
action: "notify_team"
- name: "Database Errors"
condition: "database_errors > 10 in 1 minute"
action: "page_oncall"References
Priority: π‘ Medium
Estimated Effort: 6-10 hours
Risk if not fixed: Medium - Poor user experience and debugging difficulties
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels