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..ef36cd60c 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', + DatabaseInvalidObjectDefinition = 'DatabaseInvalidObjectDefinition', + 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.DatabaseInvalidObjectDefinition, + 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`)