From 7a1cfc17cc198356de1f80063ddb083413c064da Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 18 Jun 2026 16:43:40 +0200 Subject: [PATCH] fix(logic-function): treat invoke timeout as a user-level error, not a platform error (#21779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://twenty-v7.sentry.io/issues/7527156270?project=4507072499810304 ## Problem Sentry was flooded with high-severity alerts for logic functions that simply ran too long: > Lambda timed out for function '…' during invoke (functionState=Active, phase=invoke …) A function exceeding its configured `timeoutSeconds` is a **user-level outcome** (their code is too slow), not a platform failure — but it was being reported as one. ## Root cause The two timeout mechanisms were classified inconsistently: - **Lambda's own timeout** → returns `{ status: ERROR, … }` → handled as a user error (route returns 500 with `shouldBeCapturedBySentry: false`, queue job records it without failing). Not in Sentry. ✓ - **Client-side `AbortSignal` timeout** → **threw** `LOGIC_FUNCTION_EXECUTION_TIMEOUT`, which isn't mapped in `mapErrorToRouteTriggerCode`, so it fell through to `ROUTE_TRIGGER_PLATFORM_ERROR` (Sentry) and failed the BullMQ job (Sentry). ✗ Since the executor Lambda is fixed at 900s, the client abort is the *sole* timeout enforcement for every function with `timeoutSeconds < 900` — so essentially every slow function paged the team. The `local` driver already returns an ERROR result here; only the Lambda driver threw. ## Fix On an **invoke-phase** `TimeoutError`, return a structured ERROR result instead of throwing — mirroring the Lambda's own timeout and the local driver. The timeout now flows through the normal result path: surfaced to the caller as `status: ERROR`, recorded via `handleExecutionResult` (which the throw path skipped), and kept out of Sentry. **Build- and fetch-phase timeouts still throw** and stay in Sentry — those are platform-side (executor build / code fetch too slow, even for short user code), which is exactly what the phase instrumentation exists to catch. ## Tests - Unit test on the new `buildLogicFunctionTimeoutResult` util - `npx jest logic-function-drivers/drivers/lambda` green --- .../drivers/lambda.driver.ts | 16 +++++++++++- ...logic-function-timeout-result.util.spec.ts | 25 +++++++++++++++++++ ...uild-logic-function-timeout-result.util.ts | 16 ++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-logic-function-timeout-result.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-logic-function-timeout-result.util.ts 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 d676cfc33f..190123899c 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 { LambdaAwsClientService } from 'src/engine/core-modules/logic-function/l import { LambdaExecutorManagerService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-executor-manager.service'; import { LambdaLayerManagerService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-layer-manager.service'; import { LambdaToolFunctionsService } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/services/lambda-tool-functions.service'; +import { buildLogicFunctionTimeoutResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-logic-function-timeout-result.util'; import { parseLambdaLogResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/parse-lambda-log-result.util'; import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; import { LogicFunctionExecutionMode } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; @@ -217,6 +218,18 @@ export class LambdaDriver implements LogicFunctionDriver { } catch (error) { const phaseTiming = `phase=${currentPhase} buildMs=${buildExecutorMs} fetchCodeMs=${getBuiltCodeMs}`; + const isTimeoutError = + error instanceof Error && error.name === 'TimeoutError'; + + if (isTimeoutError && currentPhase === LambdaExecutionPhase.INVOKE) { + // User-level outcome (function ran too long), not a platform error: return, don't throw. + this.logger.warn( + `Logic function '${flatLogicFunction.id}' timed out during invoke [${phaseTiming}]`, + ); + + return buildLogicFunctionTimeoutResult(timeoutMs); + } + this.logger.error( `Lambda invocation failed for function ${flatLogicFunction.id} [${phaseTiming}]: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, @@ -229,7 +242,8 @@ export class LambdaDriver implements LogicFunctionDriver { ); } - if (error instanceof Error && error.name === 'TimeoutError') { + if (isTimeoutError) { + // Build/fetch-phase timeouts are platform-side — keep throwing so they reach Sentry. const executor = await this.executorManager .getLambdaExecutor(flatLogicFunction) .catch(() => undefined); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-logic-function-timeout-result.util.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-logic-function-timeout-result.util.spec.ts new file mode 100644 index 0000000000..17f765b080 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/__tests__/build-logic-function-timeout-result.util.spec.ts @@ -0,0 +1,25 @@ +import { buildLogicFunctionTimeoutResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-logic-function-timeout-result.util'; +import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; + +describe('buildLogicFunctionTimeoutResult', () => { + it('returns an ERROR result for a timeout', () => { + const result = buildLogicFunctionTimeoutResult(30_000); + + expect(result.status).toBe(LogicFunctionExecutionStatus.ERROR); + expect(result.data).toBeNull(); + expect(result.error?.errorType).toBe('TimeoutError'); + expect(result.error?.errorMessage).toBe( + 'Function execution timed out after 30s', + ); + expect(result.duration).toBe(30_000); + }); + + it('rounds the timeout to whole seconds', () => { + expect(buildLogicFunctionTimeoutResult(900_000).error?.errorMessage).toBe( + 'Function execution timed out after 900s', + ); + expect(buildLogicFunctionTimeoutResult(1_500).error?.errorMessage).toBe( + 'Function execution timed out after 2s', + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-logic-function-timeout-result.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-logic-function-timeout-result.util.ts new file mode 100644 index 0000000000..79b48699ea --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-drivers/drivers/lambda/utils/build-logic-function-timeout-result.util.ts @@ -0,0 +1,16 @@ +import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface'; +import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; + +export const buildLogicFunctionTimeoutResult = ( + timeoutMs: number, +): LogicFunctionExecuteResult => ({ + data: null, + logs: '', + duration: timeoutMs, + status: LogicFunctionExecutionStatus.ERROR, + error: { + errorType: 'TimeoutError', + errorMessage: `Function execution timed out after ${Math.round(timeoutMs / 1_000)}s`, + stackTrace: [], + }, +});