Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-pr-894.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@icebreakers/monorepo": patch
---

fix(release): recover interrupted GitHub responses (#894)
20 changes: 18 additions & 2 deletions packages/monorepo/src/commands/release/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions packages/monorepo/test/commands/release-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,35 @@ function response(body: unknown, status = 200, headers?: Record<string, string>)
}

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()
Expand Down
Loading