From 97b9319d714eef81f57dafe042029a6d66ec310d Mon Sep 17 00:00:00 2001 From: shogun444 Date: Thu, 25 Jun 2026 13:37:37 +0530 Subject: [PATCH] feat(observability): implement global error handling and standardized error responses --- docs/error-codes.md | 156 +++------------ src/app.module.ts | 4 +- src/common/exceptions/error-codes.ts | 13 ++ .../global-exception.filter.spec.ts | 79 +++++--- .../interceptors/global-exception.filter.ts | 83 +++++++- test/error-handling.e2e-spec.ts | 187 ++++++++++++++++++ 6 files changed, 355 insertions(+), 167 deletions(-) create mode 100644 src/common/exceptions/error-codes.ts create mode 100644 test/error-handling.e2e-spec.ts diff --git a/docs/error-codes.md b/docs/error-codes.md index e63bd143..98e4c65f 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -1,140 +1,32 @@ -# Error Codes & Responses +# Standardized API Error Codes -All API errors follow a consistent response envelope and use custom exception classes. - -## Response Envelope - -Every error response returns this JSON structure: - -```json -{ - "success": false, - "statusCode": 404, - "message": "Course with id 'abc-123' was not found", - "path": "/api/v1/courses/abc-123", - "timestamp": "2026-06-24T10:00:00.000Z", - "correlationId": "req-abc-123-def" -} -``` - -## HTTP Status Code Reference - -| Status | Code | Scenario | Exception Class | -|--------|------|----------|----------------| -| 400 | `BAD_REQUEST` | Invalid JSON, missing headers, malformed input | `BadRequestException` (NestJS built-in) | -| 401 | `UNAUTHORIZED` | Missing or invalid JWT, no auth context | `UnauthorizedException` (NestJS built-in) | -| 401 | `INVALID_CREDENTIALS` | Bad credentials, user not found in JWT | `InvalidCredentialsException` | -| 401 | `INVALID_TOKEN` | Token expired, already used, or invalid | `InvalidTokenException` | -| 403 | `FORBIDDEN` | Authenticated but insufficient permissions | `ForbiddenOperationException` | -| 404 | `NOT_FOUND` | Resource does not exist | `ResourceNotFoundException` | -| 409 | `CONFLICT` | Duplicate resource (email, slug, etc.) | `ResourceConflictException` | -| 422 | `UNPROCESSABLE_ENTITY` | Business rule violation (valid JSON, bad semantics) | `BusinessValidationException` | -| 429 | `TOO_MANY_REQUESTS` | Rate limit exceeded | `RateLimitExceededException` | -| 500 | `INTERNAL_SERVER_ERROR` | Unexpected server error (never exposes internals) | Unhandled exceptions | -| 503 | `SERVICE_UNAVAILABLE` | External dependency is down | `ServiceUnavailableException` | - -## Custom Exception Reference - -### `ResourceNotFoundException` (404) - -```typescript -throw new ResourceNotFoundException('Course', courseId); -// => "Course with id 'abc-123' was not found" - -throw new ResourceNotFoundException('User'); -// => "User was not found" -``` - -### `ForbiddenOperationException` (403) - -```typescript -throw new ForbiddenOperationException(); -// => "You do not have permission to perform this action" - -throw new ForbiddenOperationException('Only course instructors can publish courses'); -``` - -### `ResourceConflictException` (409) - -```typescript -throw new ResourceConflictException('User', 'email'); -// => "User with this email already exists" - -throw new ResourceConflictException('Tenant'); -// => "Tenant already exists" -``` - -### `BusinessValidationException` (422) - -```typescript -throw new BusinessValidationException('Cannot submit a PUBLISHED course for review.'); -``` - -### `InvalidCredentialsException` (401) - -```typescript -throw new InvalidCredentialsException('User not found'); -// => "User not found" - -throw new InvalidCredentialsException(); -// => "Invalid credentials" -``` - -### `InvalidTokenException` (401) - -```typescript -throw new InvalidTokenException('Password reset token has expired'); -// => "Password reset token has expired" - -throw new InvalidTokenException(); -// => "Invalid or expired token" -``` - -### `ServiceUnavailableException` (503) - -```typescript -throw new ServiceUnavailableException('Elasticsearch'); -// => "Elasticsearch is currently unavailable. Please try again later." -``` - -### `RateLimitExceededException` (429) - -```typescript -throw new RateLimitExceededException(60); -// Returns: -// { -// message: "You have exceeded the request rate limit. Please wait before retrying.", -// error: "Too Many Requests", -// statusCode: 429, -// retryAfterSeconds: 60 -// } -``` - -## Validation Errors - -DTO validation failures (class-validator) return HTTP 400 with field-level details: +This document lists the standardized API error codes used across the TeachLink backend system. All error responses return a standardized JSON structure: ```json { "success": false, - "statusCode": 400, - "message": ["email must be a valid email address", "name should not be empty"], - "path": "/api/v1/auth/register", - "timestamp": "2026-06-24T10:00:00.000Z", - "correlationId": "req-abc-123-def" + "error": { + "code": "AUTH_001", + "message": "Invalid credentials", + "statusCode": 401, + "timestamp": "2026-05-26T10:30:00Z", + "requestId": "req-123456" + } } ``` -## Rate Limit Error Response - -When rate limited, the response includes standard headers: - -```text -HTTP/1.1 429 Too Many Requests -Retry-After: 3600 -X-RateLimit-Limit: 3 -X-RateLimit-Remaining: 0 -X-RateLimit-Reset: 1719230400 -``` - -See [rate-limiting.md](./rate-limiting.md) for details on rate limit tiers. +## Error Codes Mapping + +| Error Code | HTTP Status | Exception Class | Description | +|---|---|---|---| +| **`AUTH_001`** | 401 Unauthorized | `InvalidCredentialsException` | The username or password provided is incorrect. | +| **`AUTH_002`** | 401 Unauthorized | `InvalidTokenException` | The authentication token (e.g. password reset token) is invalid, expired, or already used. | +| **`AUTH_003`** | 403 Forbidden | `ForbiddenOperationException` | The user does not have permission to perform this action. | +| **`AUTH_004`** | 401 Unauthorized | `UnauthorizedException` (built-in) | Authentication context is missing (e.g., missing JWT). | +| **`RES_001`** | 404 Not Found | `ResourceNotFoundException` | The requested resource (course, user, etc.) could not be found. | +| **`RES_002`** | 409 Conflict | `ResourceConflictException` | A duplicate resource conflict occurred (e.g., email already registered). | +| **`VAL_001`** | 400 Bad Request | `BadRequestException` (built-in) | Input data failed validation or parsing constraints. | +| **`BUS_001`** | 422 Unprocessable Entity | `BusinessValidationException` | A business logic or state machine transition rule was violated. | +| **`SYS_001`** | 500 Internal Server Error | Generic / Unhandled | An unexpected error occurred on the server. | +| **`SYS_002`** | 503 Service Unavailable | `ServiceUnavailableException` | An external dependency or service is currently down or unreachable. | +| **`SYS_003`** | 429 Too Many Requests | `RateLimitExceededException` | The request limit has been exceeded. | diff --git a/src/app.module.ts b/src/app.module.ts index 464db8d1..81dd098b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; @@ -20,6 +20,7 @@ import { CanaryModule } from './canary/canary.module'; import { IncidentManagementModule } from './incident-management/incident-management.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout.interceptor'; +import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter'; import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { ReportingModule } from './payments/reporting/reporting.module'; @@ -71,6 +72,7 @@ const featureFlags = loadFeatureFlags(); providers: [ ...(featureFlags.ENABLE_RATE_LIMITING ? [{ provide: APP_GUARD, useClass: QuotaGuard }] : []), { provide: APP_INTERCEPTOR, useClass: RequestTimeoutInterceptor }, + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, ], }) export class AppModule {} diff --git a/src/common/exceptions/error-codes.ts b/src/common/exceptions/error-codes.ts new file mode 100644 index 00000000..60c4752c --- /dev/null +++ b/src/common/exceptions/error-codes.ts @@ -0,0 +1,13 @@ +export enum ErrorCode { + AUTH_INVALID_CREDENTIALS = 'AUTH_001', + AUTH_INVALID_TOKEN = 'AUTH_002', + AUTH_FORBIDDEN = 'AUTH_003', + AUTH_UNAUTHORIZED = 'AUTH_004', + RESOURCE_NOT_FOUND = 'RES_001', + RESOURCE_CONFLICT = 'RES_002', + VALIDATION_ERROR = 'VAL_001', + BUSINESS_RULE_VIOLATION = 'BUS_001', + INTERNAL_SERVER_ERROR = 'SYS_001', + SERVICE_UNAVAILABLE = 'SYS_002', + RATE_LIMIT_EXCEEDED = 'SYS_003', +} diff --git a/src/common/interceptors/global-exception.filter.spec.ts b/src/common/interceptors/global-exception.filter.spec.ts index fe735137..d4d29ea1 100644 --- a/src/common/interceptors/global-exception.filter.spec.ts +++ b/src/common/interceptors/global-exception.filter.spec.ts @@ -1,5 +1,6 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { GlobalExceptionFilter } from './global-exception.filter'; +import { ErrorCode } from '../exceptions/error-codes'; import { ResourceNotFoundException, ForbiddenOperationException, @@ -9,11 +10,15 @@ jest.mock('../utils/correlation.utils', () => ({ getCorrelationId: () => 'test-correlation-id', })); -function buildMockHost(overrides: { url?: string; method?: string } = {}) { +function buildMockHost(overrides: { url?: string; method?: string; requestId?: string } = {}) { const json = jest.fn(); const status = jest.fn().mockReturnValue({ json }); const response = { status }; - const request = { url: overrides.url ?? '/test', method: overrides.method ?? 'GET' }; + const request = { + url: overrides.url ?? '/test', + method: overrides.method ?? 'GET', + requestId: overrides.requestId ?? 'test-request-id', + }; return { switchToHttp: () => ({ @@ -38,13 +43,16 @@ describe('GlobalExceptionFilter', () => { filter.catch(new HttpException('bad request', HttpStatus.BAD_REQUEST), mock as any); expect(mock.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); - expect(mock.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, + expect(mock.json).toHaveBeenCalledWith({ + success: false, + error: { + code: ErrorCode.INTERNAL_SERVER_ERROR, statusCode: HttpStatus.BAD_REQUEST, - correlationId: 'test-correlation-id', - }), - ); + message: 'bad request', + timestamp: expect.any(String), + requestId: 'test-request-id', + }, + }); }); it('maps unknown errors to 500', () => { @@ -52,13 +60,16 @@ describe('GlobalExceptionFilter', () => { filter.catch(new Error('db crashed'), mock as any); expect(mock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); - expect(mock.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, + expect(mock.json).toHaveBeenCalledWith({ + success: false, + error: { + code: ErrorCode.INTERNAL_SERVER_ERROR, statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'db crashed', - }), - ); + timestamp: expect.any(String), + requestId: 'test-request-id', + }, + }); }); it('maps ResourceNotFoundException to 404', () => { @@ -66,12 +77,16 @@ describe('GlobalExceptionFilter', () => { filter.catch(new ResourceNotFoundException('Course', 'abc'), mock as any); expect(mock.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); - expect(mock.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect(mock.json).toHaveBeenCalledWith({ + success: false, + error: { + code: ErrorCode.RESOURCE_NOT_FOUND, statusCode: HttpStatus.NOT_FOUND, message: "Course with id 'abc' was not found", - }), - ); + timestamp: expect.any(String), + requestId: 'test-request-id', + }, + }); }); it('maps ForbiddenOperationException to 403', () => { @@ -79,12 +94,16 @@ describe('GlobalExceptionFilter', () => { filter.catch(new ForbiddenOperationException('Only owners may delete'), mock as any); expect(mock.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN); - expect(mock.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect(mock.json).toHaveBeenCalledWith({ + success: false, + error: { + code: ErrorCode.AUTH_FORBIDDEN, statusCode: HttpStatus.FORBIDDEN, message: 'Only owners may delete', - }), - ); + timestamp: expect.any(String), + requestId: 'test-request-id', + }, + }); }); it('includes path and timestamp in every response', () => { @@ -92,8 +111,9 @@ describe('GlobalExceptionFilter', () => { filter.catch(new HttpException('not found', 404), mock as any); const call = mock.json.mock.calls[0][0]; - expect(call.path).toBe('/api/courses/1'); - expect(call.timestamp).toBeDefined(); + expect(call.success).toBe(false); + expect(call.error.timestamp).toBeDefined(); + expect(call.error.requestId).toBe('test-request-id'); }); it('handles non-Error thrown objects', () => { @@ -101,8 +121,15 @@ describe('GlobalExceptionFilter', () => { filter.catch('a plain string error', mock as any); expect(mock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); - expect(mock.json).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Internal server error' }), - ); + expect(mock.json).toHaveBeenCalledWith({ + success: false, + error: { + code: ErrorCode.INTERNAL_SERVER_ERROR, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Internal server error', + timestamp: expect.any(String), + requestId: 'test-request-id', + }, + }); }); }); diff --git a/src/common/interceptors/global-exception.filter.ts b/src/common/interceptors/global-exception.filter.ts index 84b372e2..1cc794ad 100644 --- a/src/common/interceptors/global-exception.filter.ts +++ b/src/common/interceptors/global-exception.filter.ts @@ -5,9 +5,69 @@ import { HttpException, HttpStatus, Logger, + BadRequestException, + UnauthorizedException, } from '@nestjs/common'; import { Request, Response } from 'express'; import { getCorrelationId } from '../utils/correlation.utils'; +import { ErrorCode } from '../exceptions/error-codes'; +import { + ResourceNotFoundException, + ForbiddenOperationException, + ResourceConflictException, + BusinessValidationException, + ServiceUnavailableException, + InvalidCredentialsException, + InvalidTokenException, + RateLimitExceededException, +} from '../exceptions/app.exceptions'; + +export function getErrorCode(exception: unknown): ErrorCode { + if (exception instanceof InvalidCredentialsException) { + return ErrorCode.AUTH_INVALID_CREDENTIALS; + } + if (exception instanceof InvalidTokenException) { + return ErrorCode.AUTH_INVALID_TOKEN; + } + if (exception instanceof ForbiddenOperationException) { + return ErrorCode.AUTH_FORBIDDEN; + } + if (exception instanceof UnauthorizedException) { + return ErrorCode.AUTH_UNAUTHORIZED; + } + if (exception instanceof ResourceNotFoundException) { + return ErrorCode.RESOURCE_NOT_FOUND; + } + if (exception instanceof ResourceConflictException) { + return ErrorCode.RESOURCE_CONFLICT; + } + if (exception instanceof BadRequestException) { + return ErrorCode.VALIDATION_ERROR; + } + if (exception instanceof BusinessValidationException) { + return ErrorCode.BUSINESS_RULE_VIOLATION; + } + if (exception instanceof RateLimitExceededException) { + return ErrorCode.RATE_LIMIT_EXCEEDED; + } + if (exception instanceof ServiceUnavailableException) { + return ErrorCode.SERVICE_UNAVAILABLE; + } + + const constructorName = exception?.constructor?.name; + if (constructorName === 'InvalidCredentialsException') return ErrorCode.AUTH_INVALID_CREDENTIALS; + if (constructorName === 'InvalidTokenException') return ErrorCode.AUTH_INVALID_TOKEN; + if (constructorName === 'ForbiddenOperationException') return ErrorCode.AUTH_FORBIDDEN; + if (constructorName === 'UnauthorizedException') return ErrorCode.AUTH_UNAUTHORIZED; + if (constructorName === 'ResourceNotFoundException') return ErrorCode.RESOURCE_NOT_FOUND; + if (constructorName === 'ResourceConflictException') return ErrorCode.RESOURCE_CONFLICT; + if (constructorName === 'BadRequestException') return ErrorCode.VALIDATION_ERROR; + if (constructorName === 'BusinessValidationException') return ErrorCode.BUSINESS_RULE_VIOLATION; + if (constructorName === 'RateLimitExceededException') return ErrorCode.RATE_LIMIT_EXCEEDED; + if (constructorName === 'ServiceUnavailableException') return ErrorCode.SERVICE_UNAVAILABLE; + + return ErrorCode.INTERNAL_SERVER_ERROR; +} @Catch() export class GlobalExceptionFilter implements ExceptionFilter { @@ -20,7 +80,8 @@ export class GlobalExceptionFilter implements ExceptionFilter { const isHttpException = exception instanceof HttpException; const status = isHttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const exceptionResponse = isHttpException ? exception.getResponse() : undefined; - const message = + + const rawMessage = typeof exceptionResponse === 'object' && exceptionResponse !== null && 'message' in exceptionResponse @@ -29,25 +90,31 @@ export class GlobalExceptionFilter implements ExceptionFilter { ? exception.message : 'Internal server error'; + const message = Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage; + const errorCode = getErrorCode(exception); + const requestId = (request as any).requestId || getCorrelationId() || 'unknown'; + if (!isHttpException) { this.logger.error( - `Unhandled exception on ${request.method} ${request.url}`, + `Unhandled exception on ${request.method} ${request.url} [Request ID: ${requestId}]`, exception instanceof Error ? exception.stack : String(exception), ); } else if (status >= 500) { this.logger.error( - `Server error ${status} on ${request.method} ${request.url}`, + `Server error ${status} on ${request.method} ${request.url} [Request ID: ${requestId}]`, exception.stack, ); } response.status(status).json({ success: false, - statusCode: status, - message, - path: request.url, - timestamp: new Date().toISOString(), - correlationId: getCorrelationId(), + error: { + code: errorCode, + message, + statusCode: status, + timestamp: new Date().toISOString(), + requestId, + }, }); } } diff --git a/test/error-handling.e2e-spec.ts b/test/error-handling.e2e-spec.ts new file mode 100644 index 00000000..43084ecf --- /dev/null +++ b/test/error-handling.e2e-spec.ts @@ -0,0 +1,187 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + INestApplication, + Controller, + Get, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { GlobalExceptionFilter } from '../src/common/interceptors/global-exception.filter'; +import { ErrorCode } from '../src/common/exceptions/error-codes'; +import { + ResourceNotFoundException, + ForbiddenOperationException, + ResourceConflictException, + BusinessValidationException, + ServiceUnavailableException, + InvalidCredentialsException, + InvalidTokenException, + RateLimitExceededException, +} from '../src/common/exceptions/app.exceptions'; +import supertest from 'supertest'; + +@Controller('error-test') +class ErrorTestController { + @Get('400') + throw400() { + throw new BadRequestException('Validation failed'); + } + + @Get('401-credentials') + throw401Credentials() { + throw new InvalidCredentialsException('Invalid login'); + } + + @Get('401-token') + throw401Token() { + throw new InvalidTokenException('Token expired'); + } + + @Get('401-builtin') + throw401Builtin() { + throw new UnauthorizedException('Auth context missing'); + } + + @Get('403') + throw403() { + throw new ForbiddenOperationException('Permission denied'); + } + + @Get('404') + throw404() { + throw new ResourceNotFoundException('User', '123'); + } + + @Get('409') + throw409() { + throw new ResourceConflictException('User', 'email'); + } + + @Get('422') + throw422() { + throw new BusinessValidationException('Business rule violation'); + } + + @Get('429') + throw429() { + throw new RateLimitExceededException(30); + } + + @Get('503') + throw503() { + throw new ServiceUnavailableException('BillingService'); + } + + @Get('500') + throw500() { + throw new Error('Unhandled database crash'); + } +} + +describe('Error Handling (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [ErrorTestController], + providers: [ + { + provide: APP_FILTER, + useClass: GlobalExceptionFilter, + }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + const assertErrorResponse = ( + response: any, + expectedStatus: number, + expectedCode: string, + expectedMessage: string, + ) => { + expect(response.status).toBe(expectedStatus); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toHaveProperty('statusCode', expectedStatus); + expect(response.body.error).toHaveProperty('code', expectedCode); + expect(response.body.error).toHaveProperty('message'); + expect(response.body.error.message).toContain(expectedMessage); + expect(response.body.error).toHaveProperty('timestamp'); + expect(response.body.error).toHaveProperty('requestId'); + }; + + it('VAL_001 / 400 Bad Request', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/400'); + assertErrorResponse(res, 400, ErrorCode.VALIDATION_ERROR, 'Validation failed'); + }); + + it('AUTH_001 / 401 Invalid Credentials', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/401-credentials'); + assertErrorResponse(res, 401, ErrorCode.AUTH_INVALID_CREDENTIALS, 'Invalid login'); + }); + + it('AUTH_002 / 401 Invalid Token', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/401-token'); + assertErrorResponse(res, 401, ErrorCode.AUTH_INVALID_TOKEN, 'Token expired'); + }); + + it('AUTH_004 / 401 Unauthorized Built-in', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/401-builtin'); + assertErrorResponse(res, 401, ErrorCode.AUTH_UNAUTHORIZED, 'Auth context missing'); + }); + + it('AUTH_003 / 403 Forbidden Operation', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/403'); + assertErrorResponse(res, 403, ErrorCode.AUTH_FORBIDDEN, 'Permission denied'); + }); + + it('RES_001 / 404 Resource Not Found', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/404'); + assertErrorResponse(res, 404, ErrorCode.RESOURCE_NOT_FOUND, "User with id '123' was not found"); + }); + + it('RES_002 / 409 Resource Conflict', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/409'); + assertErrorResponse( + res, + 409, + ErrorCode.RESOURCE_CONFLICT, + 'User with this email already exists', + ); + }); + + it('BUS_001 / 422 Business Validation Exception', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/422'); + assertErrorResponse(res, 422, ErrorCode.BUSINESS_RULE_VIOLATION, 'Business rule violation'); + }); + + it('SYS_003 / 429 Rate Limit Exceeded', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/429'); + assertErrorResponse(res, 429, ErrorCode.RATE_LIMIT_EXCEEDED, 'exceeded the request rate limit'); + }); + + it('SYS_002 / 503 Service Unavailable', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/503'); + assertErrorResponse( + res, + 503, + ErrorCode.SERVICE_UNAVAILABLE, + 'BillingService is currently unavailable', + ); + }); + + it('SYS_001 / 500 Unhandled Error', async () => { + const res = await supertest(app.getHttpServer()).get('/error-test/500'); + assertErrorResponse(res, 500, ErrorCode.INTERNAL_SERVER_ERROR, 'Unhandled database crash'); + }); +});