Skip to content

Commit f26a908

Browse files
authored
Merge pull request #129 from shoaib050326/codex/issue-13-error-handling
feat(backend): add centralized error handling
2 parents f199fae + 77113c4 commit f26a908

25 files changed

Lines changed: 3849 additions & 8473 deletions

backend/package-lock.json

Lines changed: 4 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"cron-parser": "^4.9.0",
1818
"dotenv": "^16.4.5",
1919
"express": "^4.21.0",
20+
"express-rate-limit": "^7.4.1",
2021
"node-cron": "^3.0.3",
2122
"openai": "^4.67.0",
2223
"zod": "^3.23.8"

backend/src/index.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import express, { Request, Response, NextFunction } from 'express';
21
import { AsyncLocalStorage } from 'node:async_hooks';
32
import { randomUUID } from 'node:crypto';
3+
import express, { Request, Response, NextFunction } from 'express';
44
import cors from 'cors';
55
import dotenv from 'dotenv';
66
import rateLimit from 'express-rate-limit';
@@ -11,6 +11,7 @@ import { catalogRouter } from './routes/catalog.js';
1111
import { jobsRouter } from './routes/jobs.js';
1212
import { healthRouter } from './routes/health.js';
1313
import { startJobs, getJobScheduler } from './jobs/index.js';
14+
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
1415

1516
dotenv.config();
1617

@@ -43,16 +44,32 @@ console.error = (...args) => originalConsole.error(...formatMessage(args));
4344
const app = express();
4445
const PORT = process.env.PORT || 3001;
4546

46-
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
47-
? process.env.CORS_ALLOWED_ORIGINS.split(',').map(o => o.trim())
47+
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
48+
? process.env.CORS_ALLOWED_ORIGINS.split(',').map((origin) => origin.trim())
4849
: '*';
4950

50-
app.use(cors({
51-
origin: allowedOrigins,
52-
credentials: true,
53-
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
54-
allowedHeaders: ['Content-Type', 'Authorization', 'X-Trace-Id'],
55-
}));
51+
const generalLimiter = rateLimit({
52+
windowMs: 15 * 60 * 1000,
53+
max: 100,
54+
standardHeaders: true,
55+
legacyHeaders: false,
56+
});
57+
58+
const invoiceLimiter = rateLimit({
59+
windowMs: 15 * 60 * 1000,
60+
max: 20,
61+
standardHeaders: true,
62+
legacyHeaders: false,
63+
});
64+
65+
app.use(
66+
cors({
67+
origin: allowedOrigins,
68+
credentials: true,
69+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
70+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Trace-Id'],
71+
})
72+
);
5673
app.use(express.json());
5774

5875
// Trace ID middleware
@@ -86,6 +103,8 @@ app.use('/api/v1/invoice', invoiceRouter);
86103
app.use('/api/v1/stellar', stellarRouter);
87104
app.use('/api/v1/catalog', catalogRouter);
88105
app.use('/api/v1/jobs', jobsRouter);
106+
app.use(notFoundHandler);
107+
app.use(errorHandler);
89108

90109
const jobsEnabled = process.env.JOBS_ENABLED !== 'false';
91110
if (jobsEnabled) {
@@ -99,12 +118,10 @@ const server = app.listen(PORT, () => {
99118
// Graceful shutdown
100119
const shutdown = (signal: string) => {
101120
console.log(`${signal} received. Starting graceful shutdown...`);
102-
103-
// 1. Stop accepting new requests
121+
104122
server.close(() => {
105123
console.log('HTTP server closed.');
106-
107-
// 2. Stop jobs
124+
108125
try {
109126
const scheduler = getJobScheduler();
110127
if (scheduler) {
@@ -119,7 +136,6 @@ const shutdown = (signal: string) => {
119136
process.exit(0);
120137
});
121138

122-
// Force exit if server.close takes too long (e.g. 10s)
123139
setTimeout(() => {
124140
console.error('Could not close connections in time, forceful shutdown');
125141
process.exit(1);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
2+
3+
type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
4+
5+
export class AppError extends Error {
6+
statusCode: number;
7+
code: string;
8+
details?: unknown;
9+
10+
constructor(statusCode: number, message: string, code = 'INTERNAL_SERVER_ERROR', details?: unknown) {
11+
super(message);
12+
this.name = 'AppError';
13+
this.statusCode = statusCode;
14+
this.code = code;
15+
this.details = details;
16+
}
17+
}
18+
19+
export function asyncHandler(handler: AsyncRouteHandler): RequestHandler {
20+
return (req, res, next) => {
21+
Promise.resolve(handler(req, res, next)).catch(next);
22+
};
23+
}
24+
25+
export function notFoundHandler(req: Request, _res: Response, next: NextFunction) {
26+
next(new AppError(404, `Route not found: ${req.method} ${req.originalUrl}`, 'NOT_FOUND'));
27+
}
28+
29+
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
30+
const isAppError = err instanceof AppError;
31+
const statusCode = isAppError ? err.statusCode : 500;
32+
const code = isAppError ? err.code : 'INTERNAL_SERVER_ERROR';
33+
const isProduction = process.env.NODE_ENV === 'production';
34+
const message = isAppError
35+
? err.message
36+
: isProduction
37+
? 'Internal server error'
38+
: err instanceof Error
39+
? err.message
40+
: 'Unexpected error';
41+
42+
const logMethod = statusCode >= 500 ? console.error : console.warn;
43+
logMethod(`[${code}] ${message}`, err);
44+
45+
res.status(statusCode).json({
46+
error: {
47+
code,
48+
message,
49+
status: statusCode,
50+
...(isAppError && err.details !== undefined ? { details: err.details } : {}),
51+
...(!isProduction && !isAppError && err instanceof Error && err.stack
52+
? { stack: err.stack }
53+
: {}),
54+
},
55+
});
56+
}

backend/src/routes/__tests__/health.test.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { healthRouter } from '../health.js';
3-
import { Request, Response } from 'express';
3+
import { Request, Response, Router, RequestHandler } from 'express';
44
import { server as stellarServer } from '../../services/stellar.js';
55
import { getJobScheduler } from '../../jobs/index.js';
66

@@ -20,6 +20,17 @@ describe('Health Router', () => {
2020
let resJson: any;
2121
let resStatus: any;
2222

23+
const getRouteHandler = (router: Router, path: string): RequestHandler => {
24+
const layer = router.stack.find((entry) => entry.route?.path === path);
25+
const handler = layer?.route?.stack[0]?.handle;
26+
27+
if (!handler) {
28+
throw new Error(`Route handler not found for ${path}`);
29+
}
30+
31+
return handler;
32+
};
33+
2334
beforeEach(() => {
2435
vi.clearAllMocks();
2536
resJson = vi.fn();
@@ -32,13 +43,16 @@ describe('Health Router', () => {
3243

3344
describe('GET /health', () => {
3445
it('returns 200 and healthy status when all dependencies are up', async () => {
35-
vi.mocked(stellarServer.root).mockResolvedValue({} as any);
46+
const mockedStellarRoot = vi.mocked(
47+
(stellarServer as unknown as { root: () => Promise<unknown> }).root
48+
);
49+
50+
mockedStellarRoot.mockResolvedValue({});
3651
vi.mocked(getJobScheduler).mockReturnValue({} as any);
3752
process.env.OPENAI_API_KEY = 'test-key';
3853

39-
// Access the private handler (for testing purposes in vitest)
40-
const handler = (healthRouter.stack.find(s => s.route.path === '/health')?.route.stack[0].handle);
41-
await handler(mockReq as Request, mockRes as Response);
54+
const handler = getRouteHandler(healthRouter, '/health');
55+
await handler(mockReq as Request, mockRes as Response, vi.fn());
4256

4357
expect(resStatus).toHaveBeenCalledWith(200);
4458
expect(resJson).toHaveBeenCalledWith(expect.objectContaining({
@@ -52,13 +66,17 @@ describe('Health Router', () => {
5266
});
5367

5468
it('returns 200 and degraded status when OpenAI is missing', async () => {
55-
vi.mocked(stellarServer.root).mockResolvedValue({} as any);
69+
const mockedStellarRoot = vi.mocked(
70+
(stellarServer as unknown as { root: () => Promise<unknown> }).root
71+
);
72+
73+
mockedStellarRoot.mockResolvedValue({});
5674
vi.mocked(getJobScheduler).mockReturnValue({} as any);
5775
const originalKey = process.env.OPENAI_API_KEY;
5876
delete process.env.OPENAI_API_KEY;
5977

60-
const handler = (healthRouter.stack.find(s => s.route.path === '/health')?.route.stack[0].handle);
61-
await handler(mockReq as Request, mockRes as Response);
78+
const handler = getRouteHandler(healthRouter, '/health');
79+
await handler(mockReq as Request, mockRes as Response, vi.fn());
6280

6381
expect(resStatus).toHaveBeenCalledWith(200);
6482
expect(resJson).toHaveBeenCalledWith(expect.objectContaining({
@@ -74,12 +92,16 @@ describe('Health Router', () => {
7492
});
7593

7694
it('returns 503 and unhealthy status when Stellar Horizon is down', async () => {
77-
vi.mocked(stellarServer.root).mockRejectedValue(new Error('Horizon Down'));
95+
const mockedStellarRoot = vi.mocked(
96+
(stellarServer as unknown as { root: () => Promise<unknown> }).root
97+
);
98+
99+
mockedStellarRoot.mockRejectedValue(new Error('Horizon Down'));
78100
vi.mocked(getJobScheduler).mockReturnValue({} as any);
79101
process.env.OPENAI_API_KEY = 'test-key';
80102

81-
const handler = (healthRouter.stack.find(s => s.route.path === '/health')?.route.stack[0].handle);
82-
await handler(mockReq as Request, mockRes as Response);
103+
const handler = getRouteHandler(healthRouter, '/health');
104+
await handler(mockReq as Request, mockRes as Response, vi.fn());
83105

84106
expect(resStatus).toHaveBeenCalledWith(503);
85107
expect(resJson).toHaveBeenCalledWith(expect.objectContaining({
@@ -95,8 +117,8 @@ describe('Health Router', () => {
95117

96118
describe('GET /ready', () => {
97119
it('returns 200 and ready status', async () => {
98-
const handler = (healthRouter.stack.find(s => s.route.path === '/ready')?.route.stack[0].handle);
99-
await handler(mockReq as Request, mockRes as Response);
120+
const handler = getRouteHandler(healthRouter, '/ready');
121+
await handler(mockReq as Request, mockRes as Response, vi.fn());
100122

101123
expect(resStatus).toHaveBeenCalledWith(200);
102124
expect(resJson).toHaveBeenCalledWith(expect.objectContaining({

backend/src/routes/__tests__/validation.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import { invoiceRouter } from '../invoice.js';
33
import { verificationRouter } from '../verification.js';
4-
import { Request, Response } from 'express';
4+
import { Request, Response, Router, RequestHandler } from 'express';
55

66
describe('Schema Validation', () => {
77
let mockReq: Partial<Request>;
88
let mockRes: Partial<Response>;
99
let resJson: any;
1010
let resStatus: any;
1111

12+
const getNamedRouteHandler = (router: Router, path: string, name: string): RequestHandler => {
13+
const routeLayer = router.stack.find((entry) => entry.route?.path === path);
14+
const handler = routeLayer?.route?.stack.find((entry) => entry.name === name)?.handle;
15+
16+
if (!handler) {
17+
throw new Error(`Route handler not found for ${path}:${name}`);
18+
}
19+
20+
return handler;
21+
};
22+
1223
beforeEach(() => {
1324
resJson = vi.fn();
1425
resStatus = vi.fn().mockReturnValue({ json: resJson });
@@ -21,8 +32,8 @@ describe('Schema Validation', () => {
2132
describe('Invoice Validation', () => {
2233
it('returns 400 when projectId is missing', async () => {
2334
mockReq.body = { workDescription: 'Test' };
24-
const handler = (invoiceRouter.stack.find(s => s.route?.path === '/generate')?.route.stack.find((s: any) => s.name === 'validateMiddleware')?.handle);
25-
35+
const handler = getNamedRouteHandler(invoiceRouter, '/generate', 'validateMiddleware');
36+
2637
await handler(mockReq as Request, mockRes as Response, vi.fn());
2738

2839
expect(resStatus).toHaveBeenCalledWith(400);
@@ -42,8 +53,8 @@ describe('Schema Validation', () => {
4253
milestoneDescription: 'Test',
4354
projectId: 'P1'
4455
};
45-
const handler = (verificationRouter.stack.find(s => s.route?.path === '/verify')?.route.stack.find((s: any) => s.name === 'validateMiddleware')?.handle);
46-
56+
const handler = getNamedRouteHandler(verificationRouter, '/verify', 'validateMiddleware');
57+
4758
await handler(mockReq as Request, mockRes as Response, vi.fn());
4859

4960
expect(resStatus).toHaveBeenCalledWith(400);
@@ -56,8 +67,12 @@ describe('Schema Validation', () => {
5667

5768
it('returns 400 for empty bulk verification items', async () => {
5869
mockReq.body = { items: [] };
59-
const handler = (verificationRouter.stack.find(s => s.route?.path === '/verify/batch')?.route.stack.find((s: any) => s.name === 'validateMiddleware')?.handle);
60-
70+
const handler = getNamedRouteHandler(
71+
verificationRouter,
72+
'/verify/batch',
73+
'validateMiddleware'
74+
);
75+
6176
await handler(mockReq as Request, mockRes as Response, vi.fn());
6277

6378
expect(resStatus).toHaveBeenCalledWith(400);

backend/src/routes/catalog.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { Router } from 'express';
22
import { getCatalog } from '../services/catalog.js';
3+
import { asyncHandler } from '../middleware/errorHandler.js';
34

45
export const catalogRouter = Router();
56

6-
catalogRouter.get('/', (req, res) => {
7-
try {
7+
catalogRouter.get(
8+
'/',
9+
asyncHandler(async (_req, res) => {
810
const catalog = getCatalog();
911
res.json(catalog);
10-
} catch (error) {
11-
console.error('Catalog error:', error);
12-
res.status(500).json({ message: 'Failed to fetch catalog' });
13-
}
14-
});
12+
})
13+
);

0 commit comments

Comments
 (0)