@@ -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+
497583describe ( 'tracing a request' , ( ) => {
498584 it ( 'traces method, url, status and duration when asked, and nothing otherwise' , async ( ) => {
499585 const response = ( ) =>
0 commit comments