fix(route-trigger): distinguish user vs platform logic function execution errors (#21715)

## What

Splits route trigger logic-function failures into two cases instead of
one catch-all:

- **User error** — the function's own code threw an uncaught error.
Returns `500` and is **not** sent to Sentry.
- **Platform error** — an infrastructure/execution failure on our side.
Returns `500` and **is** sent to Sentry.

A disabled logic function now returns `403`.

## Why

User-code failures were flooding Sentry: a single workspace's function
hitting a transient upstream error generated tens of thousands of
events. #21656 stopped the flood by muting the entire route-trigger
execution error bucket — but muting everything also silenced genuine
platform failures we *do* want to be alerted on.

Splitting the bucket keeps the user-code noise out of Sentry (the
original goal) while making sure real platform errors still surface.

Users who want to return a specific status/body when their function
fails can still catch the error and return a `Response` — that path is
unchanged.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21715?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-17 12:45:04 +02:00
committed by GitHub
parent a7760c04ab
commit 34362de7b7
7 changed files with 107 additions and 14 deletions
@@ -247,7 +247,7 @@ export class LambdaDriver implements LogicFunctionDriver {
throw new LogicFunctionException(
`Lambda invocation failed for function '${flatLogicFunction.id}' during ${currentPhase}: ${error instanceof Error ? error.message : 'Unknown error'}`,
LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_FAILED,
LogicFunctionExceptionCode.LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR,
);
}
}
@@ -0,0 +1,82 @@
import { type ArgumentsHost } from '@nestjs/common';
import { type Response } from 'express';
import { type HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter';
import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
describe('RouteTriggerRestApiExceptionFilter', () => {
const handleError = jest.fn();
const response = {} as Response;
const httpExceptionHandlerService = {
handleError,
} as unknown as HttpExceptionHandlerService;
const filter = new RouteTriggerRestApiExceptionFilter(
httpExceptionHandlerService,
);
const host = {
switchToHttp: () => ({ getResponse: () => response }),
} as unknown as ArgumentsHost;
afterEach(() => {
jest.clearAllMocks();
});
it('returns 500 and does NOT capture user-uncaught errors in Sentry', () => {
const exception = new RouteTriggerException(
'boom',
RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR,
);
filter.catch(exception, host);
expect(handleError).toHaveBeenCalledWith(
exception,
response,
500,
undefined,
undefined,
{ shouldBeCapturedBySentry: false },
);
});
it('returns 500 and captures platform errors in Sentry', () => {
const exception = new RouteTriggerException(
'boom',
RouteTriggerExceptionCode.ROUTE_TRIGGER_PLATFORM_ERROR,
);
filter.catch(exception, host);
expect(handleError).toHaveBeenCalledWith(exception, response, 500);
});
it('maps a disabled function to 403', () => {
const exception = new RouteTriggerException(
'disabled',
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
);
filter.catch(exception, host);
expect(handleError).toHaveBeenCalledWith(exception, response, 403);
});
it('maps not-found codes to 404', () => {
const exception = new RouteTriggerException(
'missing',
RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
);
filter.catch(exception, host);
expect(handleError).toHaveBeenCalledWith(exception, response, 404);
});
});
@@ -45,7 +45,7 @@ export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter {
response,
429,
);
case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR:
case RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
@@ -54,6 +54,12 @@ export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter {
undefined,
{ shouldBeCapturedBySentry: false },
);
case RouteTriggerExceptionCode.ROUTE_TRIGGER_PLATFORM_ERROR:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
500,
);
case RouteTriggerExceptionCode.ROUTE_ALREADY_EXIST:
case RouteTriggerExceptionCode.ROUTE_PATH_ALREADY_EXIST:
default: {
@@ -12,7 +12,8 @@ export enum RouteTriggerExceptionCode {
ROUTE_ALREADY_EXIST = 'ROUTE_ALREADY_EXIST',
ROUTE_PATH_ALREADY_EXIST = 'ROUTE_PATH_ALREADY_EXIST',
FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION',
LOGIC_FUNCTION_EXECUTION_ERROR = 'LOGIC_FUNCTION_EXECUTION_ERROR',
ROUTE_TRIGGER_USER_UNCAUGHT_ERROR = 'ROUTE_TRIGGER_USER_UNCAUGHT_ERROR',
ROUTE_TRIGGER_PLATFORM_ERROR = 'ROUTE_TRIGGER_PLATFORM_ERROR',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
}
@@ -34,8 +35,10 @@ const getRouteTriggerExceptionUserFriendlyMessage = (
return msg`Route path already exists.`;
case RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION:
return msg`You do not have permission to perform this action.`;
case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR:
case RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR:
return msg`Logic function execution failed.`;
case RouteTriggerExceptionCode.ROUTE_TRIGGER_PLATFORM_ERROR:
return msg`An unexpected error occurred while executing the logic function.`;
case RouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
return msg`Too many requests. Please try again later.`;
default:
@@ -163,14 +163,16 @@ export class RouteTriggerService {
}
}
if (
error instanceof LogicFunctionException &&
error.code === LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND
) {
return RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND;
if (error instanceof LogicFunctionException) {
switch (error.code) {
case LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
return RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND;
case LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED:
return RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION;
}
}
return RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR;
return RouteTriggerExceptionCode.ROUTE_TRIGGER_PLATFORM_ERROR;
}
async handle({
@@ -249,7 +251,7 @@ export class RouteTriggerService {
if (result.error) {
throw new RouteTriggerException(
result.error.errorMessage,
RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR,
RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR,
);
}
@@ -14,7 +14,7 @@ export enum LogicFunctionExceptionCode {
LOGIC_FUNCTION_CREATE_FAILED = 'LOGIC_FUNCTION_CREATE_FAILED',
LOGIC_FUNCTION_COMPILATION_FAILED = 'LOGIC_FUNCTION_COMPILATION_FAILED',
LOGIC_FUNCTION_EXECUTION_TIMEOUT = 'LOGIC_FUNCTION_EXECUTION_TIMEOUT',
LOGIC_FUNCTION_EXECUTION_FAILED = 'LOGIC_FUNCTION_EXECUTION_FAILED',
LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR = 'LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR',
LOGIC_FUNCTION_LAYER_BUILD_FAILED = 'LOGIC_FUNCTION_LAYER_BUILD_FAILED',
LOGIC_FUNCTION_DISABLED = 'LOGIC_FUNCTION_DISABLED',
LOGIC_FUNCTION_INVALID_SEED_PROJECT = 'LOGIC_FUNCTION_INVALID_SEED_PROJECT',
@@ -44,7 +44,7 @@ const getLogicFunctionExceptionUserFriendlyMessage = (
return msg`Function code failed to compile.`;
case LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_TIMEOUT:
return msg`Function execution timed out.`;
case LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_FAILED:
case LogicFunctionExceptionCode.LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR:
return msg`Function execution failed.`;
case LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED:
return msg`Failed to build function dependencies.`;
@@ -29,7 +29,7 @@ export const logicFunctionGraphQLApiExceptionHandler = (error: any) => {
case LogicFunctionExceptionCode.LOGIC_FUNCTION_CODE_UNCHANGED:
case LogicFunctionExceptionCode.LOGIC_FUNCTION_CREATE_FAILED:
case LogicFunctionExceptionCode.LOGIC_FUNCTION_INVALID_SEED_PROJECT:
case LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_FAILED:
case LogicFunctionExceptionCode.LOGIC_FUNCTION_PLATFORM_EXECUTION_ERROR:
case LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED:
throw error;
case LogicFunctionExceptionCode.LOGIC_FUNCTION_COMPILATION_FAILED: