From cf69ebdf28415b8decfcb5847afa5717b4c8e94d 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 --- .../unit/errors/database-driver-error.test.ts | 55 ++++++ .../src/errors/database-driver.error.ts | 43 +++++ packages/repository/src/errors/index.ts | 1 + .../src/repositories/legacy-juggler-bridge.ts | 182 ++++++++++++++++-- 4 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts create mode 100644 packages/repository/src/errors/database-driver.error.ts 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..1cb86780a30d --- /dev/null +++ b/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts @@ -0,0 +1,55 @@ +// 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 {DatabaseDriverError, isDatabaseDriverError} from '../../..'; + +describe('DatabaseDriverError', () => { + it('inherits from Error correctly', () => { + const err = givenAnErrorInstance(); + expect(err).to.be.instanceof(DatabaseDriverError); + expect(err).to.be.instanceof(Error); + expect(err.stack) + .to.be.String() + // NOTE(bajtos) We cannot assert using __filename because stack traces + // are typically converted from JS paths to TS paths using source maps. + .and.match(/database-driver-error\.test\.(ts|js)/); + }); + + it('sets code to "DB_FOREIGN_KEY_VIOLATION"', () => { + const err = givenAnErrorInstance(); + expect(err.code).to.equal('DB_FOREIGN_KEY_VIOLATION'); + }); + + it('sets statusCode to 422', () => { + const err = givenAnErrorInstance(); + expect(err.statusCode).to.equal(422); + }); + + it('sets nativeCode to "1216"', () => { + const err = givenAnErrorInstance(); + expect(err.nativeCode).to.equal('1216'); // mysql's native code for foreign key violation + }); +}); + +describe('isDatabaseDriverError', () => { + it('returns true for an instance of DatabaseDriverError', () => { + const error = givenAnErrorInstance(); + expect(isDatabaseDriverError(error)).to.be.true(); + }); + + it('returns false for an instance of Error', () => { + const error = new Error('A generic error'); + expect(isDatabaseDriverError(error)).to.be.false(); + }); +}); + +function givenAnErrorInstance() { + return new DatabaseDriverError('User', '', { + code: 'DB_FOREIGN_KEY_VIOLATION', + statusCode: 422, + nativeCode: '1216', // mysql's native code for foreign key violation + }); +} diff --git a/packages/repository/src/errors/database-driver.error.ts b/packages/repository/src/errors/database-driver.error.ts new file mode 100644 index 000000000000..6ff4ffaaca7b --- /dev/null +++ b/packages/repository/src/errors/database-driver.error.ts @@ -0,0 +1,43 @@ +// 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 + +import {Entity} from '../model'; + +export class DatabaseDriverError extends Error { + code: string; + statusCode: number; + entityName: string; + nativeCode: string | number; + + constructor( + entityOrName: typeof Entity | string, + message: string, + options: { + code: string; + statusCode: number; + nativeCode: string | number; + }, + ) { + const entityName = + typeof entityOrName === 'string' + ? entityOrName + : entityOrName.modelName || entityOrName.name; + + super(message); + + this.name = 'DatabaseDriverError'; + this.entityName = entityName; + this.code = options.code; + this.statusCode = options.statusCode; + this.nativeCode = options.nativeCode; + + Error.captureStackTrace(this, this.constructor); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isDatabaseDriverError(e: any): e is DatabaseDriverError { + return e instanceof DatabaseDriverError; +} diff --git a/packages/repository/src/errors/index.ts b/packages/repository/src/errors/index.ts index ac99f724d828..196a5a0545c8 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-driver.error'; diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 025ea10393e0..cb0c2c5bf113 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -78,6 +78,156 @@ function isModelClass( ); } +import {DatabaseDriverError} from '../errors'; + +function handleDatabaseDriverError( + this: DefaultCrudRepository, + err: unknown, +): never { + const error = err as AnyObject; + if (err === null || err === undefined) { + throw new Error('An unknown database execution error occurred.'); + } + + // Handling existing already mapped errors + if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + throw error; + } + + const parsedCode = Number(error.code); + const rawCode = !isNaN(parsedCode) ? error.code : error.errno; // error.code for posgres while errno for mysql/mongodb + + const codeStr = String(rawCode); + + // Initialize with default values + let statusCode = 500; + let errorCode = 'DATABASE_ERROR'; + let message = error.message || 'An unexpected database error occurred.'; + + // Evaluate database signatures and re-map properties dynamically + switch (codeStr) { + // 1. Unique Key / Duplicate Entries + case '23505': // Postgres + case '1062': // MySQL + case '11000': // MongoDB + case '11001': + statusCode = 409; + errorCode = 'DB_UNIQUE_CONSTRAINT_VIOLATION'; + message = + 'The operation conflicts with an existing record unique constraint.'; + break; + // 2. Foreign Key Constraints (Missing Parents / Existing Children) + case '23503': // Postgres + case '1216': // MySQL + case '1217': + case '1451': + case '1452': + statusCode = 422; + errorCode = 'DB_FOREIGN_KEY_VIOLATION'; + message = + 'Relational integrity validation failed. Referenced parent record not found.'; + break; + // 3. Null / Required Fields Omissions + case '23502': // Postgres + case '1048': // MySQL + case '1364': + case '121': // MongoDB Document Validation Failed + statusCode = 400; + errorCode = 'DB_NOT_NULL_VIOLATION'; + message = 'Required database schema properties are missing or null.'; + break; + // 4. Bad Casts / Truncation / Data Type Mismatch + case '22P02': // Postgres Invalid Text Representation (e.g. Bad UUID format) + case '22001': // Postgres String Data Right Truncation + case '1265': // MySQL Data Truncated + case '1366': + statusCode = 400; + errorCode = 'DB_DATA_TYPE_MISMATCH'; + message = + 'The query properties contain unexpected formatting types or overflows.'; + break; + case '3105': // MySQL Server Generated column value ignored/disallowed + case '1906': // MariaDB Generated column value ignored/disallowed + statusCode = 400; + errorCode = 'DB_GENERATED_COLUMN_VIOLATION'; + message = + 'Cannot manually assign or update values on a database-generated computed column.'; + break; + // 5. Missing Table / Schema Definition Errors + case '1146': // MySQL errno for missing table + case '42P01': // Postgres error code for undefined_table + statusCode = 400; // Setting as 400 because 500 is too generic and doesn't provide enough context for the client + errorCode = 'DB_SCHEMA_MISSING_TABLE'; + message = 'The requested database table or relation does not exist.'; + break; + // 6. Query Timeout Errors + case '57014': // Postgres query_canceled + case '1907': // MySQL query_timeout + case '3024': // MongoDB query_timeout + statusCode = 504; + errorCode = 'DB_QUERY_TIMEOUT'; + message = 'The database operation took too long and was aborted.'; + break; + // 7. Concurrency / Locking Conflicts + case '40001': // Postgres serialization_failure + case '40P01': // Postgres deadlock_detected + case '1213': // MySQL deadlock found when trying to get lock + case '1205': // MySQL lock wait timeout exceeded + statusCode = 409; + errorCode = 'DB_LOCK_CONFLICT'; + message = + 'A concurrency lock conflict occurred. Please retry the operation.'; + break; + // 8. Check Constraint / Business Logic Violations + case '23514': // Postgres check_violation + case '23P01': // Postgres exclusion_violation + case '3819': // MySQL check constraint violation + statusCode = 400; + errorCode = 'DB_CHECK_CONSTRAINT_VIOLATION'; + message = + 'The data violates database business logic or range constraints.'; + break; + // 9. Connection / Availability Issues + case '08003': // Postgres connection_does_not_exist + case '08006': // Postgres connection_failure + case '53300': // Postgres too_many_connections + case '1040': // MySQL too many connections + case '2002': // MySQL connection refused + case '2003': // MySQL can't connect to MySQL server + case '2006': // MySQL server has gone away + case '2013': // MySQL lost connection to MySQL server during query + case '8000': // MongoDB network error + case '8001': // MongoDB connection closed + case '8002': // MongoDB connection timeout + statusCode = 503; + errorCode = 'DB_CONNECTION_FAILURE'; + message = + 'The database is temporarily unavailable or overloaded. Please try again later.'; + break; + // 10. Numeric Overflow / Out of Range Values + case '22003': // Postgres numeric_value_out_of_range + case '1264': // MySQL Out of range value for column + statusCode = 400; + errorCode = 'DB_NUMERIC_OUT_OF_RANGE'; + message = + 'A numeric or string value exceeds the maximum allowable size for the field.'; + break; + } + if (error.stack) console.error(error.stack); + + // If we matched a standard driver rule, throw our clean uniform class + if (statusCode !== 500) { + throw new DatabaseDriverError(this.entityClass, message, { + code: errorCode, + statusCode: statusCode, + nativeCode: rawCode, + }); + } + + // Otherwise, bubble up the original error safely to protect core connection strings/etc. + throw err; +} + /** * This is a bridge to the legacy DAO class. The function mixes DAO methods * into a model class and attach it to a given data source @@ -488,7 +638,9 @@ export class DefaultCrudRepository< async create(entity: DataObject, options?: Options): Promise { // perform persist hook const data = await this.entityToData(entity, options); - const model = await ensurePromise(this.modelClass.create(data, options)); + const model = await ensurePromise( + this.modelClass.create(data, options), + ).catch(handleDatabaseDriverError); return this.toEntity(model); } @@ -499,7 +651,7 @@ export class DefaultCrudRepository< ); const models = await ensurePromise( this.modelClass.createAll(data, options), - ); + ).catch(handleDatabaseDriverError); return this.toEntities(models); } @@ -520,7 +672,7 @@ export class DefaultCrudRepository< const include = filter?.include; const models = await ensurePromise( this.modelClass.find(this.normalizeFilter(filter), options), - ); + ).catch(handleDatabaseDriverError); const entities = this.toEntities(models); return this.includeRelatedModels(entities, include, options); } @@ -531,7 +683,7 @@ export class DefaultCrudRepository< ): Promise<(T & Relations) | null> { const model = await ensurePromise( this.modelClass.findOne(this.normalizeFilter(filter), options), - ); + ).catch(handleDatabaseDriverError); if (!model) return null; const entity = this.toEntity(model); const include = filter?.include; @@ -551,7 +703,7 @@ export class DefaultCrudRepository< const include = filter?.include; const model = await ensurePromise( this.modelClass.findById(id, this.normalizeFilter(filter), options), - ); + ).catch(handleDatabaseDriverError); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } @@ -583,7 +735,7 @@ export class DefaultCrudRepository< const persistedData = await this.entityToData(data, options); const result = await ensurePromise( this.modelClass.updateAll(where, persistedData, options), - ); + ).catch(handleDatabaseDriverError); return {count: result.count}; } @@ -614,7 +766,9 @@ export class DefaultCrudRepository< ): Promise { try { const payload = await this.entityToData(data, options); - await ensurePromise(this.modelClass.replaceById(id, payload, options)); + await ensurePromise( + this.modelClass.replaceById(id, payload, options), + ).catch(handleDatabaseDriverError); } catch (err) { if (err.statusCode === 404) { throw new EntityNotFoundError(this.entityClass, id); @@ -626,24 +780,30 @@ export class DefaultCrudRepository< async deleteAll(where?: Where, options?: Options): Promise { const result = await ensurePromise( this.modelClass.deleteAll(where, options), - ); + ).catch(handleDatabaseDriverError); return {count: result.count}; } async deleteById(id: ID, options?: Options): Promise { - const result = await ensurePromise(this.modelClass.deleteById(id, options)); + const result = await ensurePromise( + this.modelClass.deleteById(id, options), + ).catch(handleDatabaseDriverError); if (result.count === 0) { throw new EntityNotFoundError(this.entityClass, id); } } async count(where?: Where, options?: Options): Promise { - const result = await ensurePromise(this.modelClass.count(where, options)); + const result = await ensurePromise( + this.modelClass.count(where, options), + ).catch(handleDatabaseDriverError); return {count: result}; } exists(id: ID, options?: Options): Promise { - return ensurePromise(this.modelClass.exists(id, options)); + return ensurePromise(this.modelClass.exists(id, options)).catch( + handleDatabaseDriverError, + ); } /** From e021113b5355d5874f21d3336b77d763f48d9564 Mon Sep 17 00:00:00 2001 From: Muhammad Aaqil Date: Wed, 23 Sep 2026 18:01:43 +0500 Subject: [PATCH 2/2] feat: handle database errors Signed-off-by: Muhammad Aaqil --- .../src/errors/database-driver.error.ts | 43 -------- .../repository/src/errors/database.error.ts | 98 +++++++++++++++++++ .../src/errors/handle-repository-error.ts | 53 ++++++++++ packages/repository/src/errors/index.ts | 2 +- .../src/error-writer/database-error-mapper.ts | 43 ++++++++ .../rest/src/providers/reject.provider.ts | 7 +- 6 files changed, 200 insertions(+), 46 deletions(-) delete mode 100644 packages/repository/src/errors/database-driver.error.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/error-writer/database-error-mapper.ts diff --git a/packages/repository/src/errors/database-driver.error.ts b/packages/repository/src/errors/database-driver.error.ts deleted file mode 100644 index 6ff4ffaaca7b..000000000000 --- a/packages/repository/src/errors/database-driver.error.ts +++ /dev/null @@ -1,43 +0,0 @@ -// 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 - -import {Entity} from '../model'; - -export class DatabaseDriverError extends Error { - code: string; - statusCode: number; - entityName: string; - nativeCode: string | number; - - constructor( - entityOrName: typeof Entity | string, - message: string, - options: { - code: string; - statusCode: number; - nativeCode: string | number; - }, - ) { - const entityName = - typeof entityOrName === 'string' - ? entityOrName - : entityOrName.modelName || entityOrName.name; - - super(message); - - this.name = 'DatabaseDriverError'; - this.entityName = entityName; - this.code = options.code; - this.statusCode = options.statusCode; - this.nativeCode = options.nativeCode; - - Error.captureStackTrace(this, this.constructor); - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isDatabaseDriverError(e: any): e is DatabaseDriverError { - return e instanceof DatabaseDriverError; -} diff --git a/packages/repository/src/errors/database.error.ts b/packages/repository/src/errors/database.error.ts new file mode 100644 index 000000000000..007b44b8ee71 --- /dev/null +++ b/packages/repository/src/errors/database.error.ts @@ -0,0 +1,98 @@ +// 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 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 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..e5cb5d8b2bbe --- /dev/null +++ b/packages/repository/src/errors/handle-repository-error.ts @@ -0,0 +1,53 @@ +import { + CheckConstraintError, + DatabaseConnectionError, + DatabaseError, + DataTypeMismatchError, + ForeignKeyConstraintError, + LockConflictError, + NotNullConstraintError, + UniqueConstraintError, +} from './database.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.'); + } + + // 1. Pass through if already a standard DatabaseError instance + if (err instanceof DatabaseError) { + throw err; + } + + // 2. Map normalized error code strings coming from connectors + 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); + 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); + } + } + + // 3. Fallback: rethrow unmapped error as-is + throw err; +} diff --git a/packages/repository/src/errors/index.ts b/packages/repository/src/errors/index.ts index 196a5a0545c8..cd2a8d43b9e2 100644 --- a/packages/repository/src/errors/index.ts +++ b/packages/repository/src/errors/index.ts @@ -7,4 +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-driver.error'; +export * from './database.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..29944d348085 --- /dev/null +++ b/packages/rest/src/error-writer/database-error-mapper.ts @@ -0,0 +1,43 @@ +import {HttpErrors} from '@loopback/rest'; + +/** + * Maps protocol-neutral DatabaseError instances or error objects containing + * database code strings into standard REST HttpErrors. + */ +export function mapDatabaseErrorToHttpError(err: unknown): unknown { + if (!err || typeof err !== 'object') { + return err; + } + + const errorObj = err as {code?: string; message?: string}; + const code = errorObj.code; + + switch (code) { + // 409 Conflict + case 'UNIQUE_CONSTRAINT_VIOLATION': + case 'LOCK_CONFLICT': + return new HttpErrors.Conflict(errorObj.message); + + // 422 Unprocessable Entity + case 'FOREIGN_KEY_VIOLATION': + return new HttpErrors.UnprocessableEntity(errorObj.message); + + // 400 Bad Request + case 'NOT_NULL_VIOLATION': + case 'CHECK_CONSTRAINT_VIOLATION': + case 'DATA_TYPE_MISMATCH': + case 'GENERATED_COLUMN_VIOLATION': + return new HttpErrors.BadRequest(errorObj.message); + + // 504 Gateway Timeout + case 'QUERY_TIMEOUT': + return new HttpErrors.GatewayTimeout(errorObj.message); + + // 503 Service Unavailable + case 'CONNECTION_FAILURE': + return new HttpErrors.ServiceUnavailable(errorObj.message); + + default: + return err; + } +} diff --git a/packages/rest/src/providers/reject.provider.ts b/packages/rest/src/providers/reject.provider.ts index b7971382f56d..bcd2200c515d 100644 --- a/packages/rest/src/providers/reject.provider.ts +++ b/packages/rest/src/providers/reject.provider.ts @@ -8,6 +8,7 @@ import {HttpError} from 'http-errors'; import {ErrorWriterOptions, writeErrorToResponse} from 'strong-error-handler'; import {RestBindings} from '../keys'; import {HandlerContext, LogError, Reject} from '../types'; +import {mapDatabaseErrorToHttpError} from '../error-writer/database-error-mapper'; // TODO(bajtos) Make this mapping configurable at RestServer level, // allow apps and extensions to contribute additional mappings. @@ -24,7 +25,9 @@ export class RejectProvider { errorWriterOptions?: ErrorWriterOptions, ): Reject { const reject: Reject = ({request, response}: HandlerContext, error) => { - const err = error; + // 1. Map domain database errors to HttpErrors + const mappedError = mapDatabaseErrorToHttpError(error); + const err = mappedError; if (!err.status && !err.statusCode && err.code) { const customStatus = codeToStatusCodeMap[err.code]; @@ -35,7 +38,7 @@ export class RejectProvider { const statusCode = err.statusCode || err.status || 500; writeErrorToResponse(err, request, response, errorWriterOptions); - logError(error, statusCode, request); + logError(err, statusCode, request); }; return reject; }