@@ -46,6 +46,11 @@ import type { FetchImpl } from './http.js';
4646/** Schema version stamped into `meta.json`. Bumps with the contract. */
4747export const BUNDLE_SCHEMA_VERSION = 'cli-v1' as const ;
4848
49+ /** Max fetch attempts for each presigned URL (initial + retries). */
50+ export const STREAM_URL_MAX_RETRIES = 3 ;
51+ /** Delay between retries for transient transport failures (ms). */
52+ export const STREAM_URL_RETRY_DELAY_MS = 1000 ;
53+
4954/** Default radius around the failed step when `--failed-only` is set. */
5055export const FAILED_ONLY_RADIUS = 1 ;
5156
@@ -695,55 +700,84 @@ function sidecarExtension(kind: 'log' | 'network' | 'console' | 'screenshot' | '
695700 * the upstream reader rather than buffering chunks in V8 heap. This
696701 * matters for video files (multi-MB) and for very large HTML
697702 * snapshots.
703+ *
704+ * Transport failures (network reset, DNS blip, mid-stream EOF) are
705+ * retried up to STREAM_URL_MAX_RETRIES times with a fixed delay.
706+ * Presigned URLs are valid for 15 minutes, so retries are safe.
698707 */
699708export async function streamUrlToFile (
700709 url : string ,
701710 filePath : string ,
702711 fetchImpl : FetchImpl ,
712+ deps ?: { sleep ?: ( ms : number ) => Promise < void > } ,
703713) : Promise < void > {
704- let response : Response ;
705- try {
706- response = await fetchImpl ( url ) ;
707- } catch ( err ) {
708- const message = err instanceof Error ? err . message : String ( err ) ;
709- throw new TransportError ( `Failed to download presigned URL ${ url } : ${ message } ` ) ;
710- }
711- if ( ! response . ok ) {
712- throw ApiError . fromEnvelope ( {
713- error : {
714- code : 'UNAVAILABLE' ,
715- message : `Failed to download presigned URL (HTTP ${ response . status } ).` ,
716- nextAction :
717- 'Re-run `testsprite test failure get`. Presigned URLs in the bundle expire after 15 minutes.' ,
718- requestId : 'local' ,
719- details : { status : response . status , url } ,
720- } ,
721- } ) ;
722- }
723- if ( ! response . body ) {
724- // Some test runtimes / fetch polyfills don't expose `body` as a
725- // ReadableStream. Fall back to a buffered write — same correctness,
726- // just no streaming benefit. The bundle is bounded by the
727- // backend's 15-min TTL, so even a multi-MB video buffers fully in
728- // a tolerable amount of memory.
729- const buffer = Buffer . from ( await response . arrayBuffer ( ) ) ;
730- await writeFile ( filePath , buffer ) ;
731- return ;
732- }
733- await mkdir ( dirname ( filePath ) , { recursive : true } ) ;
734- // `response.body` is a Web ReadableStream. Node's `pipeline` accepts
735- // it via `Readable.fromWeb` (Node ≥ 18). Wrap in a try so any error
736- // from the stream propagates as a TransportError, preserving the
737- // exit-code contract.
738- const fileSink = createWriteStream ( filePath ) ;
739- try {
740- const webBody = response . body as unknown as NodeReadableStream < Uint8Array > ;
741- const { Readable } = await import ( 'node:stream' ) ;
742- const nodeStream = Readable . fromWeb ( webBody ) ;
743- await pipeline ( nodeStream , fileSink as unknown as Writable ) ;
744- } catch ( err ) {
745- const message = err instanceof Error ? err . message : String ( err ) ;
746- throw new TransportError ( `Failed mid-download of ${ url } : ${ message } ` ) ;
714+ const sleepFn = deps ?. sleep ?? ( ( ms : number ) => new Promise < void > ( r => setTimeout ( r , ms ) ) ) ;
715+ for ( let attempt = 1 ; attempt <= STREAM_URL_MAX_RETRIES ; attempt ++ ) {
716+ let response : Response ;
717+ try {
718+ response = await fetchImpl ( url ) ;
719+ } catch ( err ) {
720+ const message = err instanceof Error ? err . message : String ( err ) ;
721+ if ( attempt < STREAM_URL_MAX_RETRIES ) {
722+ await sleepFn ( STREAM_URL_RETRY_DELAY_MS ) ;
723+ continue ;
724+ }
725+ throw new TransportError ( `Failed to download presigned URL ${ url } : ${ message } ` ) ;
726+ }
727+ if ( ! response . ok ) {
728+ // Non-2xx: the URL itself is bad (expired, unauthorized, not found).
729+ // Retrying the same URL won't help — surface immediately.
730+ throw ApiError . fromEnvelope ( {
731+ error : {
732+ code : 'UNAVAILABLE' ,
733+ message : `Failed to download presigned URL (HTTP ${ response . status } ).` ,
734+ nextAction :
735+ 'Re-run `testsprite test failure get`. Presigned URLs in the bundle expire after 15 minutes.' ,
736+ requestId : 'local' ,
737+ details : { status : response . status , url } ,
738+ } ,
739+ } ) ;
740+ }
741+ if ( ! response . body ) {
742+ // Some test runtimes / fetch polyfills don't expose `body` as a
743+ // ReadableStream. Fall back to a buffered write — same correctness,
744+ // just no streaming benefit. The bundle is bounded by the
745+ // backend's 15-min TTL, so even a multi-MB video buffers fully in
746+ // a tolerable amount of memory.
747+ try {
748+ const buffer = Buffer . from ( await response . arrayBuffer ( ) ) ;
749+ await writeFile ( filePath , buffer ) ;
750+ return ;
751+ } catch ( err ) {
752+ const message = err instanceof Error ? err . message : String ( err ) ;
753+ if ( attempt < STREAM_URL_MAX_RETRIES ) {
754+ await sleepFn ( STREAM_URL_RETRY_DELAY_MS ) ;
755+ continue ;
756+ }
757+ throw new TransportError ( `Failed to download presigned URL ${ url } : ${ message } ` ) ;
758+ }
759+ }
760+ await mkdir ( dirname ( filePath ) , { recursive : true } ) ;
761+ // `response.body` is a Web ReadableStream. Node's `pipeline` accepts
762+ // it via `Readable.fromWeb` (Node >= 18). Wrap in a try so any error
763+ // from the stream propagates as a TransportError, preserving the
764+ // exit-code contract. `pipeline` destroys both streams on error, so
765+ // a new WriteStream on retry starts clean.
766+ const fileSink = createWriteStream ( filePath ) ;
767+ try {
768+ const webBody = response . body as unknown as NodeReadableStream < Uint8Array > ;
769+ const { Readable } = await import ( 'node:stream' ) ;
770+ const nodeStream = Readable . fromWeb ( webBody ) ;
771+ await pipeline ( nodeStream , fileSink as unknown as Writable ) ;
772+ return ;
773+ } catch ( err ) {
774+ const message = err instanceof Error ? err . message : String ( err ) ;
775+ if ( attempt < STREAM_URL_MAX_RETRIES ) {
776+ await sleepFn ( STREAM_URL_RETRY_DELAY_MS ) ;
777+ continue ;
778+ }
779+ throw new TransportError ( `Failed mid-download of ${ url } : ${ message } ` ) ;
780+ }
747781 }
748782}
749783
0 commit comments