@@ -444,14 +444,14 @@ export class ApiClient {
444444
445445 if ( retryResult . retry ) {
446446 // Cancel the request stream before retry to prevent tee() from buffering
447- await forRequest . cancel ( ) ;
447+ await safeStreamCancel ( forRequest ) ;
448448 await sleep ( retryResult . delay ) ;
449449 // Use the backup stream for retry
450450 return this . #streamBatchItemsWithRetry( batchId , forRetry , retryOptions , attempt + 1 ) ;
451451 }
452452
453453 // Not retrying - cancel the backup stream
454- await forRetry . cancel ( ) ;
454+ await safeStreamCancel ( forRetry ) ;
455455
456456 const errText = await response . text ( ) . catch ( ( e ) => ( e as Error ) . message ) ;
457457 let errJSON : Object | undefined ;
@@ -471,7 +471,7 @@ export class ApiClient {
471471
472472 if ( ! parsed . success ) {
473473 // Cancel backup stream since we're throwing
474- await forRetry . cancel ( ) ;
474+ await safeStreamCancel ( forRetry ) ;
475475 throw new Error (
476476 `Invalid response from server for batch ${ batchId } : ${ parsed . error . message } `
477477 ) ;
@@ -484,14 +484,14 @@ export class ApiClient {
484484
485485 if ( delay ) {
486486 // Cancel the request stream before retry to prevent tee() from buffering
487- await forRequest . cancel ( ) ;
487+ await safeStreamCancel ( forRequest ) ;
488488 // Retry with the backup stream
489489 await sleep ( delay ) ;
490490 return this . #streamBatchItemsWithRetry( batchId , forRetry , retryOptions , attempt + 1 ) ;
491491 }
492492
493493 // No more retries - cancel backup stream and throw descriptive error
494- await forRetry . cancel ( ) ;
494+ await safeStreamCancel ( forRetry ) ;
495495 throw new BatchNotSealedError ( {
496496 batchId,
497497 enqueuedCount : parsed . data . enqueuedCount ?? 0 ,
@@ -502,7 +502,7 @@ export class ApiClient {
502502 }
503503
504504 // Success - cancel the backup stream to release resources
505- await forRetry . cancel ( ) ;
505+ await safeStreamCancel ( forRetry ) ;
506506
507507 return parsed . data ;
508508 } catch ( error ) {
@@ -519,13 +519,13 @@ export class ApiClient {
519519 const delay = calculateNextRetryDelay ( retryOptions , attempt ) ;
520520 if ( delay ) {
521521 // Cancel the request stream before retry to prevent tee() from buffering
522- await forRequest . cancel ( ) ;
522+ await safeStreamCancel ( forRequest ) ;
523523 await sleep ( delay ) ;
524524 return this . #streamBatchItemsWithRetry( batchId , forRetry , retryOptions , attempt + 1 ) ;
525525 }
526526
527527 // No more retries - cancel the backup stream
528- await forRetry . cancel ( ) ;
528+ await safeStreamCancel ( forRetry ) ;
529529
530530 // Wrap in a more descriptive error
531531 const cause = error instanceof Error ? error : new Error ( String ( error ) ) ;
@@ -1731,6 +1731,30 @@ function sleep(ms: number): Promise<void> {
17311731 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
17321732}
17331733
1734+ /**
1735+ * Safely cancels a ReadableStream, handling the case where it might be locked.
1736+ *
1737+ * When fetch uses a ReadableStream as a request body and an error occurs mid-transfer
1738+ * (connection reset, timeout, etc.), the stream may remain locked by fetch's internal reader.
1739+ * Attempting to cancel a locked stream throws "Invalid state: ReadableStream is locked".
1740+ *
1741+ * This function gracefully handles that case by catching the error and doing nothing -
1742+ * the stream will be cleaned up by garbage collection when the reader is released.
1743+ */
1744+ async function safeStreamCancel ( stream : ReadableStream < unknown > ) : Promise < void > {
1745+ try {
1746+ await stream . cancel ( ) ;
1747+ } catch ( error ) {
1748+ // Ignore "locked" errors - the stream will be cleaned up when the reader is released.
1749+ // This happens when fetch crashes mid-read and doesn't release the reader lock.
1750+ if ( error instanceof TypeError && String ( error ) . includes ( "locked" ) ) {
1751+ return ;
1752+ }
1753+ // Re-throw unexpected errors
1754+ throw error ;
1755+ }
1756+ }
1757+
17341758// ============================================================================
17351759// NDJSON Stream Helpers
17361760// ============================================================================
0 commit comments