Skip to content

Commit 2cd6978

Browse files
authored
Merge pull request #182 from backstage/copilot/add-retry-logic-http-requests
Add exponential backoff retry for transient network errors in Octokit client
2 parents c4dd040 + 851395a commit 2cd6978

File tree

3 files changed

+139
-1
lines changed

3 files changed

+139
-1
lines changed

lib/createAppClient.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
import * as core from '@actions/core';
22
import { GitHub } from '@actions/github/lib/utils';
33
import { createAppAuth } from '@octokit/auth-app';
4+
import { withRetry, RetryOptions } from './withRetry';
5+
6+
const RETRY_OPTIONS: RetryOptions = {
7+
maxAttempts: 5,
8+
initialDelayMs: 500,
9+
};
410

511
export function createAppClient() {
612
const appId = core.getInput('app-id', { required: true });
713
const privateKey = core.getInput('private-key', { required: true });
814
const installationId = core.getInput('installation-id', { required: true });
915

10-
return new GitHub({
16+
const client = new GitHub({
1117
authStrategy: createAppAuth,
1218
auth: {
1319
appId,
1420
privateKey,
1521
installationId,
1622
},
1723
});
24+
25+
// Retry transient network errors (e.g. "socket hang up") with exponential backoff.
26+
client.hook.wrap('request', (request, options) =>
27+
withRetry(() => request(options), RETRY_OPTIONS),
28+
);
29+
30+
return client;
1831
}

lib/withRetry.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2+
import { withRetry } from './withRetry';
3+
4+
// Patch setTimeout so retries are instant during tests.
5+
const realSetTimeout = global.setTimeout;
6+
7+
describe('withRetry', () => {
8+
beforeEach(() => {
9+
// Make setTimeout fire instantly to keep tests fast.
10+
(global as any).setTimeout = (fn: () => void) => realSetTimeout(fn, 0);
11+
});
12+
13+
afterEach(() => {
14+
global.setTimeout = realSetTimeout;
15+
});
16+
17+
it('returns the result on the first successful attempt', async () => {
18+
const fn = jest.fn<() => Promise<string>>().mockResolvedValue('ok');
19+
const result = await withRetry(fn);
20+
expect(result).toBe('ok');
21+
expect(fn).toHaveBeenCalledTimes(1);
22+
});
23+
24+
it('retries on "socket hang up" and succeeds on second attempt', async () => {
25+
const socketError = Object.assign(new Error('socket hang up'), {
26+
code: 'ECONNRESET',
27+
});
28+
const fn = jest
29+
.fn<() => Promise<string>>()
30+
.mockRejectedValueOnce(socketError)
31+
.mockResolvedValue('ok');
32+
33+
const result = await withRetry(fn, { maxAttempts: 3, initialDelayMs: 100 });
34+
expect(result).toBe('ok');
35+
expect(fn).toHaveBeenCalledTimes(2);
36+
});
37+
38+
it('retries on ECONNRESET and succeeds on third attempt', async () => {
39+
const connError = Object.assign(new Error('read ECONNRESET'), {
40+
code: 'ECONNRESET',
41+
});
42+
const fn = jest
43+
.fn<() => Promise<number>>()
44+
.mockRejectedValueOnce(connError)
45+
.mockRejectedValueOnce(connError)
46+
.mockResolvedValue(42);
47+
48+
const result = await withRetry(fn, { maxAttempts: 5, initialDelayMs: 1 });
49+
expect(result).toBe(42);
50+
expect(fn).toHaveBeenCalledTimes(3);
51+
});
52+
53+
it('throws immediately on non-transient errors without retrying', async () => {
54+
const notFound = new Error('Not Found');
55+
const fn = jest.fn<() => Promise<string>>().mockRejectedValue(notFound);
56+
57+
await expect(
58+
withRetry(fn, { maxAttempts: 5, initialDelayMs: 1 }),
59+
).rejects.toThrow('Not Found');
60+
expect(fn).toHaveBeenCalledTimes(1);
61+
});
62+
63+
it('exhausts all attempts and re-throws the last transient error', async () => {
64+
const hangUp = new Error('socket hang up');
65+
const fn = jest.fn<() => Promise<string>>().mockRejectedValue(hangUp);
66+
67+
await expect(
68+
withRetry(fn, { maxAttempts: 3, initialDelayMs: 1 }),
69+
).rejects.toThrow('socket hang up');
70+
expect(fn).toHaveBeenCalledTimes(3);
71+
});
72+
});

lib/withRetry.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Transient error patterns that should trigger a retry.
3+
* These typically indicate temporary network-level failures.
4+
*/
5+
const RETRYABLE_PATTERNS = [
6+
'socket hang up',
7+
'ECONNRESET',
8+
'ETIMEDOUT',
9+
'ECONNREFUSED',
10+
'EHOSTUNREACH',
11+
'ENETUNREACH',
12+
];
13+
14+
export interface RetryOptions {
15+
/** Maximum number of attempts including the first try (default: 5) */
16+
maxAttempts?: number;
17+
/** Delay before the first retry in milliseconds; doubles on each attempt (default: 500) */
18+
initialDelayMs?: number;
19+
}
20+
21+
function isTransientError(error: unknown): boolean {
22+
if (!(error instanceof Error)) return false;
23+
const msg = error.message;
24+
const code = (error as NodeJS.ErrnoException).code ?? '';
25+
return RETRYABLE_PATTERNS.some(p => msg.includes(p) || code.includes(p));
26+
}
27+
28+
/**
29+
* Retries an async function with exponential backoff on transient network
30+
* errors (e.g. "socket hang up", ECONNRESET). Re-throws immediately for
31+
* non-transient errors or once maxAttempts is exhausted.
32+
*/
33+
export async function withRetry<T>(
34+
fn: () => Promise<T>,
35+
options: RetryOptions = {},
36+
): Promise<T> {
37+
const { maxAttempts = 5, initialDelayMs = 500 } = options;
38+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
39+
try {
40+
return await fn();
41+
} catch (error) {
42+
if (!isTransientError(error) || attempt === maxAttempts) {
43+
throw error;
44+
}
45+
await new Promise(resolve =>
46+
setTimeout(resolve, initialDelayMs * 2 ** (attempt - 1)),
47+
);
48+
}
49+
}
50+
// This line is never reached; the loop always returns or throws.
51+
/* istanbul ignore next */
52+
throw new Error('withRetry: unexpected end of loop');
53+
}

0 commit comments

Comments
 (0)