@@ -107,7 +107,11 @@ void setUp() {
107107 callContext = FakeCallContext .createDefault ();
108108 executor = Executors .newScheduledThreadPool (2 );
109109 clientContext =
110- ClientContext .newBuilder ().setDefaultCallContext (callContext ).setExecutor (executor ).build ();
110+ ClientContext .newBuilder ()
111+ .setDefaultCallContext (callContext )
112+ .setExecutor (executor )
113+ .setEndpoint ("https://test.endpoint.com" )
114+ .build ();
111115 callable = new ResumableUploadCallableImpl <>(mockClient , defaultSettings , clientContext );
112116 }
113117
@@ -1409,6 +1413,134 @@ void testProgressListener_orderingUnderConcurrency_pinsSequentialExecutor() thro
14091413 }
14101414 }
14111415
1416+ @ Test
1417+ void testActionableErrors_startFailureAfterRetries_messageContainsEndpointUrl () {
1418+ when (mockStartCallable .futureCall (any (), any ()))
1419+ .thenReturn (
1420+ ApiFutures .immediateFailedFuture (
1421+ createApiException (401 , StatusCode .Code .UNAUTHENTICATED )));
1422+
1423+ ResumableUploadFuture <String > future =
1424+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1425+
1426+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1427+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1428+ assertThat (ex .getCause ().getMessage ()).contains ("https://test.endpoint.com" );
1429+ assertThat (future .getUploadSessionUrl ()).isNull ();
1430+ }
1431+
1432+ @ Test
1433+ void testActionableErrors_chunkFailureAfterRetries_messageContainsUploadSessionUrl () {
1434+ String sessionUrl = "https://upload.url/chunk-error-test" ;
1435+ stubStartSession (sessionUrl );
1436+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1437+ .thenReturn (
1438+ ApiFutures .immediateFailedFuture (
1439+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1440+
1441+ ResumableUploadFuture <String > future =
1442+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1443+
1444+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1445+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1446+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1447+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1448+ }
1449+
1450+ @ Test
1451+ void testActionableErrors_preservesErrorDetailsAndSuppressedExceptions () {
1452+ String sessionUrl = "https://upload.url/chunk-error-details-test" ;
1453+ stubStartSession (sessionUrl );
1454+ ErrorDetails errorDetails = ErrorDetails .builder ().build ();
1455+ ApiException original =
1456+ ApiExceptionFactory .createException (
1457+ "HTTP 403" ,
1458+ null ,
1459+ new HttpStatusStatusCode (403 , StatusCode .Code .PERMISSION_DENIED ),
1460+ false ,
1461+ errorDetails );
1462+ IOException suppressed = new IOException ("underlying stream error" );
1463+ original .addSuppressed (suppressed );
1464+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1465+ .thenReturn (ApiFutures .immediateFailedFuture (original ));
1466+
1467+ ResumableUploadFuture <String > future =
1468+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1469+
1470+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1471+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1472+ ApiException cause = (ApiException ) ex .getCause ();
1473+ assertThat (cause .getMessage ()).contains (sessionUrl );
1474+ assertThat (cause .getErrorDetails ()).isSameInstanceAs (errorDetails );
1475+ assertThat (cause .getSuppressed ()).asList ().contains (suppressed );
1476+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1477+ }
1478+
1479+ @ Test
1480+ void testActionableErrors_recoveryFailure_messageContainsUploadSessionUrl () {
1481+ String sessionUrl = "https://upload.url/recovery-error-test" ;
1482+ stubStartSession (sessionUrl );
1483+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1484+ .thenReturn (
1485+ ApiFutures .immediateFailedFuture (
1486+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1487+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1488+ .thenReturn (
1489+ ApiFutures .immediateFailedFuture (
1490+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1491+
1492+ ResumableUploadFuture <String > future =
1493+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1494+
1495+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1496+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1497+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1498+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1499+ }
1500+
1501+ @ Test
1502+ void testActionableErrors_globalTimeoutFailure_messageContainsUploadSessionUrl () {
1503+ String sessionUrl = "https://upload.url/timeout-error-test" ;
1504+ stubStartSession (sessionUrl );
1505+ SettableApiFuture <ChunkUploadResponse <String >> hungChunk = SettableApiFuture .create ();
1506+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ())).thenReturn (hungChunk );
1507+
1508+ ResumableUploadCallSettings settings =
1509+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (50 )).build ();
1510+
1511+ ResumableUploadFuture <String > future =
1512+ callable .futureCall ("resource-path" , streamOf ("hello" ), null , settings );
1513+
1514+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1515+ assertThat (ex .getCause ()).isInstanceOf (DeadlineExceededException .class );
1516+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1517+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1518+ }
1519+
1520+ @ Test
1521+ void testActionableErrors_rewindFailure_surfacesActionableSeekableStreamMessage () {
1522+ String sessionUrl = "https://upload.url/rewind-error-test" ;
1523+ stubStartSession (sessionUrl );
1524+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1525+ .thenReturn (ApiFutures .immediateFuture (ChunkUploadResponse .create (false , null )))
1526+ .thenReturn (
1527+ ApiFutures .immediateFailedFuture (
1528+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1529+
1530+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1531+ .thenReturn (ApiFutures .immediateFuture (createQueryResponse (false , 4L , null , "active" )));
1532+
1533+ byte [] data = new byte [16 ];
1534+ ResumableUploadFuture <String > future =
1535+ callable .futureCall ("resource-path" , new ByteArrayInputStream (data ), null );
1536+
1537+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1538+ assertThat (ex .getCause ()).isInstanceOf (FailedPreconditionException .class );
1539+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1540+ assertThat (ex .getCause ().getMessage ()).contains ("seekable stream" );
1541+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1542+ }
1543+
14121544 private static class HttpStatusStatusCode implements StatusCode {
14131545 private final int httpStatus ;
14141546 private final StatusCode .Code code ;
0 commit comments