From dfcdd91edc2aad26d380c0fdc662d2f960ad8e3b Mon Sep 17 00:00:00 2001 From: Marcus Olsson <8396880+marcusolsson@users.noreply.github.com> Date: Thu, 21 May 2026 11:52:15 +0200 Subject: [PATCH] fix: retry transient 5xx errors during token refresh --- src/plugin/token.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++ src/plugin/token.ts | 9 ++++ 2 files changed, 102 insertions(+) diff --git a/src/plugin/token.test.ts b/src/plugin/token.test.ts index aaeb7ba..66876d1 100644 --- a/src/plugin/token.test.ts +++ b/src/plugin/token.test.ts @@ -475,4 +475,97 @@ describe('refreshAccessTokenDirect', () => { if (result.success) throw new Error('result should be failure'); expect(result.reason).toBe('Invalid token response from refresh endpoint'); }); + + // Issue #7 regression: retry on transient 5xx + it('retries once on 503 and succeeds on second attempt', async () => { + let callCount = 0; + + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 503, + text: async () => 'Service Unavailable', + } as Response); + } + return Promise.resolve({ + json: async () => ({ expires_in: 3600, token: 'retried-token' }), + ok: true, + } as Response); + })); + + const auth: OAuthAuthDetails = { + access: 'old', + expires: Date.now() - 1000, + refresh: 'refresh-token-retry', + type: 'oauth', + }; + + const start = Date.now(); + const result = await refreshAccessTokenDirect(auth); + const elapsed = Date.now() - start; + + expect(result.success).toBe(true); + if (!result.success) throw new Error('result should be success'); + expect(result.auth.access).toBe('retried-token'); + // Should have taken at least one retry delay (~500ms) + expect(elapsed).toBeGreaterThanOrEqual(400); + }); + + it('gives up after two consecutive 503 failures', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + text: async () => 'Service Unavailable', + } as Response), + ); + + const auth: OAuthAuthDetails = { + access: 'old', + expires: Date.now() - 1000, + refresh: 'refresh-token-double-fail', + type: 'oauth', + }; + + const start = Date.now(); + const result = await refreshAccessTokenDirect(auth); + const elapsed = Date.now() - start; + + expect(result.success).toBe(false); + if (result.success) throw new Error('result should be failure'); + expect(result.reason).toContain('503'); + // Should have taken two retry delays (~500 + ~1500 = ~2000ms) + expect(elapsed).toBeGreaterThanOrEqual(1500); + }); + + it('does not retry on 401 (client error)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => JSON.stringify({ error: 'invalid_token' }), + } as Response), + ); + + const auth: OAuthAuthDetails = { + access: 'old', + expires: Date.now() - 1000, + refresh: 'refresh-token-no-retry', + type: 'oauth', + }; + + const start = Date.now(); + const result = await refreshAccessTokenDirect(auth); + const elapsed = Date.now() - start; + + expect(result.success).toBe(false); + if (result.success) throw new Error('result should be failure'); + expect(result.reason).toBe('Refresh token is invalid or revoked'); + // Should not have waited for retry + expect(elapsed).toBeLessThan(200); + }); }); diff --git a/src/plugin/token.ts b/src/plugin/token.ts index 1353a79..75a6ee0 100644 --- a/src/plugin/token.ts +++ b/src/plugin/token.ts @@ -66,6 +66,7 @@ function parseErrorResponse( async function refreshAccessTokenInternal( auth: OAuthAuthDetails, client?: PluginInput['client'], + attempt = 1, ): Promise { const refreshToken = auth.refresh; @@ -86,6 +87,14 @@ async function refreshAccessTokenInternal( const errorText = await response.text().catch(() => ''); logDebug(`Token refresh failed: ${response.status} ${errorText}`); + // Retry transient 5xx errors (up to 2 attempts total) + if (response.status >= 500 && attempt <= 2) { + const delay = attempt === 1 ? 500 : 1500; + logDebug(`Refresh got HTTP ${response.status}, retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + return refreshAccessTokenInternal(auth, client, attempt + 1); + } + // Handle revoked/invalid refresh token if (response.status === 401 || response.status === 400) { const errorData = parseErrorResponse(errorText);