@@ -109,7 +109,11 @@ void setUp() {
109109 callContext = FakeCallContext .createDefault ();
110110 executor = Executors .newScheduledThreadPool (2 );
111111 clientContext =
112- ClientContext .newBuilder ().setDefaultCallContext (callContext ).setExecutor (executor ).build ();
112+ ClientContext .newBuilder ()
113+ .setDefaultCallContext (callContext )
114+ .setExecutor (executor )
115+ .setEndpoint ("https://test.endpoint.com" )
116+ .build ();
113117 callable = new ResumableUploadCallableImpl <>(mockClient , defaultSettings , clientContext );
114118 }
115119
@@ -1347,6 +1351,134 @@ void testProgressListener_orderingUnderConcurrency_pinsSequentialExecutor() thro
13471351 }
13481352 }
13491353
1354+ @ Test
1355+ void testActionableErrors_startFailure_preservesOriginalExceptionWithoutEndpointSuffix () {
1356+ ApiException startError = createApiException (401 , StatusCode .Code .UNAUTHENTICATED );
1357+ when (mockStartCallable .futureCall (any (), any ()))
1358+ .thenReturn (ApiFutures .immediateFailedFuture (startError ));
1359+
1360+ ResumableUploadFuture <String > future =
1361+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1362+
1363+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1364+ assertThat (ex .getCause ()).isSameInstanceAs (startError );
1365+ assertThat (ex .getCause ().getMessage ()).doesNotContain ("endpoint:" );
1366+ assertThat (future .getUploadSessionUrl ()).isNull ();
1367+ }
1368+
1369+ @ Test
1370+ void testActionableErrors_chunkFailure_messageContainsUploadSessionUrl () {
1371+ String sessionUrl = "https://upload.url/chunk-error-test" ;
1372+ stubStartSession (sessionUrl );
1373+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1374+ .thenReturn (
1375+ ApiFutures .immediateFailedFuture (
1376+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1377+
1378+ ResumableUploadFuture <String > future =
1379+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1380+
1381+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1382+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1383+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1384+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1385+ }
1386+
1387+ @ Test
1388+ void testActionableErrors_preservesErrorDetailsCauseChainAndSuppressedExceptions () {
1389+ String sessionUrl = "https://upload.url/chunk-error-details-test" ;
1390+ stubStartSession (sessionUrl );
1391+ ErrorDetails errorDetails = ErrorDetails .builder ().build ();
1392+ ApiException original =
1393+ ApiExceptionFactory .createException (
1394+ "HTTP 403" ,
1395+ null ,
1396+ new HttpStatusStatusCode (403 , StatusCode .Code .PERMISSION_DENIED ),
1397+ false ,
1398+ errorDetails );
1399+ IOException suppressed = new IOException ("underlying stream error" );
1400+ original .addSuppressed (suppressed );
1401+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1402+ .thenReturn (ApiFutures .immediateFailedFuture (original ));
1403+
1404+ ResumableUploadFuture <String > future =
1405+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1406+
1407+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1408+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1409+ ApiException cause = (ApiException ) ex .getCause ();
1410+ assertThat (cause .getMessage ()).contains (sessionUrl );
1411+ assertThat (cause .getCause ()).isSameInstanceAs (original );
1412+ assertThat (cause .getErrorDetails ()).isSameInstanceAs (errorDetails );
1413+ assertThat (cause .getSuppressed ()).asList ().contains (suppressed );
1414+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1415+ }
1416+
1417+ @ Test
1418+ void testActionableErrors_recoveryFailure_messageContainsUploadSessionUrl () {
1419+ String sessionUrl = "https://upload.url/recovery-error-test" ;
1420+ stubStartSession (sessionUrl );
1421+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1422+ .thenReturn (
1423+ ApiFutures .immediateFailedFuture (
1424+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1425+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1426+ .thenReturn (
1427+ ApiFutures .immediateFailedFuture (
1428+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1429+
1430+ ResumableUploadFuture <String > future =
1431+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1432+
1433+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1434+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1435+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1436+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1437+ }
1438+
1439+ @ Test
1440+ void testActionableErrors_globalTimeoutFailure_messageContainsUploadSessionUrl () {
1441+ String sessionUrl = "https://upload.url/timeout-error-test" ;
1442+ stubStartSession (sessionUrl );
1443+ SettableApiFuture <ChunkUploadResponse <String >> hungChunk = SettableApiFuture .create ();
1444+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ())).thenReturn (hungChunk );
1445+
1446+ ResumableUploadCallSettings settings =
1447+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (50 )).build ();
1448+
1449+ ResumableUploadFuture <String > future =
1450+ callable .futureCall ("resource-path" , streamOf ("hello" ), null , settings );
1451+
1452+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1453+ assertThat (ex .getCause ()).isInstanceOf (DeadlineExceededException .class );
1454+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1455+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1456+ }
1457+
1458+ @ Test
1459+ void testActionableErrors_rewindFailure_surfacesActionableSeekableStreamMessage () {
1460+ String sessionUrl = "https://upload.url/rewind-error-test" ;
1461+ stubStartSession (sessionUrl );
1462+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1463+ .thenReturn (ApiFutures .immediateFuture (ChunkUploadResponse .create (false , null )))
1464+ .thenReturn (
1465+ ApiFutures .immediateFailedFuture (
1466+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1467+
1468+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1469+ .thenReturn (ApiFutures .immediateFuture (createQueryResponse (false , 4L , null , "active" )));
1470+
1471+ byte [] data = new byte [16 ];
1472+ ResumableUploadFuture <String > future =
1473+ callable .futureCall ("resource-path" , new ByteArrayInputStream (data ), null );
1474+
1475+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1476+ assertThat (ex .getCause ()).isInstanceOf (FailedPreconditionException .class );
1477+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1478+ assertThat (ex .getCause ().getMessage ()).contains ("seekable stream" );
1479+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1480+ }
1481+
13501482 private static class HttpStatusStatusCode implements StatusCode {
13511483 private final int httpStatus ;
13521484 private final StatusCode .Code code ;
0 commit comments