Skip to content

Commit e62e93e

Browse files
committed
fix(cli): report interrupted response bodies without retrying writes
1 parent 0f9ecde commit e62e93e

2 files changed

Lines changed: 114 additions & 15 deletions

File tree

packages/sim-cli/src/http/client.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ describe('non-JSON responses', () => {
286286
name: 'SimApiError',
287287
status,
288288
code: 'RESPONSE_READ_FAILED',
289-
message: 'Unable to read the response: Connection closed during response',
289+
message: expect.stringContaining('Response interrupted: Connection closed during response'),
290290
})
291291
expect(fetch).toHaveBeenCalledTimes(1)
292292
})
@@ -494,6 +494,92 @@ describe('a request that never answers', () => {
494494
})
495495
})
496496

497+
describe('interrupted response bodies', () => {
498+
it('reports a failed read without implying a write occurred', async () => {
499+
vi.stubGlobal(
500+
'fetch',
501+
vi
502+
.fn()
503+
.mockResolvedValue(
504+
new Response(
505+
new ReadableStream({ start: (controller) => controller.error(new Error('Dropped')) })
506+
)
507+
)
508+
)
509+
510+
await expect(client().request('/api/v2/workflows')).rejects.toMatchObject({
511+
name: 'SimApiError',
512+
status: 200,
513+
code: 'RESPONSE_READ_FAILED',
514+
message: expect.stringContaining('Retry the request when the connection is restored.'),
515+
})
516+
})
517+
518+
it.each(['timeout', 'cancel'])('explains a %s during a mutation response', async (reason) => {
519+
const controller = new AbortController()
520+
vi.stubGlobal(
521+
'fetch',
522+
vi.fn().mockImplementation(async () => {
523+
if (reason === 'cancel') controller.abort()
524+
return new Response(
525+
new ReadableStream({
526+
start: (stream) =>
527+
stream.error(
528+
new DOMException(reason, reason === 'timeout' ? 'TimeoutError' : 'AbortError')
529+
),
530+
})
531+
)
532+
})
533+
)
534+
535+
const failure = await client()
536+
.request('/api/v2/workflows/workflow-1/operations', {
537+
method: 'POST',
538+
body: { operations: [] },
539+
signal: controller.signal,
540+
})
541+
.catch((error: unknown) => error)
542+
543+
expect(failure).toMatchObject({
544+
name: 'SimApiError',
545+
status: 200,
546+
code: 'RESPONSE_READ_FAILED',
547+
message: expect.stringContaining(reason === 'timeout' ? 'Timed out' : 'Request cancelled'),
548+
})
549+
expect(failure).toMatchObject({ message: expect.stringContaining('may have completed') })
550+
})
551+
552+
it.each([200, 500])(
553+
'reports a truncated HTTP %i mutation response without retrying',
554+
async (status) => {
555+
const response = new Response(
556+
new ReadableStream({
557+
start(controller) {
558+
controller.enqueue(new TextEncoder().encode('{"data":'))
559+
controller.error(new Error('Connection dropped after response headers'))
560+
},
561+
}),
562+
{ status, headers: { 'content-type': 'application/json' } }
563+
)
564+
const fetchMock = vi.fn().mockResolvedValue(response)
565+
vi.stubGlobal('fetch', fetchMock)
566+
567+
const failure = await client()
568+
.request('/api/v2/workflows/workflow-1/operations', {
569+
method: 'POST',
570+
body: { operations: [] },
571+
})
572+
.catch((error: unknown) => error)
573+
574+
expect(failure).toBeInstanceOf(SimApiError)
575+
expect(failure).toMatchObject({
576+
message: expect.stringContaining('may have completed'),
577+
})
578+
expect(fetchMock).toHaveBeenCalledTimes(1)
579+
}
580+
)
581+
})
582+
497583
describe('tracing a request', () => {
498584
it('traces method, url, status and duration when asked, and nothing otherwise', async () => {
499585
const response = () =>

packages/sim-cli/src/http/client.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -215,18 +215,6 @@ function transportErrorMessage(error: unknown): string {
215215
return messages.join(': ') || 'Unknown network error'
216216
}
217217

218-
async function readResponseText(response: Response): Promise<string> {
219-
try {
220-
return await response.text()
221-
} catch (error) {
222-
throw new SimApiError(
223-
`Unable to read the response: ${transportErrorMessage(error)}`,
224-
response.status,
225-
'RESPONSE_READ_FAILED'
226-
)
227-
}
228-
}
229-
230218
/**
231219
* Whether this is the refusal a workspace-scoped key gets from an operation only
232220
* a personal key may perform, under either code that expresses it.
@@ -571,7 +559,7 @@ export class SimClient {
571559

572560
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
573561
const { response, url } = await this.send(path, options)
574-
const raw = await readResponseText(response)
562+
const raw = await this.readResponseText(response, url, options)
575563

576564
if (!raw) return undefined as T
577565
try {
@@ -581,6 +569,31 @@ export class SimClient {
581569
}
582570
}
583571

572+
private async readResponseText(
573+
response: Response,
574+
url: string,
575+
options: RequestOptions
576+
): Promise<string> {
577+
try {
578+
return await response.text()
579+
} catch (cause) {
580+
const reason = options.signal?.aborted
581+
? 'Request cancelled while receiving the response.'
582+
: isRequestTimeout(cause)
583+
? `Timed out while receiving the response. ${RAISE_TIMEOUT_HINT}`
584+
: `Response interrupted: ${transportErrorMessage(cause)}`
585+
const retryHint =
586+
(options.method ?? 'GET') === 'GET'
587+
? 'Retry the request when the connection is restored.'
588+
: 'The operation may have completed. Check the saved state or run status before retrying.'
589+
throw new SimApiError(
590+
`${url}: ${reason} ${retryHint}`,
591+
response.status,
592+
'RESPONSE_READ_FAILED'
593+
)
594+
}
595+
}
596+
584597
private async send(
585598
path: string,
586599
options: RequestOptions,
@@ -668,7 +681,7 @@ export class SimClient {
668681
}
669682

670683
if (!response.ok) {
671-
const raw = await readResponseText(response)
684+
const raw = await this.readResponseText(response, url, options)
672685
const error = toApiError(url, response.status, response.headers.get('content-type'), raw)
673686
if (response.status === 401) {
674687
error.message = `${error.message} — run: sim login --profile ${this.profile.authProfile}`

0 commit comments

Comments
 (0)