From 399eb88eae78177634aba65edd98115a6af4334d Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Mon, 14 Sep 2026 09:46:15 +0800 Subject: [PATCH 1/3] fix(release): recover interrupted GitHub responses --- .../monorepo/src/commands/release/github/client.ts | 13 ++++++++++++- .../monorepo/test/commands/release-github.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/monorepo/src/commands/release/github/client.ts b/packages/monorepo/src/commands/release/github/client.ts index b64acdfc..c821f031 100644 --- a/packages/monorepo/src/commands/release/github/client.ts +++ b/packages/monorepo/src/commands/release/github/client.ts @@ -76,7 +76,18 @@ export class GitHubClient implements GitHubOperations { 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) { + if (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..cc4671c1 100644 --- a/packages/monorepo/test/commands/release-github.test.ts +++ b/packages/monorepo/test/commands/release-github.test.ts @@ -9,6 +9,19 @@ 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('retries transient responses four times and honors Retry-After', async () => { const sleeps: number[] = [] const requestFetch = vi.fn() From 1180a2786083f332c2258774a593622cd48d9d7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:51:35 +0000 Subject: [PATCH 2/3] chore(release): add automated changesets --- .changeset/auto-pr-894.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/auto-pr-894.md 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) From 3b3a96ac3ca27e8e36374dba3da71e6dca5766fd Mon Sep 17 00:00:00 2001 From: ice breaker <1324318532@qq.com> Date: Mon, 14 Sep 2026 10:00:05 +0800 Subject: [PATCH 3/3] fix(release): reconcile interrupted mutating responses --- .../src/commands/release/github/client.ts | 9 +++++++-- .../test/commands/release-github.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/monorepo/src/commands/release/github/client.ts b/packages/monorepo/src/commands/release/github/client.ts index c821f031..5b10bb9c 100644 --- a/packages/monorepo/src/commands/release/github/client.ts +++ b/packages/monorepo/src/commands/release/github/client.ts @@ -69,7 +69,10 @@ 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 } @@ -81,7 +84,9 @@ export class GitHubClient implements GitHubOperations { text = await response.text() } catch (error) { - if (attempt < this.retryAttempts) { + // 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 } diff --git a/packages/monorepo/test/commands/release-github.test.ts b/packages/monorepo/test/commands/release-github.test.ts index cc4671c1..c1c3d94a 100644 --- a/packages/monorepo/test/commands/release-github.test.ts +++ b/packages/monorepo/test/commands/release-github.test.ts @@ -22,6 +22,22 @@ describe('GitHub release client', () => { 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()