@@ -457,6 +457,102 @@ void testResumeCall_throwsUnsupportedOperationException() {
457457 () -> callable .resumeCall ("https://upload.url/session" , streamOf ("data" ), null ));
458458 }
459459
460+ @ Test
461+ void testChunkRetry_transientFailureThenSuccess_retriesAndSucceeds () throws Exception {
462+ stubStartSession ("https://upload.url/chunk-retry-ok" );
463+ TrackableStream stream = new TrackableStream ("01234567" ); // exactly 1 chunk of 8 bytes
464+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
465+ .thenReturn (
466+ ApiFutures .immediateFailedFuture (createApiException (503 , StatusCode .Code .UNAVAILABLE )))
467+ .thenReturn (
468+ ApiFutures .immediateFuture (
469+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , "chunk-done" )));
470+
471+ ResumableUploadFuture <String > future = callable .futureCall ("resource-path" , stream , null );
472+
473+ assertThat (future .get ()).isEqualTo ("chunk-done" );
474+ assertThat (future .isDone ()).isTrue ();
475+ assertThat (stream .totalBytesRead ).isEqualTo (8 );
476+ assertThat (stream .closed ).isTrue ();
477+
478+ ArgumentCaptor <ChunkUploadRequest > captor = ArgumentCaptor .forClass (ChunkUploadRequest .class );
479+ verify (mockChunkCallable , times (2 )).futureCall (captor .capture (), any ());
480+ List <ChunkUploadRequest > requests = captor .getAllValues ();
481+ assertThat (requests .get (0 ).getOffset ()).isEqualTo (0 );
482+ assertThat (requests .get (0 ).getPayload ()).isEqualTo ("01234567" .getBytes (StandardCharsets .UTF_8 ));
483+ assertThat (requests .get (1 ).getOffset ()).isEqualTo (0 );
484+ assertThat (requests .get (1 ).getPayload ()).isEqualTo ("01234567" .getBytes (StandardCharsets .UTF_8 ));
485+ }
486+
487+ @ Test
488+ void testChunkRetry_transientFailureExhaustion_surfacesLastError () {
489+ stubStartSession ("https://upload.url/chunk-exhaustion" );
490+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
491+ .thenReturn (
492+ ApiFutures .immediateFailedFuture (createApiException (503 , StatusCode .Code .UNAVAILABLE )));
493+
494+ ResumableUploadFuture <String > future =
495+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
496+
497+ ExecutionException exception = assertThrows (ExecutionException .class , future ::get );
498+ assertThat (exception .getCause ()).isInstanceOf (ApiException .class );
499+ assertThat (((ApiException ) exception .getCause ()).getStatusCode ().getTransportCode ())
500+ .isEqualTo (503 );
501+
502+ // Default chunk retry settings has maxAttempts = 5
503+ verify (mockChunkCallable , times (5 )).futureCall (any (), any ());
504+ }
505+
506+ @ Test
507+ void testChunkRetry_cancellationDuringBackoff_deschedulesPendingAttempt () {
508+ stubStartSession ("https://upload.url/cancel-backoff" );
509+ SettableApiFuture <ChunkUploadResponse <String >> chunkAttempt0Future = SettableApiFuture .create ();
510+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
511+ .thenReturn (chunkAttempt0Future )
512+ .thenReturn (
513+ ApiFutures .immediateFuture (
514+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , "should-not-reach" )));
515+
516+ ResumableUploadFuture <String > sessionFuture =
517+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
518+
519+ // Fail attempt 0 with 503 to schedule backoff
520+ chunkAttempt0Future .setException (createApiException (503 , StatusCode .Code .UNAVAILABLE ));
521+
522+ // Cancel while backoff is pending
523+ assertThat (sessionFuture .cancel (true )).isTrue ();
524+ assertThat (sessionFuture .isCancelled ()).isTrue ();
525+ assertThrows (CancellationException .class , sessionFuture ::get );
526+
527+ // Only attempt 0 occurred; attempt 1 was de-scheduled
528+ verify (mockChunkCallable , times (1 )).futureCall (any (), any ());
529+ }
530+
531+ private static class HttpStatusStatusCode implements StatusCode {
532+ private final int httpStatus ;
533+ private final StatusCode .Code code ;
534+
535+ HttpStatusStatusCode (int httpStatus , StatusCode .Code code ) {
536+ this .httpStatus = httpStatus ;
537+ this .code = code ;
538+ }
539+
540+ @ Override
541+ public StatusCode .Code getCode () {
542+ return code ;
543+ }
544+
545+ @ Override
546+ public Integer getTransportCode () {
547+ return httpStatus ;
548+ }
549+ }
550+
551+ private static ApiException createApiException (int httpStatus , StatusCode .Code code ) {
552+ return ApiExceptionFactory .createException (
553+ "HTTP " + httpStatus , null , new HttpStatusStatusCode (httpStatus , code ), false );
554+ }
555+
460556 private void stubStartSession (String uploadUrl ) {
461557 when (mockStartCallable .futureCall (any (), any ()))
462558 .thenReturn (
@@ -477,11 +573,21 @@ private static void assertChunk(
477573
478574 private static class TrackableStream extends ByteArrayInputStream {
479575 boolean closed = false ;
576+ int totalBytesRead = 0 ;
480577
481578 TrackableStream (String content ) {
482579 super (content .getBytes (StandardCharsets .UTF_8 ));
483580 }
484581
582+ @ Override
583+ public int read (byte [] b , int off , int len ) {
584+ int read = super .read (b , off , len );
585+ if (read > 0 ) {
586+ totalBytesRead += read ;
587+ }
588+ return read ;
589+ }
590+
485591 @ Override
486592 public void close () throws IOException {
487593 closed = true ;
0 commit comments