@@ -110,7 +110,11 @@ void setUp() {
110110 callContext = FakeCallContext .createDefault ();
111111 executor = Executors .newScheduledThreadPool (2 );
112112 clientContext =
113- ClientContext .newBuilder ().setDefaultCallContext (callContext ).setExecutor (executor ).build ();
113+ ClientContext .newBuilder ()
114+ .setDefaultCallContext (callContext )
115+ .setExecutor (executor )
116+ .setEndpoint ("https://test.endpoint.com" )
117+ .build ();
114118 callable = new ResumableUploadCallableImpl <>(mockClient , defaultSettings , clientContext );
115119 }
116120
@@ -1343,6 +1347,167 @@ void testProgressListener_orderingUnderConcurrency_pinsSequentialExecutor() thro
13431347 }
13441348 }
13451349
1350+ @ Test
1351+ void testActionableErrors_startFailure_preservesOriginalExceptionWithoutEndpointSuffix () {
1352+ ApiException startError = createApiException (401 , StatusCode .Code .UNAUTHENTICATED );
1353+ when (mockStartCallable .futureCall (any (), any ()))
1354+ .thenReturn (ApiFutures .immediateFailedFuture (startError ));
1355+
1356+ ResumableUploadFuture <String > future =
1357+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1358+
1359+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1360+ assertThat (ex .getCause ()).isSameInstanceAs (startError );
1361+ assertThat (ex .getCause ().getMessage ()).doesNotContain ("endpoint:" );
1362+ assertThat (future .getUploadSessionUrl ()).isNull ();
1363+ }
1364+
1365+ @ Test
1366+ void testActionableErrors_chunkFailure_messageContainsUploadSessionUrl () {
1367+ String sessionUrl = "https://upload.url/chunk-error-test" ;
1368+ stubStartSession (sessionUrl );
1369+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1370+ .thenReturn (
1371+ ApiFutures .immediateFailedFuture (
1372+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1373+
1374+ ResumableUploadFuture <String > future =
1375+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1376+
1377+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1378+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1379+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1380+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1381+ }
1382+
1383+ @ Test
1384+ void testActionableErrors_preservesErrorDetailsCauseChainAndSuppressedExceptions () {
1385+ String sessionUrl = "https://upload.url/chunk-error-details-test" ;
1386+ stubStartSession (sessionUrl );
1387+ ErrorDetails errorDetails = ErrorDetails .builder ().build ();
1388+ ApiException original =
1389+ ApiExceptionFactory .createException (
1390+ "HTTP 403" ,
1391+ null ,
1392+ new HttpStatusStatusCode (403 , StatusCode .Code .PERMISSION_DENIED ),
1393+ false ,
1394+ errorDetails );
1395+ IOException suppressed = new IOException ("underlying stream error" );
1396+ original .addSuppressed (suppressed );
1397+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1398+ .thenReturn (ApiFutures .immediateFailedFuture (original ));
1399+
1400+ ResumableUploadFuture <String > future =
1401+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1402+
1403+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1404+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1405+ ApiException cause = (ApiException ) ex .getCause ();
1406+ assertThat (cause .getMessage ()).contains (sessionUrl );
1407+ assertThat (cause .getCause ()).isSameInstanceAs (original );
1408+ assertThat (cause .getErrorDetails ()).isSameInstanceAs (errorDetails );
1409+ assertThat (cause .getSuppressed ()).asList ().contains (suppressed );
1410+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1411+ }
1412+
1413+ @ Test
1414+ void testActionableErrors_recoveryFailure_messageContainsUploadSessionUrl () {
1415+ String sessionUrl = "https://upload.url/recovery-error-test" ;
1416+ stubStartSession (sessionUrl );
1417+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1418+ .thenReturn (
1419+ ApiFutures .immediateFailedFuture (
1420+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1421+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1422+ .thenReturn (
1423+ ApiFutures .immediateFailedFuture (
1424+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1425+
1426+ ResumableUploadFuture <String > future =
1427+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1428+
1429+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1430+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1431+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1432+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1433+ }
1434+
1435+ @ Test
1436+ void testActionableErrors_globalTimeoutFailure_messageContainsUploadSessionUrl () {
1437+ String sessionUrl = "https://upload.url/timeout-error-test" ;
1438+ stubStartSession (sessionUrl );
1439+ SettableApiFuture <ChunkUploadResponse <String >> hungChunk = SettableApiFuture .create ();
1440+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ())).thenReturn (hungChunk );
1441+
1442+ ResumableUploadCallSettings settings =
1443+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (50 )).build ();
1444+
1445+ ResumableUploadFuture <String > future =
1446+ callable .futureCall ("resource-path" , streamOf ("hello" ), null , settings );
1447+
1448+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1449+ assertThat (ex .getCause ()).isInstanceOf (DeadlineExceededException .class );
1450+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1451+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1452+ }
1453+
1454+ @ Test
1455+ void testActionableErrors_rewindFailure_surfacesActionableSeekableStreamMessage () {
1456+ String sessionUrl = "https://upload.url/rewind-error-test" ;
1457+ stubStartSession (sessionUrl );
1458+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1459+ .thenReturn (
1460+ ApiFutures .immediateFuture (
1461+ ChunkUploadResponse .create (ResumableUploadStatus .ACTIVE , null )))
1462+ .thenReturn (
1463+ ApiFutures .immediateFailedFuture (
1464+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1465+
1466+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1467+ .thenReturn (
1468+ ApiFutures .immediateFuture (
1469+ createQueryResponse (false , 4L , null , ResumableUploadStatus .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+
1482+ @ Test
1483+ void testUploadCallable_failureOutcome_attachesCloseExceptionViaAddSuppressed () {
1484+ when (mockStartCallable .futureCall (any (), any ()))
1485+ .thenReturn (ApiFutures .immediateFailedFuture (new IllegalStateException ("upload failed" )));
1486+
1487+ InputStream failingStream =
1488+ new InputStream () {
1489+ @ Override
1490+ public int read () {
1491+ return -1 ;
1492+ }
1493+
1494+ @ Override
1495+ public void close () throws IOException {
1496+ throw new IOException ("stream close error" );
1497+ }
1498+ };
1499+
1500+ ResumableUploadFuture <String > future =
1501+ callable .futureCall ("resource-path" , failingStream , null );
1502+ ExecutionException exception = assertThrows (ExecutionException .class , future ::get );
1503+ assertThat (exception .getCause ()).isInstanceOf (IllegalStateException .class );
1504+ assertThat (exception .getCause ().getSuppressed ()).asList ().hasSize (1 );
1505+ assertThat (exception .getCause ().getSuppressed ()[0 ]).isInstanceOf (IOException .class );
1506+ assertThat (exception .getCause ().getSuppressed ()[0 ])
1507+ .hasMessageThat ()
1508+ .contains ("stream close error" );
1509+ }
1510+
13461511 private static class HttpStatusStatusCode implements StatusCode {
13471512 private final int httpStatus ;
13481513 private final StatusCode .Code code ;
0 commit comments