@@ -404,6 +404,102 @@ void testResumeCall_throwsUnsupportedOperationException() {
404404 () -> callable .resumeCall ("https://upload.url/session" , streamOf ("data" ), null ));
405405 }
406406
407+ @ Test
408+ void testChunkRetry_transientFailureThenSuccess_retriesAndSucceeds () throws Exception {
409+ stubStartSession ("https://upload.url/chunk-retry-ok" );
410+ TrackableStream stream = new TrackableStream ("01234567" ); // exactly 1 chunk of 8 bytes
411+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
412+ .thenReturn (
413+ ApiFutures .immediateFailedFuture (createApiException (503 , StatusCode .Code .UNAVAILABLE )))
414+ .thenReturn (
415+ ApiFutures .immediateFuture (
416+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , "chunk-done" )));
417+
418+ ResumableUploadFuture <String > future = callable .futureCall ("resource-path" , stream , null );
419+
420+ assertThat (future .get ()).isEqualTo ("chunk-done" );
421+ assertThat (future .isDone ()).isTrue ();
422+ assertThat (stream .totalBytesRead ).isEqualTo (8 );
423+ assertThat (stream .closed ).isTrue ();
424+
425+ ArgumentCaptor <ChunkUploadRequest > captor = ArgumentCaptor .forClass (ChunkUploadRequest .class );
426+ verify (mockChunkCallable , times (2 )).futureCall (captor .capture (), any ());
427+ List <ChunkUploadRequest > requests = captor .getAllValues ();
428+ assertThat (requests .get (0 ).getOffset ()).isEqualTo (0 );
429+ assertThat (requests .get (0 ).getPayload ()).isEqualTo ("01234567" .getBytes (StandardCharsets .UTF_8 ));
430+ assertThat (requests .get (1 ).getOffset ()).isEqualTo (0 );
431+ assertThat (requests .get (1 ).getPayload ()).isEqualTo ("01234567" .getBytes (StandardCharsets .UTF_8 ));
432+ }
433+
434+ @ Test
435+ void testChunkRetry_transientFailureExhaustion_surfacesLastError () {
436+ stubStartSession ("https://upload.url/chunk-exhaustion" );
437+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
438+ .thenReturn (
439+ ApiFutures .immediateFailedFuture (createApiException (503 , StatusCode .Code .UNAVAILABLE )));
440+
441+ ResumableUploadFuture <String > future =
442+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
443+
444+ ExecutionException exception = assertThrows (ExecutionException .class , future ::get );
445+ assertThat (exception .getCause ()).isInstanceOf (ApiException .class );
446+ assertThat (((ApiException ) exception .getCause ()).getStatusCode ().getTransportCode ())
447+ .isEqualTo (503 );
448+
449+ // Default chunk retry settings has maxAttempts = 5
450+ verify (mockChunkCallable , times (5 )).futureCall (any (), any ());
451+ }
452+
453+ @ Test
454+ void testChunkRetry_cancellationDuringBackoff_deschedulesPendingAttempt () {
455+ stubStartSession ("https://upload.url/cancel-backoff" );
456+ SettableApiFuture <ChunkUploadResponse <String >> chunkAttempt0Future = SettableApiFuture .create ();
457+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
458+ .thenReturn (chunkAttempt0Future )
459+ .thenReturn (
460+ ApiFutures .immediateFuture (
461+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , "should-not-reach" )));
462+
463+ ResumableUploadFuture <String > sessionFuture =
464+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
465+
466+ // Fail attempt 0 with 503 to schedule backoff
467+ chunkAttempt0Future .setException (createApiException (503 , StatusCode .Code .UNAVAILABLE ));
468+
469+ // Cancel while backoff is pending
470+ assertThat (sessionFuture .cancel (true )).isTrue ();
471+ assertThat (sessionFuture .isCancelled ()).isTrue ();
472+ assertThrows (CancellationException .class , sessionFuture ::get );
473+
474+ // Only attempt 0 occurred; attempt 1 was de-scheduled
475+ verify (mockChunkCallable , times (1 )).futureCall (any (), any ());
476+ }
477+
478+ private static class HttpStatusStatusCode implements StatusCode {
479+ private final int httpStatus ;
480+ private final StatusCode .Code code ;
481+
482+ HttpStatusStatusCode (int httpStatus , StatusCode .Code code ) {
483+ this .httpStatus = httpStatus ;
484+ this .code = code ;
485+ }
486+
487+ @ Override
488+ public StatusCode .Code getCode () {
489+ return code ;
490+ }
491+
492+ @ Override
493+ public Integer getTransportCode () {
494+ return httpStatus ;
495+ }
496+ }
497+
498+ private static ApiException createApiException (int httpStatus , StatusCode .Code code ) {
499+ return ApiExceptionFactory .createException (
500+ "HTTP " + httpStatus , null , new HttpStatusStatusCode (httpStatus , code ), false );
501+ }
502+
407503 private void stubStartSession (String uploadUrl ) {
408504 when (mockStartCallable .futureCall (any (), any ()))
409505 .thenReturn (
@@ -424,11 +520,21 @@ private static void assertChunk(
424520
425521 private static class TrackableStream extends ByteArrayInputStream {
426522 boolean closed = false ;
523+ int totalBytesRead = 0 ;
427524
428525 TrackableStream (String content ) {
429526 super (content .getBytes (StandardCharsets .UTF_8 ));
430527 }
431528
529+ @ Override
530+ public int read (byte [] b , int off , int len ) {
531+ int read = super .read (b , off , len );
532+ if (read > 0 ) {
533+ totalBytesRead += read ;
534+ }
535+ return read ;
536+ }
537+
432538 @ Override
433539 public void close () throws IOException {
434540 closed = true ;
0 commit comments