Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions listener/src/api/error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import http from 'http';
import logger from '../utils/logger';
import { sendErr, ErrorCode } from '../utils/response';

export class ApiError extends Error {
public readonly statusCode: number;
public readonly errorCode: string;
public readonly details?: unknown;

constructor(
message: string,
statusCode: number,
errorCode: string = ErrorCode.INTERNAL_ERROR,
details?: unknown,
) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
this.errorCode = errorCode;
this.details = details;
}

static badRequest(message: string, details?: unknown): ApiError {
return new ApiError(message, 400, ErrorCode.BAD_REQUEST, details);
}

static unauthorized(message: string): ApiError {
return new ApiError(message, 401, ErrorCode.UNAUTHORIZED);
}

static notFound(message: string): ApiError {
return new ApiError(message, 404, ErrorCode.NOT_FOUND);
}

static conflict(message: string): ApiError {
return new ApiError(message, 409, ErrorCode.CONFLICT);
}

static unprocessable(message: string, details?: unknown): ApiError {
return new ApiError(message, 422, ErrorCode.UNPROCESSABLE, details);
}

static payloadTooLarge(message: string, details?: unknown): ApiError {
return new ApiError(message, 413, ErrorCode.PAYLOAD_TOO_LARGE, details);
}

static rateLimited(message: string): ApiError {
return new ApiError(message, 429, ErrorCode.RATE_LIMITED);
}

static serviceUnavailable(message: string): ApiError {
return new ApiError(message, 503, ErrorCode.SERVICE_UNAVAILABLE);
}

static internal(message: string, details?: unknown): ApiError {
return new ApiError(message, 500, ErrorCode.INTERNAL_ERROR, details);
}
}

export function handleApiError(
res: http.ServerResponse,
error: unknown,
requestId?: string,
correlationId?: string,
): void {
if (error instanceof ApiError) {
logger.error('API error', {
requestId,
correlationId,
statusCode: error.statusCode,
errorCode: error.errorCode,
message: error.message,
});
sendErr(res, error.statusCode, error.message, error.errorCode, error.details);
return;
}

const message = error instanceof Error ? error.message : String(error);
logger.error('Unhandled API error', {
requestId,
correlationId,
error,
});
sendErr(res, 500, message, ErrorCode.INTERNAL_ERROR);
}

export function wrapAsyncHandler(
handler: (req: http.IncomingMessage, res: http.ServerResponse, url: URL) => Promise<void>,
): (req: http.IncomingMessage, res: http.ServerResponse, url: URL) => void {
return (req: http.IncomingMessage, res: http.ServerResponse, url: URL): void => {
handler(req, res, url).catch((error: unknown) => {
handleApiError(res, error);
});
};
}
148 changes: 49 additions & 99 deletions listener/src/api/events-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { generateRequestId, resolveCorrelationId } from '../utils/request-id';
import { TemplateService } from '../services/template-service';
import { handleTemplateRoutes } from './template-routes';
import { sendOk, sendErr, sendJson, ErrorCode } from '../utils/response';
import { handleApiError, ApiError } from './error-handler';
import { applyRequestContext } from '../utils/request-id';
import { TemplateService } from '../services/template-service';
import { handleTemplateRoutes } from './template-routes';
Expand Down Expand Up @@ -466,14 +467,12 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
handleTemplateRoutes(req, res, requestId, options.schedulerTemplateService)
.then((handled) => {
if (!handled) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
sendErr(res, 404, 'Not found', ErrorCode.NOT_FOUND);
}
})
.catch((error) => {
logger.error('Template route handler error', { error, requestId, correlationId });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal server error' }));
handleApiError(res, error, requestId, correlationId);
});
return;
}
Expand Down Expand Up @@ -713,8 +712,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
// POST /api/notifications/import — bulk import from JSON or CSV
if (req.method === 'POST' && url.pathname === '/api/notifications/import') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
sendErr(res, 503, 'Scheduler not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

Expand All @@ -723,8 +721,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
const provided = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
const allowed = options.apiKeys.some((k) => k.key === provided);
if (!allowed) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
sendErr(res, 401, 'Unauthorized', ErrorCode.UNAUTHORIZED);
return;
}
}
Expand All @@ -736,8 +733,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
const contentType = req.headers['content-type'] || '';
const importer = new NotificationImportService(options.notificationAPI!);
const summary = await importer.importFromBody(body, contentType, { requestId });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(summary));
sendOk(res, 200, summary);
logger.info('Bulk notification import finished', {
requestId,
correlationId,
Expand All @@ -746,8 +742,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
});
} catch (error) {
logger.error('Failed to import notifications', { error, requestId, correlationId });
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
handleApiError(res, error, requestId, correlationId);
}
});
return;
Expand Down Expand Up @@ -862,14 +857,11 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
const monitor = getJobMonitor();
const limitParam = url.searchParams.get('limit');
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 25, 1), 200) : 25;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
...monitor.getSnapshot(),
recentJobs: monitor.listRecentJobs(limit),
recentFailures: monitor.listFailures(limit),
})
);
sendOk(res, 200, {
...monitor.getSnapshot(),
recentJobs: monitor.listRecentJobs(limit),
recentFailures: monitor.listFailures(limit),
});
return;
}

Expand All @@ -878,67 +870,58 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
const monitor = getJobMonitor();
const limitParam = url.searchParams.get('limit');
const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 50, 1), 200) : 50;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ failures: monitor.listFailures(limit), count: monitor.listFailures(limit).length }));
// GET /api/schedule/execution-metrics
sendOk(res, 200, { failures: monitor.listFailures(limit), count: monitor.listFailures(limit).length });
return;
}
if (req.method === 'GET' && url.pathname === '/api/schedule/execution-metrics') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
sendErr(res, 503, 'Scheduler not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

options.notificationAPI.getExecutionMetrics()
.then((metrics) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(metrics));
sendOk(res, 200, metrics);
})
.catch((error) => {
logger.error('Failed to get execution metrics', { error, requestId, correlationId });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
handleApiError(res, error, requestId, correlationId);
});
return;
}

// GET /api/schedule/retry-distribution
if (req.method === 'GET' && url.pathname === '/api/schedule/retry-distribution') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
sendErr(res, 503, 'Scheduler not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

options.notificationAPI.getRetryDistribution()
.then((distribution) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(distribution));
sendOk(res, 200, distribution);
})
.catch((error) => {
logger.error('Failed to get retry distribution', { error, requestId, correlationId });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
handleApiError(res, error, requestId, correlationId);
});
return;
}

// GET /api/schedule/retry-statistics
if (req.method === 'GET' && url.pathname === '/api/schedule/retry-statistics') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
sendErr(res, 503, 'Scheduler not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

options.notificationAPI.getRetryStatistics()
.then((stats) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(stats));
sendOk(res, 200, stats);
})
.catch((error) => {
logger.error('Failed to get retry statistics', { error, requestId, correlationId });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
handleApiError(res, error, requestId, correlationId);
});
return;
}
Expand All @@ -949,8 +932,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
// retryCount. Accepts an optional ?limit= query param (default 100).
if (req.method === 'GET' && url.pathname === '/api/schedule/queue') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
sendErr(res, 503, 'Scheduler not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

Expand All @@ -965,13 +947,11 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
count: jobs.length,
durationMs: Date.now() - startTime,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ count: jobs.length, jobs }));
sendOk(res, 200, { count: jobs.length, jobs });
})
.catch((error) => {
logger.error('Failed to get pending jobs', { error, requestId, correlationId });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
handleApiError(res, error, requestId, correlationId);
});
return;
}
Expand Down Expand Up @@ -1124,10 +1104,6 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
suggestionService.getSuggestions(q, limit)
.then((result) => {
sendOk(res, 200, result);
logger.info('GET /api/search/suggestions complete', { requestId, durationMs: Date.now() - startTime });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));

logger.info('GET /api/search/suggestions complete', {
requestId,
correlationId,
Expand All @@ -1147,16 +1123,11 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
sendErr(res, 503, 'Template service not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

logger.info('Handling GET /api/templates', { requestId, correlationId });
(options.templateService as any).listAll()
.then((templates: any[]) => { sendOk(res, 200, templates.map(serializeTemplate)); })
.catch((error: Error) => {
logger.info('Handling GET /api/templates', { requestId, correlationId });
options.templateService.listAll()
.then((templates) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(templates.map(serializeTemplate)));
})
.catch((error) => {
logger.error('Failed to list templates', { error, requestId, correlationId });
sendErr(res, 500, error.message, ErrorCode.INTERNAL_ERROR);
});
Expand Down Expand Up @@ -1260,23 +1231,6 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
return;
}

// GET /api/templates (getAll — duplicate handler kept for compatibility)
if (req.method === 'GET' && url.pathname === '/api/templates') {
if (!options.templateService) {
sendErr(res, 503, 'Template service not enabled', ErrorCode.SERVICE_UNAVAILABLE);
return;
}

logger.info('Handling GET /api/templates', { requestId, correlationId });
(options.templateService as any).getAll()
.then((templates: any[]) => { sendOk(res, 200, templates.map(serializeTemplate)); })
.catch((error: Error) => {
logger.error('Failed to load templates', { error, requestId, correlationId });
sendErr(res, 500, error.message, ErrorCode.INTERNAL_ERROR);
});
return;
}

// DELETE /api/templates/:id
const deleteTemplateMatch = url.pathname.match(/^\/api\/templates\/([^/]+)$/);
if (req.method === 'DELETE' && deleteTemplateMatch) {
Expand Down Expand Up @@ -1425,30 +1379,26 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
if (handled) return;
}

logger.warn('Unhandled request', { requestId, method: req.method, url: req.url });
sendErr(res, 404, 'Not found', ErrorCode.NOT_FOUND);
// GET /api/metrics/response-time — expose response-time counters (#491)
if (req.method === 'GET' && url.pathname === '/api/metrics/response-time') {
const metrics = responseTime.getMetrics();
const reset = url.searchParams.get('reset') === 'true';
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(metrics));
if (reset) {
responseTime.resetMetrics();
logger.info('Response-time metrics reset', { requestId });
}
return;
}

logger.warn('Unhandled request', {
requestId,
correlationId,
method: req.method,
url: req.url,
});
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
responseTime.finish(req, res, requestId, 404);
// GET /api/metrics/response-time — expose response-time counters (#491)
if (req.method === 'GET' && url.pathname === '/api/metrics/response-time') {
const metrics = responseTime.getMetrics();
const reset = url.searchParams.get('reset') === 'true';
sendOk(res, 200, metrics);
if (reset) {
responseTime.resetMetrics();
logger.info('Response-time metrics reset', { requestId });
}
return;
}

logger.warn('Unhandled request', {
requestId,
correlationId,
method: req.method,
url: req.url,
});
sendErr(res, 404, 'Not found', ErrorCode.NOT_FOUND);
responseTime.finish(req, res, requestId, 404);
});

if (rateLimiter) {
Expand Down
Loading
Loading