fix(logic-function): treat invoke timeout as a user-level error, not a platform error (#21779)

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
This commit is contained in:
Thomas Trompette
2026-06-18 16:43:40 +02:00
committed by GitHub
parent 7afc991bd6
commit 7a1cfc17cc
3 changed files with 56 additions and 1 deletions
@@ -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);
@@ -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',
);
});
});
@@ -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: [],
},
});