From a924a2805895039eff790c5c35d5773fdc529f4f Mon Sep 17 00:00:00 2001 From: Lenny Date: Mon, 30 Mar 2026 13:26:32 +0700 Subject: [PATCH 1/2] fix: improved error handling and normalization --- src/http/routes/s3/index.ts | 9 ++- src/internal/database/connection.ts | 9 +++ src/internal/errors/codes.ts | 55 +++++++++++++++++-- src/storage/database/knex.ts | 28 ++++++++++ .../protocols/s3/signature-v4-stream.ts | 10 +++- src/test/signature-v4-stream.test.ts | 4 +- 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/http/routes/s3/index.ts b/src/http/routes/s3/index.ts index 43a004992..7f2c42af5 100644 --- a/src/http/routes/s3/index.ts +++ b/src/http/routes/s3/index.ts @@ -76,9 +76,12 @@ export default async function routes(fastify: FastifyInstance) { const isValid = compiler(data) if (!isValid) { - const validationError = new Error('Invalid request') - ;(validationError as Error & { validation?: unknown }).validation = - compiler.errors + const validationError = new Error('Invalid request') as Error & { + validation?: unknown + statusCode?: number + } + validationError.validation = compiler.errors + validationError.statusCode = 400 throw validationError } diff --git a/src/internal/database/connection.ts b/src/internal/database/connection.ts index 795109c6a..1f27d54dc 100644 --- a/src/internal/database/connection.ts +++ b/src/internal/database/connection.ts @@ -135,6 +135,15 @@ export class TenantConnection { throw ERRORS.DatabaseTimeout(e) } + // Handle database connection limit errors + if ( + e instanceof DatabaseError && + ((e.code === '08P01' && e.message.includes('no more connections allowed')) || + e.message.includes('Max client connections reached')) + ) { + throw ERRORS.DatabaseConnectionLimit(e) + } + throw e } } diff --git a/src/internal/errors/codes.ts b/src/internal/errors/codes.ts index 274083765..d041ab6c1 100644 --- a/src/internal/errors/codes.ts +++ b/src/internal/errors/codes.ts @@ -19,6 +19,10 @@ export enum ErrorCode { KeyAlreadyExists = 'KeyAlreadyExists', BucketAlreadyExists = 'BucketAlreadyExists', DatabaseTimeout = 'DatabaseTimeout', + DatabaseConnectionLimit = 'DatabaseConnectionLimit', + DatabaseReadOnly = 'DatabaseReadOnly', + InvalidObjectDefinition = 'InvalidObjectDefinition', + DatabaseSchemaMismatch = 'DatabaseSchemaMismatch', InvalidSignature = 'InvalidSignature', ExpiredToken = 'ExpiredToken', SignatureDoesNotMatch = 'SignatureDoesNotMatch', @@ -276,12 +280,12 @@ export const ERRORS = { message: `invalid range provided`, }), - EntityTooLarge: (e?: Error, entity = 'object') => + EntityTooLarge: (e?: Error, entity = 'object', limit = 'the maximum allowed size') => new StorageBackendError({ error: 'Payload too large', code: ErrorCode.EntityTooLarge, httpStatusCode: 413, - message: `The ${entity} exceeded the maximum allowed size`, + message: `The ${entity} exceeded ${limit}`, originalError: e, }), @@ -372,6 +376,39 @@ export const ERRORS = { originalError: e, }), + DatabaseConnectionLimit: (e?: Error) => + new StorageBackendError({ + code: ErrorCode.DatabaseConnectionLimit, + httpStatusCode: 503, + message: + 'The database has reached its maximum number of connections. Please try again later.', + originalError: e, + }), + + DatabaseReadOnly: (e?: Error) => + new StorageBackendError({ + code: ErrorCode.DatabaseReadOnly, + httpStatusCode: 503, + message: 'The database is currently in read-only mode. Please try again later.', + originalError: e, + }), + + InvalidObjectDefinition: (e?: Error) => + new StorageBackendError({ + code: ErrorCode.InvalidObjectDefinition, + httpStatusCode: 503, + message: 'The database schema is invalid or incompatible.', + originalError: e, + }), + + DatabaseSchemaMismatch: (e?: Error) => + new StorageBackendError({ + code: ErrorCode.DatabaseSchemaMismatch, + httpStatusCode: 503, + message: 'The database schema is out of sync. Please run migrations or contact support.', + originalError: e, + }), + ResourceLocked: (e?: Error) => new StorageBackendError({ code: ErrorCode.ResourceLocked, @@ -517,10 +554,20 @@ export function isStorageError(errorType: ErrorCode, error: any): error is Stora return error instanceof StorageBackendError && error.code === errorType } +function hasStatusCode(error: Error): error is Error & { statusCode: number } { + return 'statusCode' in error && typeof (error as any).statusCode === 'number' +} + export function normalizeRawError(error: any) { if (error instanceof Error) { - const statusCode = - error instanceof StorageBackendError && error.httpStatusCode ? error.httpStatusCode : 0 + let statusCode = 0 + if (error instanceof StorageBackendError && error.httpStatusCode) { + statusCode = error.httpStatusCode + } else if (hasStatusCode(error)) { + // Fastify validation errors include statusCode we can use + statusCode = error.statusCode + } + return { raw: JSON.stringify(error), name: error.name, diff --git a/src/storage/database/knex.ts b/src/storage/database/knex.ts index a8c6f6668..5e50fa510 100644 --- a/src/storage/database/knex.ts +++ b/src/storage/database/knex.ts @@ -1171,6 +1171,34 @@ export class DBError extends StorageBackendError implements RenderableError { query, code: pgError.code, }) + case '25006': // read only sql transaction + return ERRORS.DatabaseReadOnly(pgError).withMetadata({ + query, + code: pgError.code, + }) + case '42P17': // invalid object definition + return ERRORS.InvalidObjectDefinition(pgError).withMetadata({ + query, + code: pgError.code, + }) + case '22P02': // invalid text representation + return ERRORS.InvalidParameter('value', { + error: pgError, + message: pgError.message || 'Invalid value format or type conversion failed', + }).withMetadata({ + query, + code: pgError.code, + }) + case '42703': // undefined column + return ERRORS.DatabaseSchemaMismatch(pgError).withMetadata({ + query, + code: pgError.code, + }) + case '42P01': // relation does not exist + return ERRORS.DatabaseSchemaMismatch(pgError).withMetadata({ + query, + code: pgError.code, + }) default: return ERRORS.DatabaseError(`database error, code: ${pgError.code}`, pgError).withMetadata({ query, diff --git a/src/storage/protocols/s3/signature-v4-stream.ts b/src/storage/protocols/s3/signature-v4-stream.ts index 33e655f2d..4d3ea2a34 100644 --- a/src/storage/protocols/s3/signature-v4-stream.ts +++ b/src/storage/protocols/s3/signature-v4-stream.ts @@ -1,5 +1,6 @@ // ChunkSignatureParser.ts +import { ERRORS } from '@internal/errors' import crypto from 'crypto' import { Transform, TransformCallback, TransformOptions } from 'stream' @@ -118,7 +119,14 @@ export class ChunkSignatureV4Parser extends Transform { cb() } catch (err) { - cb(err as Error) + const error = err as Error + // Convert chunk size errors to 400 instead of 500 + if (error.message && error.message.includes('Chunk size exceeds')) { + const limit = error.message.replace('Chunk size exceeds', '').trim() + cb(ERRORS.EntityTooLarge(error, 'chunk', limit)) + } else { + cb(error) + } } } diff --git a/src/test/signature-v4-stream.test.ts b/src/test/signature-v4-stream.test.ts index 2cc74f6f9..6d3b948d8 100644 --- a/src/test/signature-v4-stream.test.ts +++ b/src/test/signature-v4-stream.test.ts @@ -66,7 +66,7 @@ describe('ChunkSignatureV4Parser', () => { const sig = 'f'.repeat(64) return new Promise((resolve) => { parser.on('error', (err) => { - expect(err.message).toMatch(/Chunk size exceeds 1 bytes/) + expect(err.message).toEqual('The chunk exceeded 1 bytes') resolve() }) parser.write(`2;chunk-signature=${sig}\r\n`) @@ -161,7 +161,7 @@ describe('ChunkSignatureV4Parser', () => { const parser = makeParser({ maxChunkSize: 1 }) return new Promise((resolve) => { parser.on('error', (err) => { - expect(err.message).toMatch(/Chunk size exceeds 1 bytes/) + expect(err.message).toEqual('The chunk exceeded 1 bytes') resolve() }) parser.write(`2;chunk-signature=${sig}\r\n`) From 1421208313dff66816b8e38daee38a5dbe6f9876 Mon Sep 17 00:00:00 2001 From: Lenny Date: Mon, 30 Mar 2026 16:50:38 +0700 Subject: [PATCH 2/2] use database prefix for InvalidObjectDefinition error --- src/internal/errors/codes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/errors/codes.ts b/src/internal/errors/codes.ts index d041ab6c1..ef36cd60c 100644 --- a/src/internal/errors/codes.ts +++ b/src/internal/errors/codes.ts @@ -21,7 +21,7 @@ export enum ErrorCode { DatabaseTimeout = 'DatabaseTimeout', DatabaseConnectionLimit = 'DatabaseConnectionLimit', DatabaseReadOnly = 'DatabaseReadOnly', - InvalidObjectDefinition = 'InvalidObjectDefinition', + DatabaseInvalidObjectDefinition = 'DatabaseInvalidObjectDefinition', DatabaseSchemaMismatch = 'DatabaseSchemaMismatch', InvalidSignature = 'InvalidSignature', ExpiredToken = 'ExpiredToken', @@ -395,7 +395,7 @@ export const ERRORS = { InvalidObjectDefinition: (e?: Error) => new StorageBackendError({ - code: ErrorCode.InvalidObjectDefinition, + code: ErrorCode.DatabaseInvalidObjectDefinition, httpStatusCode: 503, message: 'The database schema is invalid or incompatible.', originalError: e,