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
156 changes: 24 additions & 132 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
@@ -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. |
4 changes: 3 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 {}
13 changes: 13 additions & 0 deletions src/common/exceptions/error-codes.ts
Original file line number Diff line number Diff line change
@@ -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',
}
79 changes: 53 additions & 26 deletions src/common/interceptors/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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: () => ({
Expand All @@ -38,71 +43,93 @@ 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', () => {
const mock = buildMockHost();
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', () => {
const mock = buildMockHost();
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', () => {
const mock = buildMockHost();
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', () => {
const mock = buildMockHost({ url: '/api/courses/1' });
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', () => {
const mock = buildMockHost();
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',
},
});
});
});
Loading
Loading