Skip to content

Commit 7a603bd

Browse files
msonnbLuccaRebelloToledocodex
committed
fix(aws-serverless): Retry transient extension polling failures
Retry transient connection errors up to three times with short backoff, resetting the budget after a successful poll. Keep HTTP errors fatal and exit when retries are exhausted to avoid leaving an extension stuck. Co-Authored-By: LuccaRebelloToledo <luccarebtoledo@gmail.com> Co-Authored-By: OpenAI GPT-6 <codex@openai.com>
1 parent f6a4b50 commit 7a603bd

2 files changed

Lines changed: 107 additions & 8 deletions

File tree

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,36 @@
22
import { consoleSandbox } from '@sentry/core';
33
import { AwsLambdaExtension } from './aws-lambda-extension';
44

5+
export const POLL_RETRY_DELAYS = [100, 200, 400] as const;
6+
57
async function main(): Promise<void> {
68
const extension = new AwsLambdaExtension();
79

810
await extension.register();
911

1012
extension.startSentryTunnel();
1113

14+
let failures = 0;
15+
1216
// eslint-disable-next-line no-constant-condition
1317
while (true) {
14-
await extension.next();
18+
try {
19+
await extension.next();
20+
failures = 0;
21+
} catch (err) {
22+
const delay = POLL_RETRY_DELAYS[failures++];
23+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
24+
25+
if (
26+
delay === undefined ||
27+
!code ||
28+
!['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code)
29+
) {
30+
throw err;
31+
}
32+
33+
await new Promise(resolve => setTimeout(resolve, delay));
34+
}
1535
}
1636
}
1737

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,99 @@
1-
import { afterEach, expect, test, vi } from 'vitest';
2-
import { AwsLambdaExtension } from '../src/lambda-extension/aws-lambda-extension';
1+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
2+
3+
beforeEach(() => {
4+
vi.resetModules();
5+
vi.useFakeTimers();
6+
vi.setSystemTime(0);
7+
});
38

49
afterEach(() => {
510
vi.restoreAllMocks();
11+
vi.useRealTimers();
612
});
713

8-
test('logs polling failures and exits instead of leaving the tunnel running', async () => {
9-
const error = new Error('Extension API connection failed');
14+
async function setupExtension() {
15+
const { AwsLambdaExtension } = await import('../src/lambda-extension/aws-lambda-extension');
1016
vi.spyOn(AwsLambdaExtension.prototype, 'register').mockResolvedValue();
1117
vi.spyOn(AwsLambdaExtension.prototype, 'startSentryTunnel').mockImplementation(() => {});
12-
vi.spyOn(AwsLambdaExtension.prototype, 'next').mockRejectedValue(error);
18+
const next = vi.spyOn(AwsLambdaExtension.prototype, 'next').mockImplementation(() => new Promise(() => {}));
1319
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
1420
const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
21+
return { next, log, exit };
22+
}
23+
24+
test.each(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN'])(
25+
'recovers from %s without exiting',
26+
async code => {
27+
const { next, exit, log } = await setupExtension();
28+
next.mockRejectedValueOnce(Object.assign(new Error('Connection failed'), { code })).mockResolvedValueOnce();
29+
30+
const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index');
31+
await vi.advanceTimersByTimeAsync(0);
32+
expect(next).toHaveBeenCalledTimes(1);
33+
await vi.advanceTimersByTimeAsync(POLL_RETRY_DELAYS[0] - 1);
34+
expect(next).toHaveBeenCalledTimes(1);
35+
await vi.advanceTimersByTimeAsync(1);
36+
37+
expect(next).toHaveBeenCalledTimes(3);
38+
expect(exit).not.toHaveBeenCalled();
39+
expect(log).not.toHaveBeenCalled();
40+
},
41+
);
42+
43+
test('logs and exits after exhausting consecutive connection retries', async () => {
44+
const { next, exit, log } = await setupExtension();
45+
const error = Object.assign(new Error('Connection reset'), { code: 'ECONNRESET' });
46+
const attempts: number[] = [];
47+
next.mockImplementation(async () => {
48+
attempts.push(Date.now());
49+
throw error;
50+
});
51+
52+
const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index');
53+
const retryBudget = POLL_RETRY_DELAYS.reduce((total, delay) => total + delay, 0);
54+
await vi.advanceTimersByTimeAsync(retryBudget - 1);
55+
expect(exit).not.toHaveBeenCalled();
56+
await vi.advanceTimersByTimeAsync(1);
57+
58+
expect(attempts).toEqual([0, POLL_RETRY_DELAYS[0], POLL_RETRY_DELAYS[0] + POLL_RETRY_DELAYS[1], retryBudget]);
59+
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
60+
expect(log).toHaveBeenCalledExactlyOnceWith('Error in Lambda Extension', error);
61+
expect(vi.getTimerCount()).toBe(0);
62+
});
63+
64+
test('resets the retry budget after a successful poll', async () => {
65+
const { next, exit } = await setupExtension();
66+
const error = Object.assign(new Error('Connection reset'), { code: 'ECONNRESET' });
67+
next
68+
.mockRejectedValueOnce(error)
69+
.mockRejectedValueOnce(error)
70+
.mockResolvedValueOnce()
71+
.mockRejectedValueOnce(error)
72+
.mockRejectedValueOnce(error)
73+
.mockRejectedValueOnce(error)
74+
.mockResolvedValueOnce();
75+
76+
const { POLL_RETRY_DELAYS } = await import('../src/lambda-extension/index');
77+
const retryBudget = POLL_RETRY_DELAYS.reduce((total, delay) => total + delay, 0);
78+
await vi.advanceTimersByTimeAsync(POLL_RETRY_DELAYS[0] + POLL_RETRY_DELAYS[1] + retryBudget);
79+
80+
expect(next).toHaveBeenCalledTimes(8);
81+
expect(exit).not.toHaveBeenCalled();
82+
});
83+
84+
test.each([
85+
new Error('Failed to advance to next event: Forbidden'),
86+
new Error('Failed to advance to next event: Container error'),
87+
Object.assign(new Error('Invalid URL'), { code: 'ERR_INVALID_URL' }),
88+
])('logs and exits immediately for $message', async error => {
89+
const { next, log, exit } = await setupExtension();
90+
next.mockRejectedValue(error);
1591

1692
await import('../src/lambda-extension/index');
93+
await vi.advanceTimersByTimeAsync(0);
1794

18-
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
19-
expect(log).toHaveBeenCalledWith('Error in Lambda Extension', error);
95+
expect(next).toHaveBeenCalledTimes(1);
96+
expect(exit).toHaveBeenCalledExactlyOnceWith(1);
97+
expect(log).toHaveBeenCalledExactlyOnceWith('Error in Lambda Extension', error);
98+
expect(vi.getTimerCount()).toBe(0);
2099
});

0 commit comments

Comments
 (0)