From 446c39d1c08f70ba0dfd59cc70c83e5881f265ac Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 15 Apr 2026 18:49:43 +0200 Subject: [PATCH] Fix silent failures in logic function route trigger execution (#19698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Route-triggered logic functions were returning empty 500 responses with zero server-side logging when the Lambda build chain failed. This PR makes those failures observable and returns meaningful HTTP responses to API clients. - **Observability** — Log errors (with stack traces) at each layer of the execution chain: `LambdaDriver` (deps-layer fetch, SDK-layer fetch, invocation), `LogicFunctionExecutorService`, and `RouteTriggerService`. - **Typed exceptions** — Replace raw `throw error` sites with `LogicFunctionException` carrying an appropriate code and `userFriendlyMessage` (new codes: `LOGIC_FUNCTION_EXECUTION_FAILED`, `LOGIC_FUNCTION_LAYER_BUILD_FAILED`). - **Correct HTTP semantics** — `RouteTriggerService` maps inner exception codes to the right `RouteTriggerExceptionCode` so `LOGIC_FUNCTION_NOT_FOUND` returns 404 and `RATE_LIMIT_EXCEEDED` returns 429 (new code + filter case) instead of a generic 500. - **User-facing messages** — Forward the inner `CustomException.userFriendlyMessage` when wrapping into `RouteTriggerException`, without leaking raw internal error text into the public exception message. - **Infra** — Bump Lambda ephemeral storage from 2048 to 4096 MB to prevent `ENOSPC` errors during yarn install layer builds (root cause of the original silent failures). --- .../drivers/lambda.driver.ts | 61 +++++++++++++--- .../logic-function-executor.service.ts | 34 ++++++--- ...route-trigger-rest-api-exception-filter.ts | 6 ++ .../exceptions/route-trigger.exception.ts | 3 + .../triggers/route/route-trigger.service.ts | 73 +++++++++++++++++-- .../logic-function.exception.ts | 6 ++ ...ion-graphql-api-exception-handler.utils.ts | 2 + 7 files changed, 159 insertions(+), 26 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts index b7bf561f5ec..808e03a6df7 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda.driver.ts @@ -25,6 +25,7 @@ import { import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { @@ -64,7 +65,7 @@ const YARN_INSTALL_LAMBDA_MEMORY_MB = 1024; const COMMON_LAYER_NAME_PREFIX = 'twenty-common-layer'; const BUILDER_LAMBDA_TIMEOUT_SECONDS = 60; const BUILDER_LAMBDA_MEMORY_MB = 512; -const LAMBDA_EPHEMERAL_STORAGE_MB = 2048; +const LAMBDA_EPHEMERAL_STORAGE_MB = 4096; const YARN_INSTALL_HANDLER_PATH = resolve( __dirname, join( @@ -122,6 +123,7 @@ export interface LambdaDriverOptions extends LambdaClientConfig { } export class LambdaDriver implements LogicFunctionDriver { + private readonly logger = new Logger(LambdaDriver.name); private lambdaClient: Lambda | undefined; private assumeRoleCredentials: | { accessKeyId: string; secretAccessKey: string; sessionToken: string } @@ -1002,15 +1004,41 @@ export class LambdaDriver implements LogicFunctionDriver { applicationUniversalIdentifier: string; lambdaExecutor: GetFunctionCommandOutput | undefined; }) { - const depsLayerArn = await this.getLayerArn({ - flatApplication, - applicationUniversalIdentifier, - }); + let depsLayerArn: string; - const sdkLayerArn = await this.ensureSdkLayer({ - flatApplication, - applicationUniversalIdentifier, - }); + try { + depsLayerArn = await this.getLayerArn({ + flatApplication, + applicationUniversalIdentifier, + }); + } catch (error) { + this.logger.error( + `Failed to get dependency layer for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + throw new LogicFunctionException( + `Failed to get dependency layer for function '${flatLogicFunction.id}': ${error instanceof Error ? error.message : 'Unknown error'}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED, + ); + } + + let sdkLayerArn: string; + + try { + sdkLayerArn = await this.ensureSdkLayer({ + flatApplication, + applicationUniversalIdentifier, + }); + } catch (error) { + this.logger.error( + `Failed to get SDK layer for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + throw new LogicFunctionException( + `Failed to get SDK layer for function '${flatLogicFunction.id}': ${error instanceof Error ? error.message : 'Unknown error'}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED, + ); + } if (!isDefined(lambdaExecutor)) { await this.createLambdaExecutor({ @@ -1158,13 +1186,26 @@ export class LambdaDriver implements LogicFunctionDriver { status: LogicFunctionExecutionStatus.SUCCESS, }; } catch (error) { + this.logger.error( + `Lambda invocation failed for function ${flatLogicFunction.id}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + if (error instanceof ResourceNotFoundException) { throw new LogicFunctionException( `Function '${flatLogicFunction.id}' does not exist`, LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, ); } - throw error; + + if (error instanceof LogicFunctionException) { + throw error; + } + + throw new LogicFunctionException( + `Lambda invocation failed for function '${flatLogicFunction.id}': ${error instanceof Error ? error.message : 'Unknown error'}`, + LogicFunctionExceptionCode.LOGIC_FUNCTION_EXECUTION_FAILED, + ); } } } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts index 9792eea447a..26a9dee0ec2 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { DEFAULT_API_KEY_NAME, @@ -50,6 +50,8 @@ export enum LogicFunctionExecutionExceptionCode { @Injectable() export class LogicFunctionExecutorService { + private readonly logger = new Logger(LogicFunctionExecutorService.name); + constructor( private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory, private readonly throttlerService: ThrottlerService, @@ -87,14 +89,28 @@ export class LogicFunctionExecutorService { const driver = this.logicFunctionDriverFactory.getCurrentDriver(); - const resultLogicFunction = await driver.execute({ - flatLogicFunction, - flatApplication, - applicationUniversalIdentifier: flatApplication.universalIdentifier, - payload, - env: envVariables, - timeoutMs: flatLogicFunction.timeoutSeconds * 1_000, - }); + let resultLogicFunction: LogicFunctionExecuteResult; + + try { + resultLogicFunction = await driver.execute({ + flatLogicFunction, + flatApplication, + applicationUniversalIdentifier: flatApplication.universalIdentifier, + payload, + env: envVariables, + timeoutMs: flatLogicFunction.timeoutSeconds * 1_000, + }); + } catch (error) { + this.logger.error( + `Logic function execution failed: ` + + `functionId=${logicFunctionId}, ` + + `workspaceId=${workspaceId}, ` + + `driver=${driver.constructor.name}: ` + + `${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + throw error; + } await this.handleExecutionResult({ result: resultLogicFunction, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts index ebcf97b15bf..43955dae280 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter.ts @@ -39,6 +39,12 @@ export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter { response, 403, ); + case RouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 429, + ); case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR: return this.httpExceptionHandlerService.handleError( exception as CustomException, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts index 5e4e52c81a9..b08ca905991 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception.ts @@ -13,6 +13,7 @@ export enum RouteTriggerExceptionCode { ROUTE_PATH_ALREADY_EXIST = 'ROUTE_PATH_ALREADY_EXIST', FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION', LOGIC_FUNCTION_EXECUTION_ERROR = 'LOGIC_FUNCTION_EXECUTION_ERROR', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', } const getRouteTriggerExceptionUserFriendlyMessage = ( @@ -35,6 +36,8 @@ const getRouteTriggerExceptionUserFriendlyMessage = ( return msg`You do not have permission to perform this action.`; case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR: return msg`Logic function execution failed.`; + case RouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED: + return msg`Too many requests. Please try again later.`; default: assertUnreachable(code); } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts index a227adada9f..77daadb3751 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Request } from 'express'; @@ -14,11 +14,22 @@ import { RouteTriggerExceptionCode, } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception'; import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util'; +import { + LogicFunctionException, + LogicFunctionExceptionCode, +} from 'src/engine/metadata-modules/logic-function/logic-function.exception'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; -import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { + LogicFunctionExecutionException, + LogicFunctionExecutionExceptionCode, + LogicFunctionExecutorService, +} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { CustomException } from 'src/utils/custom-exception'; @Injectable() export class RouteTriggerService { + private readonly logger = new Logger(RouteTriggerService.name); + constructor( private readonly accessTokenService: AccessTokenService, private readonly logicFunctionExecutorService: LogicFunctionExecutorService, @@ -118,6 +129,28 @@ export class RouteTriggerService { return authContext; } + private mapErrorToRouteTriggerCode( + error: unknown, + ): RouteTriggerExceptionCode { + if (error instanceof LogicFunctionExecutionException) { + switch (error.code) { + case LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND: + return RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND; + case LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED: + return RouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED; + } + } + + if ( + error instanceof LogicFunctionException && + error.code === LogicFunctionExceptionCode.LOGIC_FUNCTION_NOT_FOUND + ) { + return RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND; + } + + return RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR; + } + async handle({ request, httpMethod, @@ -146,11 +179,37 @@ export class RouteTriggerService { forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [], }); - const result = await this.logicFunctionExecutorService.execute({ - logicFunctionId: logicFunction.id, - workspaceId: logicFunction.workspaceId, - payload: event, - }); + let result; + + try { + result = await this.logicFunctionExecutorService.execute({ + logicFunctionId: logicFunction.id, + workspaceId: logicFunction.workspaceId, + payload: event, + }); + } catch (error) { + if (error instanceof RouteTriggerException) { + throw error; + } + + this.logger.error( + `Unexpected error executing logic function ${logicFunction.id}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + + const code = this.mapErrorToRouteTriggerCode(error); + + throw new RouteTriggerException( + `Logic function execution failed for ${logicFunction.id}`, + code, + { + userFriendlyMessage: + error instanceof CustomException + ? error.userFriendlyMessage + : undefined, + }, + ); + } if (!isDefined(result)) { return result; diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts index 3741fe0f3f3..31a40b64cba 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.exception.ts @@ -14,6 +14,8 @@ 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_LAYER_BUILD_FAILED = 'LOGIC_FUNCTION_LAYER_BUILD_FAILED', LOGIC_FUNCTION_DISABLED = 'LOGIC_FUNCTION_DISABLED', LOGIC_FUNCTION_INVALID_SEED_PROJECT = 'LOGIC_FUNCTION_INVALID_SEED_PROJECT', } @@ -40,6 +42,10 @@ 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: + return msg`Function execution failed.`; + case LogicFunctionExceptionCode.LOGIC_FUNCTION_LAYER_BUILD_FAILED: + return msg`Failed to build function dependencies.`; case LogicFunctionExceptionCode.LOGIC_FUNCTION_DISABLED: return msg`Logic function execution is disabled.`; case LogicFunctionExceptionCode.LOGIC_FUNCTION_INVALID_SEED_PROJECT: diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts index 1e50eeddf05..f234ef791f7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/logic-function-graphql-api-exception-handler.utils.ts @@ -29,6 +29,8 @@ 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_LAYER_BUILD_FAILED: throw error; case LogicFunctionExceptionCode.LOGIC_FUNCTION_COMPILATION_FAILED: throw new UserInputError(error);