From 10049e17e84e51f55e97dc63a4b1af95fc5b21a6 Mon Sep 17 00:00:00 2001 From: Muhammad Aaqil Date: Thu, 2 Jul 2026 11:12:15 +0500 Subject: [PATCH 1/2] feat: handle database errors Signed-off-by: Muhammad Aaqil --- docs/site/reference/error-codes.md | 39 ++++ examples/todo/src/__tests__/helpers.ts | 2 +- .../unit/errors/database-driver-error.test.ts | 216 ++++++++++++++++++ .../repository/src/errors/database.error.ts | 148 ++++++++++++ .../src/errors/handle-repository-error.ts | 72 ++++++ packages/repository/src/errors/index.ts | 1 + .../integration/http-handler.integration.ts | 43 ++++ .../reject.provider.integration.ts | 89 ++++++++ .../database-error-mapper.unit.ts | 164 +++++++++++++ .../src/error-writer/database-error-mapper.ts | 126 ++++++++++ packages/rest/src/keys.ts | 9 + .../rest/src/providers/reject.provider.ts | 12 +- 12 files changed, 917 insertions(+), 4 deletions(-) create mode 100644 packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts create mode 100644 packages/repository/src/errors/database.error.ts create mode 100644 packages/repository/src/errors/handle-repository-error.ts create mode 100644 packages/rest/src/__tests__/integration/reject.provider.integration.ts create mode 100644 packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts create mode 100644 packages/rest/src/error-writer/database-error-mapper.ts diff --git a/docs/site/reference/error-codes.md b/docs/site/reference/error-codes.md index 01f7e0c62e1b..fed95d5b4e78 100644 --- a/docs/site/reference/error-codes.md +++ b/docs/site/reference/error-codes.md @@ -95,6 +95,45 @@ You have the following options how to fix the error } ``` +## Database Error Codes + +When repository operations fail due to database constraints, connection issues, +or protocol errors, LoopBack maps low-level database driver errors to +standardized, protocol-neutral error codes. + +These domain error codes are automatically mapped to corresponding REST HTTP +statuses by `@loopback/rest`: + +| Error Code | HTTP Status | Description | +| :---------------------------- | :----------------------- | :----------------------------------------------------------------------------------------------------------------------------- | +| `UNIQUE_CONSTRAINT_VIOLATION` | 409 Conflict | A record with a duplicate key or non-unique value was inserted or updated. | +| `LOCK_CONFLICT` | 409 Conflict | The operation failed due to a database deadlock or lock wait timeout. | +| `FOREIGN_KEY_VIOLATION` | 422 Unprocessable Entity | The operation violates a foreign key constraint (referencing a non-existent parent or deleting a referenced parent). | +| `NOT_NULL_VIOLATION` | 400 Bad Request | A required non-nullable database column was provided as `null` or omitted without a default value. | +| `CHECK_CONSTRAINT_VIOLATION` | 400 Bad Request | The operation violates a custom SQL `CHECK` constraint. | +| `DATA_TYPE_MISMATCH` | 400 Bad Request | A value provided does not match the database column type (e.g., string length exceeded, invalid string format for field type). | +| `GENERATED_COLUMN_VIOLATION` | 400 Bad Request | An explicit attempt was made to write or update a generated/computed database column. | +| `QUERY_TIMEOUT` | 504 Gateway Timeout | The database query execution exceeded the configured statement timeout threshold. | +| `CONNECTION_FAILURE` | 503 Service Unavailable | The database connection was lost, refused, or unable to be acquired from the pool. | + +### Configuring or Disabling Database Error Mapping + +By default, `@loopback/rest` automatically translates database domain errors +into HTTP error responses. You can disable this mapping across your application +using `RestBindings.DATABASE_ERROR_MAPPING_OPTIONS`: + +```typescript +import {RestBindings} from '@loopback/rest'; + +// In your application constructor or sequence setup +app.bind(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS).to({ + enabled: false, +}); +``` + +When set to `{ enabled: false }`, database errors pass through unmapped and fall +back to the default error handler (returning an HTTP 500 status). + ## Other error codes Besides LoopBack-specific error codes, your application can encounter low-level diff --git a/examples/todo/src/__tests__/helpers.ts b/examples/todo/src/__tests__/helpers.ts index a9a76ccc9f53..9359e0256b61 100644 --- a/examples/todo/src/__tests__/helpers.ts +++ b/examples/todo/src/__tests__/helpers.ts @@ -48,7 +48,7 @@ export function givenTodo(todo?: Partial) { export const aLocation = { address: '1 New Orchard Road, Armonk, 10504', - geopoint: {y: 41.109728357749, x: -73.72462031805}, + geopoint: {y: 41.109725723771, x: -73.724620709372}, get geostring() { return `${this.geopoint.y},${this.geopoint.x}`; }, diff --git a/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts b/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts new file mode 100644 index 000000000000..f35dc68cb504 --- /dev/null +++ b/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts @@ -0,0 +1,216 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; + +import { + CheckConstraintError, + ColumnNotFoundError, + DatabaseConnectionError, + DatabaseError, + DataTypeMismatchError, + ForeignKeyConstraintError, + GeneratedColumnViolationError, + LockConflictError, + NotNullConstraintError, + QueryTimeoutError, + DbSyntaxError, + EntityNotFoundError, + UniqueConstraintError, +} from '../../..'; +import {handleRepositoryError} from '../../../errors/handle-repository-error'; + +describe('handleRepositoryError', () => { + it('throws DatabaseError for a falsy error', () => { + expect(() => handleRepositoryError(null)).to.throwError( + new DatabaseError('An unknown database execution error occurred.'), + ); + }); + + it('passes through an existing DatabaseError', () => { + const error = new DatabaseError('Database error'); + + expect(() => handleRepositoryError(error)).to.throwError(error); + }); + + it('maps UNIQUE_CONSTRAINT_VIOLATION', () => { + const error = givenConnectorError( + 'UNIQUE_CONSTRAINT_VIOLATION', + 'Duplicate value', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + UniqueConstraintError, + ); + + expect(() => handleRepositoryError(error)).to.throwError(/Duplicate value/); + }); + + it('maps FOREIGN_KEY_VIOLATION', () => { + const error = givenConnectorError( + 'FOREIGN_KEY_VIOLATION', + 'Foreign key violation', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + ForeignKeyConstraintError, + ); + }); + + it('maps NOT_NULL_VIOLATION', () => { + const error = givenConnectorError( + 'NOT_NULL_VIOLATION', + 'Not-null violation', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + NotNullConstraintError, + ); + }); + + it('maps CHECK_CONSTRAINT_VIOLATION', () => { + const error = givenConnectorError( + 'CHECK_CONSTRAINT_VIOLATION', + 'Check constraint violation', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + CheckConstraintError, + ); + }); + + it('maps TABLE_NOT_FOUND', () => { + const error = givenConnectorError('TABLE_NOT_FOUND', 'Entity not found'); + + expect(() => handleRepositoryError(error)).to.throwError( + EntityNotFoundError, + ); + }); + + it('maps COLUMN_NOT_FOUND', () => { + const error = givenConnectorError('COLUMN_NOT_FOUND', 'Column not found'); + + expect(() => handleRepositoryError(error)).to.throwError( + ColumnNotFoundError, + ); + }); + + it('maps GENERATED_COLUMN_VIOLATION', () => { + const error = givenConnectorError( + 'GENERATED_COLUMN_VIOLATION', + 'Generated column violation', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + GeneratedColumnViolationError, + ); + }); + + it('maps DATA_TYPE_MISMATCH', () => { + const error = givenConnectorError( + 'DATA_TYPE_MISMATCH', + 'Data type mismatch', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + DataTypeMismatchError, + ); + }); + + it('maps SYNTAX_ERROR', () => { + const error = givenConnectorError('SyntaxError', 'Syntax error'); + expect(() => handleRepositoryError(error)).to.throwError(DbSyntaxError); + }); + + it('maps QUERY_TIMEOUT', () => { + const error = givenConnectorError('QUERY_TIMEOUT', 'Query timeout'); + expect(() => handleRepositoryError(error)).to.throwError(QueryTimeoutError); + }); + + it('maps LOCK_CONFLICT', () => { + const error = givenConnectorError('LOCK_CONFLICT', 'Lock conflict'); + + expect(() => handleRepositoryError(error)).to.throwError(LockConflictError); + }); + + it('maps CONNECTION_FAILURE', () => { + const error = givenConnectorError( + 'CONNECTION_FAILURE', + 'Connection failure', + ); + + expect(() => handleRepositoryError(error)).to.throwError( + DatabaseConnectionError, + ); + }); + + it('preserves message and details when mapping an error', () => { + const details = { + constraint: 'users_email_key', + field: 'email', + }; + + const error = { + code: 'UNIQUE_CONSTRAINT_VIOLATION', + message: 'Email already exists', + details, + }; + + try { + handleRepositoryError(error); + } catch (err) { + expect(err).to.be.instanceof(UniqueConstraintError); + expect((err as UniqueConstraintError).message).to.equal( + 'Email already exists', + ); + expect((err as UniqueConstraintError).details).to.eql(details); + } + }); + + it('rethrows an unmapped connector error as-is', () => { + const error = { + code: 'SOME_UNKNOWN_ERROR', + message: 'Unknown connector error', + }; + + try { + handleRepositoryError(error); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('rethrows a generic Error as-is', () => { + const error = new Error('Something went wrong'); + + try { + handleRepositoryError(error); + } catch (err) { + expect(err).to.equal(error); + } + }); + + it('rethrows an object without a code as-is', () => { + const error = { + message: 'Something went wrong', + }; + + try { + handleRepositoryError(error); + } catch (err) { + expect(err).to.equal(error); + } + }); +}); + +function givenConnectorError(code: string, message: string) { + return { + code, + message, + details: { + source: 'test', + }, + }; +} diff --git a/packages/repository/src/errors/database.error.ts b/packages/repository/src/errors/database.error.ts new file mode 100644 index 000000000000..f3b6afeff238 --- /dev/null +++ b/packages/repository/src/errors/database.error.ts @@ -0,0 +1,148 @@ +// Copyright IBM Corp. and LoopBack contributors 2018,2019. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Base abstract class for all database-related domain errors. + * Protocol-neutral: Contains no HTTP status codes or transport metadata. + */ +export class DatabaseError extends Error { + public readonly code: string; + public readonly details?: Record; + + constructor( + message: string, + code = 'DATABASE_ERROR', + details?: Record, + ) { + super(message); + this.name = 'DatabaseError'; + this.code = code; + this.details = details; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +export class UniqueConstraintError extends DatabaseError { + constructor( + message = 'Unique constraint violation occurred.', + details?: Record, + ) { + super(message, 'UNIQUE_CONSTRAINT_VIOLATION', details); + this.name = 'UniqueConstraintError'; + } +} + +export class ForeignKeyConstraintError extends DatabaseError { + constructor( + message = 'Foreign key constraint violation occurred.', + details?: Record, + ) { + super(message, 'FOREIGN_KEY_VIOLATION', details); + this.name = 'ForeignKeyConstraintError'; + } +} + +export class NotNullConstraintError extends DatabaseError { + constructor( + message = 'Required property cannot be null or omitted.', + details?: Record, + ) { + super(message, 'NOT_NULL_VIOLATION', details); + this.name = 'NotNullConstraintError'; + } +} + +export class CheckConstraintError extends DatabaseError { + constructor( + message = 'Check constraint or validation check failed.', + details?: Record, + ) { + super(message, 'CHECK_CONSTRAINT_VIOLATION', details); + this.name = 'CheckConstraintError'; + } +} + +export class TableNotFoundError extends DatabaseError { + constructor( + message = 'The requested database table or relation does not exist.', + details?: Record, + ) { + super(message, 'TABLE_NOT_FOUND', details); + this.name = 'TableNotFoundError'; + } +} + +export class ColumnNotFoundError extends DatabaseError { + constructor( + message = 'The requested database column does not exist.', + details?: Record, + ) { + super(message, 'COLUMN_NOT_FOUND', details); + this.name = 'ColumnNotFoundError'; + } +} + +export class GeneratedColumnViolationError extends DatabaseError { + constructor( + message = 'Cannot manually assign or update values on a database-generated computed column.', + details?: Record, + ) { + super(message, 'GENERATED_COLUMN_VIOLATION', details); + this.name = 'GeneratedColumnViolationError'; + } +} + +export class DataTypeMismatchError extends DatabaseError { + constructor( + message = 'Data type mismatch or string truncation occurred.', + details?: Record, + ) { + super(message, 'DATA_TYPE_MISMATCH', details); + this.name = 'DataTypeMismatchError'; + } +} + +export class LockConflictError extends DatabaseError { + constructor( + message = 'Concurrency lock conflict or deadlock detected.', + details?: Record, + ) { + super(message, 'LOCK_CONFLICT', details); + this.name = 'LockConflictError'; + } +} + +export class QueryTimeoutError extends DatabaseError { + constructor( + message = 'The database operation took too long and was aborted.', + details?: Record, + ) { + super(message, 'QUERY_TIMEOUT', details); + this.name = 'QueryTimeoutError'; + } +} + +export class DbSyntaxError extends DatabaseError { + constructor( + message = 'A database syntax or parse error occurred.', + details?: Record, + ) { + super(message, 'SYNTAX_ERROR', details); + this.name = 'DbSyntaxError'; + } +} + +export class DatabaseConnectionError extends DatabaseError { + constructor( + message = 'Database service connection failed or unavailable.', + details?: Record, + ) { + super(message, 'CONNECTION_FAILURE', details); + this.name = 'DatabaseConnectionError'; + } +} diff --git a/packages/repository/src/errors/handle-repository-error.ts b/packages/repository/src/errors/handle-repository-error.ts new file mode 100644 index 000000000000..a1d4cba6745b --- /dev/null +++ b/packages/repository/src/errors/handle-repository-error.ts @@ -0,0 +1,72 @@ +import { + CheckConstraintError, + ColumnNotFoundError, + DatabaseConnectionError, + DatabaseError, + DataTypeMismatchError, + DbSyntaxError, + ForeignKeyConstraintError, + GeneratedColumnViolationError, + LockConflictError, + NotNullConstraintError, + QueryTimeoutError, + UniqueConstraintError, +} from './database.error'; +import {EntityNotFoundError} from './entity-not-found.error'; + +/** + * Normalizes or re-throws errors originating from legacy juggler or connector operations. + */ +export function handleRepositoryError(err: unknown): never { + if (!err) { + throw new DatabaseError('An unknown database execution error occurred.'); + } + + if (err instanceof DatabaseError) { + throw err; + } + + if (typeof err === 'object' && err !== null && 'code' in err) { + const errorObj = err as { + code?: string; + message?: string; + details?: Record; + }; + + switch (errorObj.code) { + case 'UNIQUE_CONSTRAINT_VIOLATION': + throw new UniqueConstraintError(errorObj.message, errorObj.details); + // returning entity not found error to avoid leaking db schema information + case 'TABLE_NOT_FOUND': + throw new EntityNotFoundError( + errorObj.message || 'record not found', + errorObj.details, + ); + case 'FOREIGN_KEY_VIOLATION': + throw new ForeignKeyConstraintError(errorObj.message, errorObj.details); + case 'NOT_NULL_VIOLATION': + throw new NotNullConstraintError(errorObj.message, errorObj.details); + case 'CHECK_CONSTRAINT_VIOLATION': + throw new CheckConstraintError(errorObj.message, errorObj.details); + case 'DATA_TYPE_MISMATCH': + throw new DataTypeMismatchError(errorObj.message, errorObj.details); + case 'LOCK_CONFLICT': + throw new LockConflictError(errorObj.message, errorObj.details); + case 'CONNECTION_FAILURE': + throw new DatabaseConnectionError(errorObj.message, errorObj.details); + case 'COLUMN_NOT_FOUND': + throw new ColumnNotFoundError(errorObj.message, errorObj.details); + case 'GENERATED_COLUMN_VIOLATION': + throw new GeneratedColumnViolationError( + errorObj.message, + errorObj.details, + ); + case 'SyntaxError': + throw new DbSyntaxError(errorObj.message, errorObj.details); + case 'QUERY_TIMEOUT': + throw new QueryTimeoutError(errorObj.message, errorObj.details); + } + } + + throw err; +} diff --git a/packages/repository/src/errors/index.ts b/packages/repository/src/errors/index.ts index ac99f724d828..cd2a8d43b9e2 100644 --- a/packages/repository/src/errors/index.ts +++ b/packages/repository/src/errors/index.ts @@ -7,3 +7,4 @@ export * from './entity-not-found.error'; export * from './invalid-polymorphism.error'; export * from './invalid-relation.error'; export * from './invalid-body.error'; +export * from './database.error'; diff --git a/packages/rest/src/__tests__/integration/http-handler.integration.ts b/packages/rest/src/__tests__/integration/http-handler.integration.ts index 0372e9d3edd6..20fe5f55729f 100644 --- a/packages/rest/src/__tests__/integration/http-handler.integration.ts +++ b/packages/rest/src/__tests__/integration/http-handler.integration.ts @@ -618,6 +618,29 @@ describe('HttpHandler', () => { statusCode: 500, }); }); + + it('maps database constraint errors to HTTP errors', async () => { + givenDatabaseErrorController('UNIQUE_CONSTRAINT_VIOLATION'); + logErrorsExcept(409); + await client.get('/database-error').expect(409, { + error: { + name: 'ConflictError', + message: 'Database error', + statusCode: 409, + }, + }); + }); + + it('maps database connection failures to HTTP 503', async () => { + givenDatabaseErrorController('CONNECTION_FAILURE'); + logErrorsExcept(503); + await client.get('/database-error').expect(503, { + error: { + message: 'Service Unavailable', + statusCode: 503, + }, + }); + }); }); let rootContext: Context; @@ -701,4 +724,24 @@ describe('HttpHandler', () => { }); client = createClientForHandler(app); } + + function givenDatabaseErrorController(code: string) { + const spec = anOpenApiSpec() + .withOperation( + 'get', + '/database-error', + anOperationSpec().withOperationName('databaseError'), + ) + .build(); + + class TestController { + @get('/database-error') + databaseError() { + const err = new Error('Database error'); + Object.assign(err, {code}); + throw err; + } + } + givenControllerClass(TestController, spec); + } }); diff --git a/packages/rest/src/__tests__/integration/reject.provider.integration.ts b/packages/rest/src/__tests__/integration/reject.provider.integration.ts new file mode 100644 index 000000000000..f0928270540a --- /dev/null +++ b/packages/rest/src/__tests__/integration/reject.provider.integration.ts @@ -0,0 +1,89 @@ +import { + UniqueConstraintError, + QueryTimeoutError, + TableNotFoundError, + DatabaseConnectionError, +} from '@loopback/repository'; +import {Client, createRestAppClient, expect} from '@loopback/testlab'; +import {get, RestApplication, RestBindings} from '../..'; + +describe('RejectProvider - DATABASE_ERROR_MAPPING_OPTIONS', () => { + let app: RestApplication; + let client: Client; + + class TestController { + @get('/test-unique-error') + testUniqueError() { + throw new UniqueConstraintError('Duplicate key error'); + } + + @get('/test-timeout-error') + testTimeoutError() { + throw new QueryTimeoutError('Query timed out'); + } + + @get('/test-table-not-found') + testTableNotFound() { + throw new TableNotFoundError('Table does not exist'); + } + + @get('/test-connection-error') + testConnectionError() { + throw new DatabaseConnectionError('Connection failed'); + } + } + + beforeEach(() => { + app = new RestApplication({rest: {port: 0}}); + app.bind(RestBindings.SequenceActions.LOG_ERROR).to(() => {}); + app.controller(TestController); + }); + + afterEach(async () => { + if (app) await app.stop(); + }); + + it('maps database error to HTTP 409 Conflict by default', async () => { + app.bind(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS).to({enabled: true}); + await app.start(); + client = createRestAppClient(app); + + await client.get('/test-unique-error').expect(409); + }); + + it('maps query timeout error to HTTP 504 Gateway Timeout', async () => { + app.bind(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS).to({enabled: true}); + await app.start(); + client = createRestAppClient(app); + + const res = await client.get('/test-timeout-error').expect(504); + expect(res.body.error.statusCode).to.equal(504); + }); + + it('maps table not found error to HTTP 400 Bad Request', async () => { + app.bind(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS).to({enabled: true}); + await app.start(); + client = createRestAppClient(app); + + const res = await client.get('/test-table-not-found').expect(400); + expect(res.body.error.statusCode).to.equal(400); + }); + + it('maps database connection error to HTTP 503 Service Unavailable', async () => { + app.bind(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS).to({enabled: true}); + await app.start(); + client = createRestAppClient(app); + + const res = await client.get('/test-connection-error').expect(503); + expect(res.body.error.statusCode).to.equal(503); + }); + + it('bypasses error mapping (returns 500) when disabled via app.bind()', async () => { + app.bind(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS).to({enabled: false}); + await app.start(); + client = createRestAppClient(app); + + const res = await client.get('/test-unique-error').expect(500); + expect(res.body.error.statusCode).to.equal(500); + }); +}); diff --git a/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts b/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts new file mode 100644 index 000000000000..eb945d85293f --- /dev/null +++ b/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts @@ -0,0 +1,164 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved. +// Node module: @loopback/rest +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; + +import {HttpErrors} from '../../../index'; + +import {mapDatabaseErrorToHttpError} from '../../../error-writer/database-error-mapper'; + +describe('mapDatabaseErrorToHttpError', () => { + const databaseErrorMappings = [ + [ + 'UNIQUE_CONSTRAINT_VIOLATION', + HttpErrors.Conflict, + 409, + 'A record with this unique value already exists.', + ], + [ + 'LOCK_CONFLICT', + HttpErrors.Conflict, + 409, + 'The operation could not be completed due to a concurrent lock.', + ], + [ + 'FOREIGN_KEY_VIOLATION', + HttpErrors.UnprocessableEntity, + 422, + 'The referenced record does not exist.', + ], + [ + 'NOT_NULL_VIOLATION', + HttpErrors.BadRequest, + 400, + 'A required property was missing or provided as null.', + ], + [ + 'CHECK_CONSTRAINT_VIOLATION', + HttpErrors.BadRequest, + 400, + 'The provided value violates a field constraint.', + ], + [ + 'TABLE_NOT_FOUND', + HttpErrors.BadRequest, + 400, + 'The requested database table or relation does not exist.', + ], + [ + 'COLUMN_NOT_FOUND', + HttpErrors.BadRequest, + 400, + 'The requested database column does not exist.', + ], + [ + 'DATA_TYPE_MISMATCH', + HttpErrors.BadRequest, + 400, + 'One or more properties contained invalid data types.', + ], + [ + 'GENERATED_COLUMN_VIOLATION', + HttpErrors.BadRequest, + 400, + 'Attempted to modify a read-only or computed property.', + ], + [ + 'SYNTAX_ERROR', + HttpErrors.BadRequest, + 400, + 'A database syntax or parse error occurred.', + ], + [ + 'QUERY_TIMEOUT', + HttpErrors.GatewayTimeout, + 504, + 'The database query execution timed out.', + ], + [ + 'CONNECTION_FAILURE', + HttpErrors.ServiceUnavailable, + 503, + 'Database service is temporarily unavailable.', + ], + ] as const; + + for (const [ + code, + ErrorClass, + statusCode, + expectedMessage, + ] of databaseErrorMappings) { + it(`maps ${code} to HTTP ${statusCode} with standard message`, () => { + const error = {code}; + + const result = mapDatabaseErrorToHttpError(error); + + expect(result).to.be.instanceof(ErrorClass); + expect(result).to.have.property('statusCode', statusCode); + expect(result).to.have.property('message', expectedMessage); + }); + } + + it('returns an unmapped error unchanged', () => { + const error = { + code: 'UNKNOWN_DATABASE_ERROR', + message: 'Unknown database error', + }; + + const result = mapDatabaseErrorToHttpError(error); + + expect(result).to.equal(error); + }); + + it('returns a generic Error unchanged', () => { + const error = new Error('Something went wrong'); + + const result = mapDatabaseErrorToHttpError(error); + + expect(result).to.equal(error); + }); + + it('returns null unchanged', () => { + expect(mapDatabaseErrorToHttpError(null)).to.equal(null); + }); + + it('returns undefined unchanged', () => { + expect(mapDatabaseErrorToHttpError(undefined)).to.equal(undefined); + }); + + it('returns primitive values unchanged', () => { + expect(mapDatabaseErrorToHttpError('database error')).to.equal( + 'database error', + ); + + expect(mapDatabaseErrorToHttpError(123)).to.equal(123); + + expect(mapDatabaseErrorToHttpError(false)).to.equal(false); + }); + + it('preserves the original error message', () => { + const error = { + code: 'UNIQUE_CONSTRAINT_VIOLATION', + message: 'Email already exists', + }; + + const result = mapDatabaseErrorToHttpError(error); + + expect(result).to.be.instanceof(HttpErrors.Conflict); + expect(result).to.have.property('statusCode', 409); + expect(result).to.have.property('message', 'Email already exists'); + }); + + it('handles an object without a code unchanged', () => { + const error = { + message: 'Some database error', + }; + + const result = mapDatabaseErrorToHttpError(error); + + expect(result).to.equal(error); + }); +}); diff --git a/packages/rest/src/error-writer/database-error-mapper.ts b/packages/rest/src/error-writer/database-error-mapper.ts new file mode 100644 index 000000000000..1d0e049ef7f5 --- /dev/null +++ b/packages/rest/src/error-writer/database-error-mapper.ts @@ -0,0 +1,126 @@ +import { + UniqueConstraintError, + LockConflictError, + ForeignKeyConstraintError, + NotNullConstraintError, + CheckConstraintError, + DataTypeMismatchError, + DatabaseConnectionError, + TableNotFoundError, + ColumnNotFoundError, + GeneratedColumnViolationError, + QueryTimeoutError, + DbSyntaxError, +} from '@loopback/repository'; +import {HttpErrors} from '../'; + +export interface DatabaseErrorMappingOptions { + /** + * Whether to map database domain errors to HTTP errors. + * @defaultValue true + */ + enabled?: boolean; +} + +export function mapDatabaseErrorToHttpError( + err: unknown, + options: DatabaseErrorMappingOptions = {enabled: true}, +): unknown { + if (!err || typeof err !== 'object' || options.enabled === false) { + return err; + } + + const errorObj = err as {code?: string; message?: string}; + const code = errorObj.code; + const getMessage = message => { + return message; + }; + + if ( + err instanceof UniqueConstraintError || + code === 'UNIQUE_CONSTRAINT_VIOLATION' + ) { + return new HttpErrors.Conflict( + getMessage('A record with this unique value already exists.'), + ); + } + + if (err instanceof LockConflictError || code === 'LOCK_CONFLICT') { + return new HttpErrors.Conflict( + getMessage( + 'The operation could not be completed due to a concurrent lock.', + ), + ); + } + + if ( + err instanceof ForeignKeyConstraintError || + code === 'FOREIGN_KEY_VIOLATION' + ) { + return new HttpErrors.UnprocessableEntity( + getMessage('The referenced record does not exist.'), + ); + } + + if (err instanceof NotNullConstraintError || code === 'NOT_NULL_VIOLATION') { + return new HttpErrors.BadRequest( + getMessage('A required property was missing or provided as null.'), + ); + } + + if ( + err instanceof CheckConstraintError || + code === 'CHECK_CONSTRAINT_VIOLATION' + ) { + return new HttpErrors.BadRequest( + getMessage('The provided value violates a field constraint.'), + ); + } + + if (err instanceof TableNotFoundError || code === 'TABLE_NOT_FOUND') { + return new HttpErrors.BadRequest( + getMessage('The requested database table or relation does not exist.'), + ); + } + + if (err instanceof ColumnNotFoundError || code === 'COLUMN_NOT_FOUND') { + return new HttpErrors.BadRequest( + getMessage('The requested database column does not exist.'), + ); + } + + if ( + err instanceof GeneratedColumnViolationError || + code === 'GENERATED_COLUMN_VIOLATION' + ) { + return new HttpErrors.BadRequest( + getMessage('Attempted to modify a read-only or computed property.'), + ); + } + + if (err instanceof DataTypeMismatchError || code === 'DATA_TYPE_MISMATCH') { + return new HttpErrors.BadRequest( + getMessage('One or more properties contained invalid data types.'), + ); + } + + if (err instanceof DbSyntaxError || code === 'SYNTAX_ERROR') { + return new HttpErrors.BadRequest( + getMessage('A database syntax or parse error occurred.'), + ); + } + + if (err instanceof QueryTimeoutError || code === 'QUERY_TIMEOUT') { + return new HttpErrors.GatewayTimeout( + getMessage('The database query execution timed out.'), + ); + } + + if (err instanceof DatabaseConnectionError || code === 'CONNECTION_FAILURE') { + return new HttpErrors.ServiceUnavailable( + getMessage('Database service is temporarily unavailable.'), + ); + } + + return err; +} diff --git a/packages/rest/src/keys.ts b/packages/rest/src/keys.ts index 145768c8b5a0..a68e44aab9f1 100644 --- a/packages/rest/src/keys.ts +++ b/packages/rest/src/keys.ts @@ -28,6 +28,7 @@ import { Response, Send, } from './types'; +import {DatabaseErrorMappingOptions} from './error-writer/database-error-mapper'; /** * RestServer-specific bindings @@ -100,6 +101,14 @@ export namespace RestBindings { 'rest.errorWriterOptions', ); + /** + * Binding key for setting and injecting database error mapping options. + */ + export const DATABASE_ERROR_MAPPING_OPTIONS = + BindingKey.create( + 'rest.databaseErrorMappingOptions', + ); + /** * Binding key for request body parser options */ diff --git a/packages/rest/src/providers/reject.provider.ts b/packages/rest/src/providers/reject.provider.ts index b7971382f56d..3ce2130db575 100644 --- a/packages/rest/src/providers/reject.provider.ts +++ b/packages/rest/src/providers/reject.provider.ts @@ -6,6 +6,10 @@ import {BindingScope, inject, injectable} from '@loopback/core'; import {HttpError} from 'http-errors'; import {ErrorWriterOptions, writeErrorToResponse} from 'strong-error-handler'; +import { + DatabaseErrorMappingOptions, + mapDatabaseErrorToHttpError, +} from '../error-writer/database-error-mapper'; import {RestBindings} from '../keys'; import {HandlerContext, LogError, Reject} from '../types'; @@ -22,9 +26,12 @@ export class RejectProvider { logError: LogError, @inject(RestBindings.ERROR_WRITER_OPTIONS, {optional: true}) errorWriterOptions?: ErrorWriterOptions, + @inject(RestBindings.DATABASE_ERROR_MAPPING_OPTIONS, {optional: true}) + dbErrorOptions?: DatabaseErrorMappingOptions, ): Reject { const reject: Reject = ({request, response}: HandlerContext, error) => { - const err = error; + const mappedError = mapDatabaseErrorToHttpError(error, dbErrorOptions); + const err = mappedError; if (!err.status && !err.statusCode && err.code) { const customStatus = codeToStatusCodeMap[err.code]; @@ -32,10 +39,9 @@ export class RejectProvider { err.statusCode = customStatus; } } - const statusCode = err.statusCode || err.status || 500; writeErrorToResponse(err, request, response, errorWriterOptions); - logError(error, statusCode, request); + logError(err, statusCode, request); }; return reject; } From ab6ad00cab4ae82d0f64da695ae7085b2a851c3a Mon Sep 17 00:00:00 2001 From: Muhammad Aaqil Date: Thu, 12 Nov 2026 17:32:53 +0500 Subject: [PATCH 2/2] fix: add missing type of parameter Signed-off-by: Muhammad Aaqil --- .../integration/http-handler.integration.ts | 2 +- .../unit/error-writer/database-error-mapper.unit.ts | 13 ------------- .../rest/src/error-writer/database-error-mapper.ts | 2 +- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/rest/src/__tests__/integration/http-handler.integration.ts b/packages/rest/src/__tests__/integration/http-handler.integration.ts index 20fe5f55729f..0ecc5f355d59 100644 --- a/packages/rest/src/__tests__/integration/http-handler.integration.ts +++ b/packages/rest/src/__tests__/integration/http-handler.integration.ts @@ -625,7 +625,7 @@ describe('HttpHandler', () => { await client.get('/database-error').expect(409, { error: { name: 'ConflictError', - message: 'Database error', + message: 'A record with this unique value already exists.', statusCode: 409, }, }); diff --git a/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts b/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts index eb945d85293f..fa0b32751e33 100644 --- a/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts +++ b/packages/rest/src/__tests__/unit/error-writer/database-error-mapper.unit.ts @@ -139,19 +139,6 @@ describe('mapDatabaseErrorToHttpError', () => { expect(mapDatabaseErrorToHttpError(false)).to.equal(false); }); - it('preserves the original error message', () => { - const error = { - code: 'UNIQUE_CONSTRAINT_VIOLATION', - message: 'Email already exists', - }; - - const result = mapDatabaseErrorToHttpError(error); - - expect(result).to.be.instanceof(HttpErrors.Conflict); - expect(result).to.have.property('statusCode', 409); - expect(result).to.have.property('message', 'Email already exists'); - }); - it('handles an object without a code unchanged', () => { const error = { message: 'Some database error', diff --git a/packages/rest/src/error-writer/database-error-mapper.ts b/packages/rest/src/error-writer/database-error-mapper.ts index 1d0e049ef7f5..ca21456a5205 100644 --- a/packages/rest/src/error-writer/database-error-mapper.ts +++ b/packages/rest/src/error-writer/database-error-mapper.ts @@ -32,7 +32,7 @@ export function mapDatabaseErrorToHttpError( const errorObj = err as {code?: string; message?: string}; const code = errorObj.code; - const getMessage = message => { + const getMessage = (message: string) => { return message; };