Skip to content

Commit ba75461

Browse files
fix(aws-serverless): Keep the Lambda extension polling past 300s invocations
`/event/next` acknowledges the previous event and waits for the next one, so the poll stays open for the whole of the following invocation. Node's `fetch` caps that at undici's 300s `headersTimeout`, and the rejection escapes a loop with no `try`/`catch` — the extension never asks for another event, and Lambda holds every later invocation on that execution environment until the function timeout. The poll now uses `http.request`, which has no default timeout, with a deadline above the 900s Lambda ceiling so a stalled socket still settles. A failed poll is retried with capped backoff instead of ending the loop, except for a 4xx, which never starts working. Registration fails when the Extensions API returns 200 without an identifier rather than leaving the id null. `next` returns the event so the loop stops on SHUTDOWN, which the extension registered for and ignored. Failures are reported through `console`: `debug` is only enabled from `Sentry.init`, which this process never calls. Fixes #24218 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 03aaf42 commit ba75461

3 files changed

Lines changed: 377 additions & 15 deletions

File tree

packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts

Lines changed: 141 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,85 @@ import {
1010
} from '@sentry/core';
1111
import { DEBUG_BUILD } from './debug-build';
1212

13+
const POLL_RETRY_BASE_MS = 100;
14+
const POLL_RETRY_MAX_MS = 5_000;
15+
const POLL_RETRY_MAX_EXPONENT = 6;
16+
17+
/**
18+
* Deliberately above the 900s Lambda ceiling: the poll must never be cut while an invocation is
19+
* still running, but it must still settle if the connection goes stale without a FIN/RST. A poll
20+
* that never settles parks the extension, and Lambda then holds every later invocation open until
21+
* the function timeout kills it.
22+
*/
23+
export const POLL_TIMEOUT_MS = 960_000;
24+
25+
interface ExtensionEvent {
26+
eventType?: string;
27+
}
28+
29+
interface ExtensionsApiResponse {
30+
statusCode: number;
31+
body: string;
32+
}
33+
34+
export class ExtensionsApiError extends Error {
35+
public constructor(
36+
message: string,
37+
public readonly statusCode: number,
38+
) {
39+
super(message);
40+
this.name = 'ExtensionsApiError';
41+
}
42+
}
43+
44+
/**
45+
* Structural rather than `instanceof`: the check has to hold for an error that crossed a
46+
* module boundary, and a transport failure carries `code`, never `statusCode`.
47+
*/
48+
function isClientError(err: unknown): boolean {
49+
const statusCode = (err as { statusCode?: unknown } | null)?.statusCode;
50+
return typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500;
51+
}
52+
53+
/**
54+
* Exported only for testing purposes.
55+
*
56+
* `fetch` cannot be used for the long poll: Node's implementation applies undici's 300s
57+
* `headersTimeout`, and lifting it would mean passing a dispatcher and depending on `undici`
58+
* directly. `http.request` has no default timeout, and the Extensions API is plain HTTP on
59+
* localhost.
60+
*/
61+
export function request(
62+
url: string,
63+
headers: Record<string, string>,
64+
timeoutMs: number,
65+
): Promise<ExtensionsApiResponse> {
66+
return new Promise((resolve, reject) => {
67+
const req = http.request(url, { headers }, res => {
68+
const chunks: Buffer[] = [];
69+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
70+
res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString() }));
71+
res.on('error', err => {
72+
req.destroy();
73+
reject(err);
74+
});
75+
});
76+
77+
req.setTimeout(timeoutMs, () => {
78+
req.destroy(new Error(`Extensions API did not respond within ${timeoutMs}ms`));
79+
});
80+
81+
req.on('error', reject);
82+
req.end();
83+
});
84+
}
85+
86+
function sleep(ms: number): Promise<void> {
87+
return new Promise(resolve => {
88+
setTimeout(resolve, ms);
89+
});
90+
}
91+
1392
/**
1493
* The Extension API Client.
1594
*/
@@ -42,25 +121,80 @@ export class AwsLambdaExtension {
42121
}
43122

44123
this._extensionId = res.headers.get('lambda-extension-identifier');
124+
125+
if (!this._extensionId) {
126+
throw new Error('Extensions API accepted the registration without returning an extension identifier');
127+
}
45128
}
46129

47130
/**
48-
* Advances the extension to the next event.
131+
* Advances the extension to the next event and returns it.
49132
*/
50-
public async next(): Promise<void> {
133+
public async next(): Promise<ExtensionEvent> {
51134
if (!this._extensionId) {
52135
throw new Error('Extension ID is not set');
53136
}
54137

55-
const res = await fetch(`${this._baseUrl}/event/next`, {
56-
headers: {
138+
// This request blocks until the next event arrives, so it stays open for the whole
139+
// duration of the current invocation. Under `fetch` that is capped at 300s, so any
140+
// invocation that runs longer than that loses the extension partway through.
141+
const res = await request(
142+
`${this._baseUrl}/event/next`,
143+
{
57144
'Lambda-Extension-Identifier': this._extensionId,
58145
'Content-Type': 'application/json',
59146
},
60-
});
147+
POLL_TIMEOUT_MS,
148+
);
61149

62-
if (!res.ok) {
63-
throw new Error(`Failed to advance to next event: ${await res.text()}`);
150+
if (res.statusCode < 200 || res.statusCode > 299) {
151+
throw new ExtensionsApiError(`Failed to advance to next event: ${res.body}`, res.statusCode);
152+
}
153+
154+
try {
155+
return JSON.parse(res.body) as ExtensionEvent;
156+
} catch {
157+
return {};
158+
}
159+
}
160+
161+
/**
162+
* Polls the Extensions API until the environment shuts down.
163+
*
164+
* A failed poll is retried rather than ending the loop. Lambda only completes an invocation
165+
* once the runtime and every registered extension have asked for the next event, so an
166+
* extension that stops polling does not fail loudly — it leaves every later invocation on
167+
* that execution environment running until the function timeout kills it.
168+
*/
169+
public async run(): Promise<void> {
170+
let consecutiveFailures = 0;
171+
172+
for (;;) {
173+
try {
174+
const event = await this.next();
175+
consecutiveFailures = 0;
176+
177+
// The runtime API is torn down right after this, so polling again would only produce
178+
// errors on the way out.
179+
if (event.eventType === 'SHUTDOWN') {
180+
return;
181+
}
182+
} catch (err) {
183+
// A rejected registration is not going to start working; retrying only buries it.
184+
if (isClientError(err)) {
185+
throw err;
186+
}
187+
188+
consecutiveFailures++;
189+
190+
consoleSandbox(() => {
191+
// eslint-disable-next-line no-console
192+
console.error('Sentry Lambda extension: polling the Extensions API failed, retrying.', err);
193+
});
194+
195+
const exponent = Math.min(consecutiveFailures - 1, POLL_RETRY_MAX_EXPONENT);
196+
await sleep(Math.min(POLL_RETRY_BASE_MS * 2 ** exponent, POLL_RETRY_MAX_MS));
197+
}
64198
}
65199
}
66200

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env node
2-
import { debug } from '@sentry/core';
2+
import { consoleSandbox } from '@sentry/core';
33
import { AwsLambdaExtension } from './aws-lambda-extension';
4-
import { DEBUG_BUILD } from './debug-build';
54

65
async function main(): Promise<void> {
76
const extension = new AwsLambdaExtension();
@@ -10,12 +9,14 @@ async function main(): Promise<void> {
109

1110
extension.startSentryTunnel();
1211

13-
// eslint-disable-next-line no-constant-condition
14-
while (true) {
15-
await extension.next();
16-
}
12+
await extension.run();
1713
}
1814

1915
main().catch(err => {
20-
DEBUG_BUILD && debug.error('Error in Lambda Extension', err);
16+
// The debug logger is only enabled from `Sentry.init`, and this process never calls it, so
17+
// nothing reported through the logger from here would ever be visible.
18+
consoleSandbox(() => {
19+
// eslint-disable-next-line no-console
20+
console.error('Sentry Lambda extension: stopped, events will no longer be tunnelled.', err);
21+
});
2122
});

0 commit comments

Comments
 (0)