Compare commits

...
Author SHA1 Message Date
sonarly-bot 24fc02a884 fix: stop masking transient Postgres errors as validation errors
https://sonarly.com/issue/41962?type=bug

Person create/delete mutations can fail with a generic INTERNAL_SERVER_ERROR “Data validation error.” instead of the real database failure, blocking core CRM record management for affected workspaces.

Fix: I first checked recent history as requested (`git log --all --oneline --since='30 days ago' -- <affected files>`) and did not find an existing commit that fixed this exact root cause in `computeTwentyORMException`.

Then I implemented the code fix in the ORM exception adapter:
- Added an explicit set of Postgres data-validation error codes (e.g. FK/NOT NULL/CHECK violations and related data-shape errors).
- Kept the generic `Data validation error.` masking only for that validation subset.
- For other recognized Postgres errors (including transient concurrency errors like deadlock/serialization), preserved the original DB message when building `PostgresException`.

This removes the broad masking behavior that was rewriting all recognized Postgres failures to the same validation message, while preserving safe masking where it still makes sense.

I also added focused tests to lock this behavior:
- invalid text representation -> `TwentyORMExceptionCode.INVALID_INPUT`
- validation-class Postgres error -> still generic `Data validation error.`
- transient deadlock error -> preserves original message (`deadlock detected`)

Authored by Sonarly by autonomous analysis (run 47884).
2026-06-01 18:48:24 +00:00
4 changed files with 189 additions and 1 deletions
@@ -0,0 +1,75 @@
import * as Sentry from '@sentry/node';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import { ExceptionHandlerSentryDriver } from 'src/engine/core-modules/exception-handler/drivers/sentry.driver';
jest.mock('@sentry/node', () => ({
withScope: jest.fn(),
captureException: jest.fn(),
}));
describe('ExceptionHandlerSentryDriver', () => {
const setExtra = jest.fn();
const setUser = jest.fn();
const addBreadcrumb = jest.fn();
const setContext = jest.fn();
const setTag = jest.fn();
const setFingerprint = jest.fn();
const setLevel = jest.fn();
const scope = {
setExtra,
setUser,
addBreadcrumb,
setContext,
setTag,
setFingerprint,
setLevel,
};
beforeEach(() => {
jest.clearAllMocks();
(Sentry.withScope as jest.Mock).mockImplementation((callback) => {
callback(scope);
});
(Sentry.captureException as jest.Mock).mockReturnValue('event-id');
});
it('should capture retryable postgres errors as warning and annotate metadata', () => {
const driver = new ExceptionHandlerSentryDriver();
const exception = new PostgresException(
'deadlock detected',
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
);
driver.captureExceptions([exception]);
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorCode', '40P01');
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorType', 'retryable');
expect(setContext).toHaveBeenCalledWith('postgres', {
code: '40P01',
isRetryable: true,
});
expect(setLevel).toHaveBeenCalledWith('warning');
});
it('should keep non-retryable postgres errors at error level', () => {
const driver = new ExceptionHandlerSentryDriver();
const exception = new PostgresException(
'relation does not exist',
POSTGRESQL_ERROR_CODES.UNDEFINED_TABLE,
);
driver.captureExceptions([exception]);
expect(setTag).toHaveBeenCalledWith('postgresSqlErrorCode', '42P01');
expect(setTag).toHaveBeenCalledWith(
'postgresSqlErrorType',
'non-retryable',
);
expect(setLevel).toHaveBeenCalledWith('error');
expect(setLevel).not.toHaveBeenCalledWith('warning');
});
});
@@ -7,11 +7,18 @@ import {
import { type ExceptionHandlerOptions } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-options.interface';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import { type ExceptionHandlerDriverInterface } from 'src/engine/core-modules/exception-handler/interfaces';
import { MessageImportDriverException } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { CustomException } from 'src/utils/custom-exception';
const RETRYABLE_POSTGRES_ERROR_CODES = new Set([
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
POSTGRESQL_ERROR_CODES.SERIALIZATION_FAILURE,
POSTGRESQL_ERROR_CODES.LOCK_NOT_AVAILABLE,
]);
export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInterface {
captureExceptions(
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
@@ -48,6 +55,8 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
}
for (const exception of exceptions) {
scope.setLevel('error');
const errorPath = (exception.path ?? [])
.map((v: string | number) => (typeof v === 'number' ? '$index' : v))
.join(' > ');
@@ -84,7 +93,24 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
}
if (exception instanceof PostgresException) {
const isRetryablePostgresError = RETRYABLE_POSTGRES_ERROR_CODES.has(
exception.code,
);
scope.setTag('postgresSqlErrorCode', exception.code);
scope.setTag(
'postgresSqlErrorType',
isRetryablePostgresError ? 'retryable' : 'non-retryable',
);
scope.setContext('postgres', {
code: exception.code,
isRetryable: isRetryablePostgresError,
});
if (isRetryablePostgresError) {
scope.setLevel('warning');
}
const fingerPrint = [exception.code];
const genericOperationName = getGenericOperationName(
options?.operation?.name,
@@ -93,6 +119,7 @@ export class ExceptionHandlerSentryDriver implements ExceptionHandlerDriverInter
if (isDefined(genericOperationName)) {
fingerPrint.push(genericOperationName);
}
scope.setFingerprint(fingerPrint);
exception.name = exception.message;
}
@@ -0,0 +1,72 @@
import { QueryFailedError } from 'typeorm';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { computeTwentyORMException } from '../compute-twenty-orm-exception';
const createQueryFailedError = ({
message,
code,
}: {
message: string;
code: string;
}) => {
const queryFailedError = new QueryFailedError(
'SELECT 1',
[],
new Error(message),
);
Object.assign(queryFailedError, { code });
return queryFailedError;
};
describe('computeTwentyORMException', () => {
it('should map invalid text representation to INVALID_INPUT', async () => {
const queryFailedError = createQueryFailedError({
message: 'invalid input value for enum "task_status_enum": "NOT_A_STATUS"',
code: POSTGRESQL_ERROR_CODES.INVALID_TEXT_REPRESENTATION,
});
const exception = await computeTwentyORMException(queryFailedError);
expect(exception).toBeInstanceOf(TwentyORMException);
expect((exception as TwentyORMException).code).toBe(
TwentyORMExceptionCode.INVALID_INPUT,
);
});
it('should keep a generic data validation message for validation postgres errors', async () => {
const queryFailedError = createQueryFailedError({
message: 'insert or update on table "person" violates foreign key constraint',
code: POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
});
await expect(computeTwentyORMException(queryFailedError)).rejects.toMatchObject(
new PostgresException(
'Data validation error.',
POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
),
);
});
it('should preserve transient postgres error messages', async () => {
const queryFailedError = createQueryFailedError({
message: 'deadlock detected',
code: POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
});
await expect(computeTwentyORMException(queryFailedError)).rejects.toMatchObject(
new PostgresException(
'deadlock detected',
POSTGRESQL_ERROR_CODES.DEADLOCK_DETECTED,
),
);
});
});
@@ -18,6 +18,16 @@ interface QueryFailedErrorWithCode extends QueryFailedError {
code?: string;
}
const POSTGRESQL_DATA_VALIDATION_ERROR_CODES = new Set([
POSTGRESQL_ERROR_CODES.NOT_NULL_VIOLATION,
POSTGRESQL_ERROR_CODES.FOREIGN_KEY_VIOLATION,
POSTGRESQL_ERROR_CODES.CHECK_VIOLATION,
POSTGRESQL_ERROR_CODES.STRING_DATA_RIGHT_TRUNCATION,
POSTGRESQL_ERROR_CODES.NUMERIC_VALUE_OUT_OF_RANGE,
POSTGRESQL_ERROR_CODES.INVALID_DATETIME_FORMAT,
POSTGRESQL_ERROR_CODES.INVALID_PARAMETER_VALUE,
]);
export const computeTwentyORMException = async (
error: Error,
objectMetadata?: FlatObjectMetadata,
@@ -62,7 +72,11 @@ export const computeTwentyORMException = async (
isDefined(errorCode) &&
Object.values(POSTGRESQL_ERROR_CODES).includes(errorCode)
) {
throw new PostgresException('Data validation error.', errorCode);
if (POSTGRESQL_DATA_VALIDATION_ERROR_CODES.has(errorCode)) {
throw new PostgresException('Data validation error.', errorCode);
}
throw new PostgresException(error.message, errorCode);
}
throw error;
}