From 580ccd09ff765b1eaab725afcaee58299a20475f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:36:44 -0400 Subject: [PATCH 1/7] feat(backend): add typed error classes and global error middleware Introduce AppError, NotFoundError, ValidationError, and AuthError with a consistent { error, message, code, requestId } response shape. Stack traces are included only in development. Closes #550 --- backend/src/app.ts | 24 +-- backend/src/errors/AppError.ts | 49 +++++++ backend/src/errors/index.ts | 1 + .../middleware/__tests__/errorHandler.test.ts | 138 ++++++++++++++++++ backend/src/middleware/errorHandler.ts | 99 +++++++++++++ 5 files changed, 291 insertions(+), 20 deletions(-) create mode 100644 backend/src/errors/AppError.ts create mode 100644 backend/src/errors/index.ts create mode 100644 backend/src/middleware/__tests__/errorHandler.test.ts create mode 100644 backend/src/middleware/errorHandler.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index e2a3bab4..ff86168c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,8 +4,6 @@ import morgan from 'morgan'; import helmet from 'helmet'; import path from 'path'; import { fileURLToPath } from 'url'; -import config from './config/index.js'; -import logger from './utils/logger.js'; import passport from './config/passport.js'; import { apiVersionMiddleware } from './middlewares/apiVersionMiddleware.js'; import { requestIdMiddleware } from './middleware/requestId.js'; @@ -33,6 +31,7 @@ import contractEventRoutes from './routes/contractEventRoutes.js'; import certificateRoutes from './routes/certificateRoutes.js'; import cashFlowForecastRoutes from './routes/cashFlowForecastRoutes.js'; import { HealthController } from './controllers/healthController.js'; +import { errorHandler, notFoundHandler } from './middleware/errorHandler.js'; // Part 49 — admin, audit integrity, per-tenant rate limits, quotas import adminRoutes from './routes/adminRoutes.js'; @@ -176,23 +175,8 @@ app.use('/api/audit-analytics', auditAnalyticsRoutes); app.use('/api/smart-rate-limit', smartRateLimitRoutes); app.use('/api/tenant-security', tenantSecurityRoutes); -// 404 handler -app.use((req, res) => { - res.status(404).json({ - error: 'Not Found', - path: req.path, - requestId: (req as any).requestId, - }); -}); - -// Error handler -app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { - logger.error('Unhandled error', { err, requestId: (req as any).requestId }); - res.status(500).json({ - error: 'Internal Server Error', - message: config.nodeEnv === 'development' ? err.message : 'An error occurred', - requestId: (req as any).requestId, - }); -}); +// 404 + global error handler (typed AppError responses) +app.use(notFoundHandler); +app.use(errorHandler); export default app; diff --git a/backend/src/errors/AppError.ts b/backend/src/errors/AppError.ts new file mode 100644 index 00000000..d29144db --- /dev/null +++ b/backend/src/errors/AppError.ts @@ -0,0 +1,49 @@ +/** + * Base application error with HTTP status and machine-readable code. + * Controllers and services throw subclasses; the global error middleware + * maps them to a consistent JSON response shape. + */ +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly isOperational: boolean; + + constructor( + message: string, + statusCode: number, + code: string, + isOperational = true + ) { + super(message); + this.name = this.constructor.name; + this.statusCode = statusCode; + this.code = code; + this.isOperational = isOperational; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Resource was not found (HTTP 404). */ +export class NotFoundError extends AppError { + constructor(message = 'Resource not found', code = 'NOT_FOUND') { + super(message, 404, code); + } +} + +/** Request failed validation (HTTP 400). */ +export class ValidationError extends AppError { + constructor(message = 'Validation failed', code = 'VALIDATION_ERROR') { + super(message, 400, code); + } +} + +/** Authentication or authorization failure (HTTP 401 / 403). */ +export class AuthError extends AppError { + constructor( + message = 'Authentication required', + statusCode: 401 | 403 = 401, + code = 'AUTH_ERROR' + ) { + super(message, statusCode, code); + } +} diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts new file mode 100644 index 00000000..57b9ed14 --- /dev/null +++ b/backend/src/errors/index.ts @@ -0,0 +1 @@ +export { AppError, NotFoundError, ValidationError, AuthError } from './AppError.js'; diff --git a/backend/src/middleware/__tests__/errorHandler.test.ts b/backend/src/middleware/__tests__/errorHandler.test.ts new file mode 100644 index 00000000..e6f5e91a --- /dev/null +++ b/backend/src/middleware/__tests__/errorHandler.test.ts @@ -0,0 +1,138 @@ +import { Request, Response, NextFunction } from 'express'; +import { errorHandler, notFoundHandler } from '../errorHandler.js'; +import { NotFoundError, ValidationError, AuthError, AppError } from '../../errors/index.js'; +import config from '../../config/index.js'; +import logger from '../../utils/logger.js'; + +jest.mock('../../config/index.js', () => ({ + __esModule: true, + default: { nodeEnv: 'test' }, +})); + +jest.mock('../../utils/logger.js', () => ({ + __esModule: true, + default: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +describe('errorHandler middleware', () => { + let req: Partial; + let res: Partial; + let next: NextFunction; + let statusMock: jest.Mock; + let jsonMock: jest.Mock; + + beforeEach(() => { + jsonMock = jest.fn().mockReturnThis(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + req = { + method: 'GET', + path: '/api/missing', + originalUrl: '/api/missing', + requestId: 'req-abc-123', + }; + res = { status: statusMock, json: jsonMock } as any; + next = jest.fn(); + (config as any).nodeEnv = 'test'; + jest.clearAllMocks(); + }); + + it('maps NotFoundError to consistent 404 payload', () => { + errorHandler(new NotFoundError('Employee not found'), req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(404); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'NotFoundError', + message: 'Employee not found', + code: 'NOT_FOUND', + requestId: 'req-abc-123', + }); + expect(jsonMock.mock.calls[0][0].stack).toBeUndefined(); + }); + + it('maps ValidationError to 400 with VALIDATION_ERROR code', () => { + errorHandler( + new ValidationError('email is required'), + req as Request, + res as Response, + next + ); + + expect(statusMock).toHaveBeenCalledWith(400); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'ValidationError', + message: 'email is required', + code: 'VALIDATION_ERROR', + requestId: 'req-abc-123', + }) + ); + }); + + it('maps AuthError to 401 by default', () => { + errorHandler(new AuthError(), req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(401); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'AuthError', + code: 'AUTH_ERROR', + requestId: 'req-abc-123', + }) + ); + }); + + it('maps AuthError with 403 when forbidden', () => { + errorHandler( + new AuthError('Forbidden', 403, 'FORBIDDEN'), + req as Request, + res as Response, + next + ); + + expect(statusMock).toHaveBeenCalledWith(403); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'FORBIDDEN', + message: 'Forbidden', + }) + ); + }); + + it('hides internal details for unknown errors outside development', () => { + errorHandler(new Error('SELECT * FROM secrets'), req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'InternalServerError', + message: 'An error occurred', + code: 'INTERNAL_ERROR', + requestId: 'req-abc-123', + }); + expect(logger.error).toHaveBeenCalled(); + }); + + it('includes stack traces only in development', () => { + (config as any).nodeEnv = 'development'; + const err = new AppError('boom', 500, 'BOOM'); + + errorHandler(err, req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(500); + const body = jsonMock.mock.calls[0][0]; + expect(body.stack).toEqual(expect.stringContaining('AppError')); + expect(body.message).toBe('boom'); + }); + + it('notFoundHandler forwards a NotFoundError to next', () => { + notFoundHandler(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + const forwarded = (next as jest.Mock).mock.calls[0][0]; + expect(forwarded).toBeInstanceOf(NotFoundError); + expect(forwarded.message).toContain('GET /api/missing'); + }); +}); diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 00000000..bf4c365a --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,99 @@ +import { Request, Response, NextFunction } from 'express'; +import config from '../config/index.js'; +import logger from '../utils/logger.js'; +import { AppError, NotFoundError } from '../errors/index.js'; + +/** Consistent error payload returned to clients. */ +export interface ErrorResponseBody { + error: string; + message: string; + code: string; + requestId?: string; + stack?: string; +} + +function requestIdOf(req: Request): string | undefined { + return typeof req.requestId === 'string' ? req.requestId : undefined; +} + +/** + * Express 404 fallback that uses the same response shape as the error handler. + */ +export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void { + next(new NotFoundError(`Cannot ${req.method} ${req.path}`)); +} + +/** + * Global error middleware. Maps AppError subclasses (and unknown errors) to + * `{ error, message, code, requestId }` and only includes stack traces in + * development. + */ +export function errorHandler( + err: unknown, + req: Request, + res: Response, + _next: NextFunction +): void { + const requestId = requestIdOf(req); + const isDev = config.nodeEnv === 'development'; + + if (err instanceof AppError) { + if (!err.isOperational || err.statusCode >= 500) { + logger.error('Operational/server error', { + err, + code: err.code, + statusCode: err.statusCode, + requestId, + path: req.originalUrl, + method: req.method, + }); + } else { + logger.warn('Client error', { + message: err.message, + code: err.code, + statusCode: err.statusCode, + requestId, + path: req.originalUrl, + method: req.method, + }); + } + + const body: ErrorResponseBody = { + error: err.name, + message: err.message, + code: err.code, + requestId, + }; + + if (isDev && err.stack) { + body.stack = err.stack; + } + + res.status(err.statusCode).json(body); + return; + } + + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + + logger.error('Unhandled error', { + message, + stack, + requestId, + path: req.originalUrl, + method: req.method, + }); + + const body: ErrorResponseBody = { + error: 'InternalServerError', + message: isDev ? message || 'An error occurred' : 'An error occurred', + code: 'INTERNAL_ERROR', + requestId, + }; + + if (isDev && stack) { + body.stack = stack; + } + + res.status(500).json(body); +} From 0c90c79a62e8a3a74163c70814ed7eb0d476f50b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:37:42 -0400 Subject: [PATCH 2/7] fix(ci): drop invalid top-level retention-days from workflows GitHub Actions rejects unknown workflow keys, so these files failed with zero jobs. --- .github/workflows/build.yml | 4 ---- .github/workflows/contract-release.yml | 8 ++------ .github/workflows/dapp-ipfs.yml | 4 ---- .github/workflows/secrets-check.yml | 4 ---- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52029992..68212ca7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,6 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig @@ -113,6 +112,3 @@ jobs: - name: Run Tests working-directory: ./frontend run: npm test --if-present - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 2655fcbc..6597cb2a 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -5,10 +5,9 @@ on: tags: - "v*" -# Retention policy: Keep successful runs for 90 days, failed/cancelled for 14 days -permissions: # required permissions for the workflow +permissions: id-token: write - contents: write # in order to create releases + contents: write attestations: write concurrency: @@ -27,6 +26,3 @@ jobs: package: "..." secrets: release_token: ${{ secrets.GITHUB_TOKEN }} - -# Workflow run retention settings -retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 29d16e55..5887cc7e 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -9,7 +9,6 @@ on: workflow_dispatch: -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true @@ -65,6 +64,3 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- CID: ${{ steps.storacha.outputs.cid }}" >> "$GITHUB_STEP_SUMMARY" echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index b8d3b7e8..5e989082 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -10,7 +10,6 @@ on: paths: - "k8s/**" -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days jobs: check-secrets-placeholders: name: Verify no real secrets in k8s manifests @@ -20,6 +19,3 @@ jobs: - name: Check backend-secret.yaml for non-placeholder values run: ./scripts/check-k8s-secrets.sh - -# Workflow run retention settings -retention-days: 30 \ No newline at end of file From db37bd4c6c65c3edbfcbc7df9eb4fe32298b12d6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:37:48 -0400 Subject: [PATCH 3/7] fix(ci): drop invalid top-level retention-days from workflows GitHub Actions rejects retention-days as a workflow root key, so every check on this branch failed at parse time. Artifact retention stays on upload-artifact steps where that key is valid. --- .github/workflows/build.yml | 1 + .github/workflows/contract-release.yml | 5 +++-- .github/workflows/dapp-ipfs.yml | 1 + .github/workflows/secrets-check.yml | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68212ca7..3241a1e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] +# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 6597cb2a..344b0121 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -5,9 +5,10 @@ on: tags: - "v*" -permissions: +# Retention policy: Keep successful runs for 90 days, failed/cancelled for 14 days +permissions: # required permissions for the workflow id-token: write - contents: write + contents: write # in order to create releases attestations: write concurrency: diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 5887cc7e..80b579de 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -9,6 +9,7 @@ on: workflow_dispatch: +# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index 5e989082..447caecf 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -10,6 +10,7 @@ on: paths: - "k8s/**" +# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days jobs: check-secrets-placeholders: name: Verify no real secrets in k8s manifests From 99fd9db5ab3f894f4a63c0003f6632cb8f216099 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:37:55 -0400 Subject: [PATCH 4/7] fix(ci): drop invalid top-level retention-days from workflow files GitHub Actions rejects retention-days at the workflow root, so every check on this branch failed before any job started. --- .github/workflows/build.yml | 2 +- .github/workflows/contract-release.yml | 1 - .github/workflows/dapp-ipfs.yml | 1 - .github/workflows/secrets-check.yml | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3241a1e7..2be064d1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days +# Retention is configured in the repo Actions settings, not as a workflow key. env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index 344b0121..cca80486 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -5,7 +5,6 @@ on: tags: - "v*" -# Retention policy: Keep successful runs for 90 days, failed/cancelled for 14 days permissions: # required permissions for the workflow id-token: write contents: write # in order to create releases diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 80b579de..5887cc7e 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -9,7 +9,6 @@ on: workflow_dispatch: -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index 447caecf..5e989082 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -10,7 +10,6 @@ on: paths: - "k8s/**" -# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days jobs: check-secrets-placeholders: name: Verify no real secrets in k8s manifests From eb41c7f9d8ed5fc9cbc5aff9f392372f2f50c5be Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:38:35 -0400 Subject: [PATCH 5/7] fix(ci): remove invalid top-level retention-days from workflow files --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2be064d1..68212ca7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,6 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] -# Retention is configured in the repo Actions settings, not as a workflow key. env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig From b6e71ff39f60a9ab60aa6e25ad83e1bd5d8394ad Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Fri, 25 Sep 2026 21:16:22 -0400 Subject: [PATCH 6/7] chore: keep this pull request scoped to its issue --- .github/workflows/build.yml | 4 ++++ .github/workflows/contract-release.yml | 4 ++++ .github/workflows/dapp-ipfs.yml | 4 ++++ .github/workflows/secrets-check.yml | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68212ca7..52029992 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: branches: ["main"] types: [opened, synchronize, reopened, ready_for_review] +# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days env: CARGO_TERM_COLOR: always PKG_CONFIG_PATH: /usr/lib/pkgconfig @@ -112,3 +113,6 @@ jobs: - name: Run Tests working-directory: ./frontend run: npm test --if-present + +# Workflow run retention settings +retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/contract-release.yml b/.github/workflows/contract-release.yml index cca80486..2655fcbc 100644 --- a/.github/workflows/contract-release.yml +++ b/.github/workflows/contract-release.yml @@ -5,6 +5,7 @@ on: tags: - "v*" +# Retention policy: Keep successful runs for 90 days, failed/cancelled for 14 days permissions: # required permissions for the workflow id-token: write contents: write # in order to create releases @@ -26,3 +27,6 @@ jobs: package: "..." secrets: release_token: ${{ secrets.GITHUB_TOKEN }} + +# Workflow run retention settings +retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/dapp-ipfs.yml b/.github/workflows/dapp-ipfs.yml index 5887cc7e..29d16e55 100644 --- a/.github/workflows/dapp-ipfs.yml +++ b/.github/workflows/dapp-ipfs.yml @@ -9,6 +9,7 @@ on: workflow_dispatch: +# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true @@ -64,3 +65,6 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "- CID: ${{ steps.storacha.outputs.cid }}" >> "$GITHUB_STEP_SUMMARY" echo "- URL: ${{ steps.storacha.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" + +# Workflow run retention settings +retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/secrets-check.yml b/.github/workflows/secrets-check.yml index 5e989082..b8d3b7e8 100644 --- a/.github/workflows/secrets-check.yml +++ b/.github/workflows/secrets-check.yml @@ -10,6 +10,7 @@ on: paths: - "k8s/**" +# Retention policy: Keep successful runs for 30 days, failed/cancelled for 7 days jobs: check-secrets-placeholders: name: Verify no real secrets in k8s manifests @@ -19,3 +20,6 @@ jobs: - name: Check backend-secret.yaml for non-placeholder values run: ./scripts/check-k8s-secrets.sh + +# Workflow run retention settings +retention-days: 30 \ No newline at end of file From c664d0d63ceceaf81bbf1ceab025513d63044774 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Fri, 25 Sep 2026 21:23:36 -0400 Subject: [PATCH 7/7] fix(errors): delegate once response headers are sent --- backend/src/middleware/errorHandler.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index bf4c365a..310f6a7f 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -32,8 +32,13 @@ export function errorHandler( err: unknown, req: Request, res: Response, - _next: NextFunction + next: NextFunction ): void { + if (res.headersSent) { + next(err); + return; + } + const requestId = requestIdOf(req); const isDev = config.nodeEnv === 'development';