@@ -378,4 +378,97 @@ mod tests {
378378 )
379379 . await ;
380380 }
381+
382+ /// Spawns a local TCP server that always answers `503` (a retryable status) and
383+ /// counts how many connections (= upload attempts) it receives.
384+ fn spawn_mock_returning_503 ( ) -> ( String , std:: sync:: Arc < std:: sync:: atomic:: AtomicUsize > ) {
385+ use std:: io:: { Read , Write } ;
386+ use std:: net:: TcpListener ;
387+ use std:: sync:: Arc ;
388+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
389+
390+ let listener = TcpListener :: bind ( "127.0.0.1:0" ) . unwrap ( ) ;
391+ let url = format ! ( "http://{}/upload" , listener. local_addr( ) . unwrap( ) ) ;
392+ let hits = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
393+
394+ let hits_loop = hits. clone ( ) ;
395+ std:: thread:: spawn ( move || {
396+ for stream in listener. incoming ( ) {
397+ let Ok ( mut stream) = stream else { continue } ;
398+ hits_loop. fetch_add ( 1 , Ordering :: SeqCst ) ;
399+ std:: thread:: spawn ( move || {
400+ let mut buf = [ 0u8 ; 2048 ] ;
401+ let _ = stream. read ( & mut buf) ;
402+ let body = "transient" ;
403+ let resp = format ! (
404+ "HTTP/1.1 503 Service Unavailable\r \n Content-Length: {}\r \n Connection: close\r \n \r \n {}" ,
405+ body. len( ) ,
406+ body
407+ ) ;
408+ let _ = stream. write_all ( resp. as_bytes ( ) ) ;
409+ } ) ;
410+ }
411+ } ) ;
412+
413+ ( url, hits)
414+ }
415+
416+ fn upload_data_for ( url : String ) -> UploadData {
417+ UploadData {
418+ status : "success" . to_string ( ) ,
419+ upload_url : url,
420+ run_id : "test-run" . to_string ( ) ,
421+ }
422+ }
423+
424+ /// WallTime/Memory path: an on-disk archive is streamed via `STREAMING_CLIENT`,
425+ /// which has no retry middleware, so `send_streamed_with_retry` rebuilds the
426+ /// stream on each attempt. A transient `503` must be retried 3 times (4 attempts
427+ /// total). Slow (~7s) because of the default exponential backoff (1s, 2s, 4s).
428+ #[ tokio:: test]
429+ async fn streamed_upload_is_retried ( ) {
430+ use std:: sync:: atomic:: Ordering ;
431+
432+ let ( url, hits) = spawn_mock_returning_503 ( ) ;
433+
434+ let path = tempfile:: NamedTempFile :: new ( )
435+ . unwrap ( )
436+ . into_temp_path ( )
437+ . keep ( )
438+ . unwrap ( ) ;
439+ std:: fs:: write ( & path, b"profile-archive" ) . unwrap ( ) ;
440+ let archive = ProfileArchive :: new_uncompressed_on_disk ( path) . unwrap ( ) ;
441+
442+ let result = upload_profile_archive ( & upload_data_for ( url) , archive) . await ;
443+
444+ assert ! ( result. is_err( ) , "a 503 should surface as an error after retries" ) ;
445+ assert_eq ! (
446+ hits. load( Ordering :: SeqCst ) ,
447+ 4 ,
448+ "streamed upload should be attempted 4 times (1 + 3 retries)"
449+ ) ;
450+ }
451+
452+ /// Valgrind path: an in-memory archive goes through `REQUEST_CLIENT`, which retries
453+ /// transient failures 3 times. Confirms the same `503` mock is genuinely retryable,
454+ /// so the single attempt above is due to the client, not the status code.
455+ ///
456+ /// Slow (~7s) because of the default exponential backoff (1s, 2s, 4s).
457+ #[ tokio:: test]
458+ async fn in_memory_upload_is_retried ( ) {
459+ use std:: sync:: atomic:: Ordering ;
460+
461+ let ( url, hits) = spawn_mock_returning_503 ( ) ;
462+
463+ let archive = ProfileArchive :: new_compressed_in_memory ( b"profile-archive" . to_vec ( ) ) ;
464+
465+ let result = upload_profile_archive ( & upload_data_for ( url) , archive) . await ;
466+
467+ assert ! ( result. is_err( ) , "a 503 should surface as an error" ) ;
468+ assert_eq ! (
469+ hits. load( Ordering :: SeqCst ) ,
470+ 4 ,
471+ "in-memory upload should be attempted 4 times (1 + 3 retries)"
472+ ) ;
473+ }
381474}
0 commit comments