From b2590e0b22b1c56d52ea4bed364c604e43ee4cb2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 01:57:09 +0000 Subject: [PATCH] Fix FetcherService retrying with multiple fetchers on 429 and other application errors (#282100) * Initial plan * Fix FetcherService to not retry with other fetchers on 429 status code Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> * Improve test robustness based on code review feedback Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: TylerLeonhardt <2644648+TylerLeonhardt@users.noreply.github.com> --- .../github-authentication/src/node/fetch.ts | 15 +++++++ .../src/test/node/fetch.test.ts | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/extensions/github-authentication/src/node/fetch.ts b/extensions/github-authentication/src/node/fetch.ts index 32702be3968..7bd5f929029 100644 --- a/extensions/github-authentication/src/node/fetch.ts +++ b/extensions/github-authentication/src/node/fetch.ts @@ -76,6 +76,16 @@ export function createFetch(): Fetch { }; } +function shouldNotRetry(status: number): boolean { + // Don't retry with other fetchers for these HTTP status codes: + // - 429 Too Many Requests (rate limiting) + // - 401 Unauthorized (authentication issue) + // - 403 Forbidden (authorization issue) + // - 404 Not Found (resource doesn't exist) + // These are application-level errors where retrying with a different fetcher won't help + return status === 429 || status === 401 || status === 403 || status === 404; +} + async function fetchWithFallbacks(availableFetchers: readonly Fetcher[], url: string, options: FetchOptions, logService: Log): Promise<{ response: FetchResponse; updatedFetchers?: Fetcher[] }> { if (options.retryFallbacks && availableFetchers.length > 1) { let firstResult: { ok: boolean; response: FetchResponse } | { ok: false; err: any } | undefined; @@ -85,6 +95,11 @@ async function fetchWithFallbacks(availableFetchers: readonly Fetcher[], url: st firstResult = result; } if (!result.ok) { + // For certain HTTP status codes, don't retry with other fetchers + // These are application-level errors, not network-level errors + if ('response' in result && shouldNotRetry(result.response.status)) { + return { response: result.response }; + } continue; } if (fetcher !== availableFetchers[0]) { diff --git a/extensions/github-authentication/src/test/node/fetch.test.ts b/extensions/github-authentication/src/test/node/fetch.test.ts index 6ce569378b0..211b133e406 100644 --- a/extensions/github-authentication/src/test/node/fetch.test.ts +++ b/extensions/github-authentication/src/test/node/fetch.test.ts @@ -144,4 +144,46 @@ suite('fetching', () => { assert.strictEqual(res.status, 200); assert.deepStrictEqual(await res.text(), 'Hello, world!'); }); + + test('should not retry with other fetchers on 429 status', async () => { + // Set up server to return 429 for the first request + let requestCount = 0; + const oldListener = server.listeners('request')[0] as (req: http.IncomingMessage, res: http.ServerResponse) => void; + if (!oldListener) { + throw new Error('No request listener found on server'); + } + + server.removeAllListeners('request'); + server.on('request', (req, res) => { + requestCount++; + if (req.url === '/rate-limited') { + res.writeHead(429, { + 'Content-Type': 'text/plain', + 'X-Client-User-Agent': String(req.headers['user-agent'] ?? '').toLowerCase(), + }); + res.end('Too Many Requests'); + } else { + oldListener(req, res); + } + }); + + try { + const res = await createFetch()(`http://localhost:${port}/rate-limited`, { + logger, + retryFallbacks: true, + expectJSON: false, + }); + + // Verify only one request was made (no fallback attempts) + assert.strictEqual(requestCount, 1, 'Should only make one request for 429 status'); + assert.strictEqual(res.status, 429); + // Note: We only check that we got a response, not which fetcher was used, + // as the fetcher order may vary by configuration + assert.strictEqual(await res.text(), 'Too Many Requests'); + } finally { + // Restore original listener + server.removeAllListeners('request'); + server.on('request', oldListener); + } + }); });