diff --git a/.changeset/auto-pr-894.md b/.changeset/auto-pr-894.md new file mode 100644 index 00000000..f0a29408 --- /dev/null +++ b/.changeset/auto-pr-894.md @@ -0,0 +1,5 @@ +--- +"@icebreakers/monorepo": patch +--- + +fix(release): recover interrupted GitHub responses (#894) diff --git a/packages/monorepo/src/commands/release/github/client.ts b/packages/monorepo/src/commands/release/github/client.ts index b64acdfc..5b10bb9c 100644 --- a/packages/monorepo/src/commands/release/github/client.ts +++ b/packages/monorepo/src/commands/release/github/client.ts @@ -69,14 +69,30 @@ export class GitHubClient implements GitHubOperations { }) } catch (error) { - if (attempt < this.retryAttempts) { + // A mutating request may have been accepted even when the transport + // fails before returning a response. Let the caller reconcile it + // before issuing another POST. + if (method !== 'POST' && attempt < this.retryAttempts) { await this.sleep(this.retryDelay * 2 ** (attempt - 1)) continue } const detail = error instanceof Error ? error.message : String(error) throw new GitHubApiError(`GitHub API request ${method} ${endpoint} failed: ${detail}. Check network access and GITHUB_API_URL.`, 0) } - const text = await response.text() + let text: string + try { + text = await response.text() + } + catch (error) { + // The server may have committed a POST before the response stream + // was interrupted. Reconcile by the resource's idempotency key first. + if (method !== 'POST' && attempt < this.retryAttempts) { + await this.sleep(this.retryDelay * 2 ** (attempt - 1)) + continue + } + const detail = error instanceof Error ? error.message : String(error) + throw new GitHubApiError(`GitHub API response ${method} ${endpoint} was interrupted: ${detail}`, 0) + } let data: T | undefined if (text) { try { diff --git a/packages/monorepo/test/commands/release-github.test.ts b/packages/monorepo/test/commands/release-github.test.ts index 0c0722a0..c1c3d94a 100644 --- a/packages/monorepo/test/commands/release-github.test.ts +++ b/packages/monorepo/test/commands/release-github.test.ts @@ -9,6 +9,35 @@ function response(body: unknown, status = 200, headers?: Record) } describe('GitHub release client', () => { + it('retries when a response body is interrupted', async () => { + const interrupted = { text: async () => { + throw new TypeError('socket closed') + } } as unknown as Response + const requestFetch = vi.fn() + .mockResolvedValueOnce(response(undefined, 404)) + .mockResolvedValueOnce(interrupted) + .mockResolvedValueOnce(response({ id: 1, tag_name: 'repo@1.0.0' }, 201)) + const client = new GitHubClient({ token: 'token', repository: 'acme/repo', fetch: requestFetch, retryDelay: 0 }) + await expect(client.ensureRelease({ tag: 'repo@1.0.0', target: 'abc123' })).resolves.toMatchObject({ id: 1 }) + expect(requestFetch).toHaveBeenCalledTimes(3) + }) + + it('reconciles a release after an accepted POST response is interrupted', async () => { + const interrupted = { text: async () => { + throw new TypeError('socket closed') + } } as unknown as Response + const requestFetch = vi.fn() + .mockResolvedValueOnce(response(undefined, 404)) + .mockResolvedValueOnce(interrupted) + .mockResolvedValueOnce(response({ id: 9, tag_name: 'repo@1.0.0' })) + const client = new GitHubClient({ token: 'token', repository: 'acme/repo', fetch: requestFetch, retryDelay: 0 }) + + await expect(client.ensureRelease({ tag: 'repo@1.0.0', target: 'abc123' })).resolves.toMatchObject({ id: 9 }) + expect(requestFetch).toHaveBeenCalledTimes(3) + expect(requestFetch.mock.calls[1]?.[1]).toMatchObject({ method: 'POST' }) + expect(requestFetch.mock.calls[2]?.[1]).toMatchObject({ method: 'GET' }) + }) + it('retries transient responses four times and honors Retry-After', async () => { const sleeps: number[] = [] const requestFetch = vi.fn()