@@ -108,7 +108,11 @@ void setUp() {
108108
109109 defaultSettings = ResumableUploadCallSettings .newBuilder ().setChunkSize (8 ).build ();
110110 callContext = FakeCallContext .createDefault ();
111- clientContext = ClientContext .newBuilder ().setDefaultCallContext (callContext ).build ();
111+ clientContext =
112+ ClientContext .newBuilder ()
113+ .setDefaultCallContext (callContext )
114+ .setEndpoint ("https://test.endpoint.com" )
115+ .build ();
112116 executor = Executors .newSingleThreadExecutor ();
113117 callable = new ResumableUploadCallableImpl <>(mockClient , defaultSettings , clientContext );
114118 }
@@ -1229,6 +1233,168 @@ void testProgressListener_orderingUnderConcurrency_pinsSequentialExecutor() thro
12291233 }
12301234 }
12311235
1236+ @ Test
1237+ void testActionableErrors_startFailure_preservesOriginalExceptionWithoutEndpointSuffix () {
1238+ ApiException startError = createApiException (401 , StatusCode .Code .UNAUTHENTICATED );
1239+ when (mockStartCallable .futureCall (any (), any ()))
1240+ .thenReturn (ApiFutures .immediateFailedFuture (startError ));
1241+
1242+ ResumableUploadFuture <String > future =
1243+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1244+
1245+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1246+ assertThat (ex .getCause ()).isSameInstanceAs (startError );
1247+ assertThat (ex .getCause ().getMessage ()).doesNotContain ("endpoint:" );
1248+ assertThat (future .getUploadSessionUrl ()).isNull ();
1249+ }
1250+
1251+ @ Test
1252+ void testActionableErrors_chunkFailure_messageContainsUploadSessionUrl () {
1253+ String sessionUrl = "https://upload.url/chunk-error-test" ;
1254+ stubStartSession (sessionUrl );
1255+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1256+ .thenReturn (
1257+ ApiFutures .immediateFailedFuture (
1258+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1259+
1260+ ResumableUploadFuture <String > future =
1261+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1262+
1263+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1264+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1265+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1266+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1267+ }
1268+
1269+ @ Test
1270+ void testActionableErrors_preservesErrorDetailsCauseChainAndSuppressedExceptions () {
1271+ String sessionUrl = "https://upload.url/chunk-error-details-test" ;
1272+ stubStartSession (sessionUrl );
1273+ ErrorDetails errorDetails = ErrorDetails .builder ().build ();
1274+ ApiException original =
1275+ ApiExceptionFactory .createException (
1276+ "HTTP 403" ,
1277+ null ,
1278+ new HttpStatusStatusCode (403 , StatusCode .Code .PERMISSION_DENIED ),
1279+ false ,
1280+ errorDetails );
1281+ IOException suppressed = new IOException ("underlying stream error" );
1282+ original .addSuppressed (suppressed );
1283+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1284+ .thenReturn (ApiFutures .immediateFailedFuture (original ));
1285+
1286+ ResumableUploadFuture <String > future =
1287+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1288+
1289+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1290+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1291+ ApiException cause = (ApiException ) ex .getCause ();
1292+ assertThat (cause .getMessage ()).contains (sessionUrl );
1293+ assertThat (cause .getCause ()).isSameInstanceAs (original );
1294+ assertThat (cause .getErrorDetails ()).isSameInstanceAs (errorDetails );
1295+ assertThat (cause .getSuppressed ()).asList ().contains (suppressed );
1296+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1297+ }
1298+
1299+ @ Test
1300+ void testActionableErrors_recoveryFailure_messageContainsUploadSessionUrl () {
1301+ String sessionUrl = "https://upload.url/recovery-error-test" ;
1302+ stubStartSession (sessionUrl );
1303+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1304+ .thenReturn (
1305+ ApiFutures .immediateFailedFuture (
1306+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1307+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1308+ .thenReturn (
1309+ ApiFutures .immediateFailedFuture (
1310+ createApiException (403 , StatusCode .Code .PERMISSION_DENIED )));
1311+
1312+ ResumableUploadFuture <String > future =
1313+ callable .futureCall ("resource-path" , streamOf ("hello" ), null );
1314+
1315+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1316+ assertThat (ex .getCause ()).isInstanceOf (ApiException .class );
1317+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1318+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1319+ }
1320+
1321+ @ Test
1322+ void testActionableErrors_globalTimeoutFailure_messageContainsUploadSessionUrl () {
1323+ String sessionUrl = "https://upload.url/timeout-error-test" ;
1324+ stubStartSession (sessionUrl );
1325+ SettableApiFuture <ChunkUploadResponse <String >> hungChunk = SettableApiFuture .create ();
1326+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ())).thenReturn (hungChunk );
1327+
1328+ ResumableUploadCallSettings settings =
1329+ defaultSettings .toBuilder ().setGlobalTimeout (Duration .ofMillis (50 )).build ();
1330+
1331+ ResumableUploadFuture <String > future =
1332+ callable .futureCall ("resource-path" , streamOf ("hello" ), null , settings );
1333+
1334+ ExecutionException ex =
1335+ assertThrows (ExecutionException .class , () -> future .get (5 , TimeUnit .SECONDS ));
1336+ assertThat (ex .getCause ()).isInstanceOf (DeadlineExceededException .class );
1337+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1338+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1339+ }
1340+
1341+ @ Test
1342+ void testActionableErrors_rewindFailure_surfacesActionableSeekableStreamMessage () {
1343+ String sessionUrl = "https://upload.url/rewind-error-test" ;
1344+ stubStartSession (sessionUrl );
1345+ when (mockChunkCallable .futureCall (any (ChunkUploadRequest .class ), any ()))
1346+ .thenReturn (
1347+ ApiFutures .immediateFuture (
1348+ ChunkUploadResponse .create (ResumableUploadStatus .ACTIVE , null )))
1349+ .thenReturn (
1350+ ApiFutures .immediateFailedFuture (
1351+ createApiException (400 , StatusCode .Code .INVALID_ARGUMENT )));
1352+
1353+ when (mockQueryCallable .futureCall (any (QueryStatusRequest .class ), any ()))
1354+ .thenReturn (
1355+ ApiFutures .immediateFuture (
1356+ createQueryResponse (4L , null , ResumableUploadStatus .ACTIVE )));
1357+
1358+ byte [] data = new byte [16 ];
1359+ ResumableUploadFuture <String > future =
1360+ callable .futureCall ("resource-path" , new ByteArrayInputStream (data ), null );
1361+
1362+ ExecutionException ex = assertThrows (ExecutionException .class , future ::get );
1363+ assertThat (ex .getCause ()).isInstanceOf (FailedPreconditionException .class );
1364+ assertThat (ex .getCause ().getMessage ()).contains (sessionUrl );
1365+ assertThat (ex .getCause ().getMessage ()).contains ("seekable stream" );
1366+ assertThat (future .getUploadSessionUrl ()).isEqualTo (sessionUrl );
1367+ }
1368+
1369+ @ Test
1370+ void testUploadCallable_failureOutcome_attachesCloseExceptionViaAddSuppressed () {
1371+ when (mockStartCallable .futureCall (any (), any ()))
1372+ .thenReturn (ApiFutures .immediateFailedFuture (new IllegalStateException ("upload failed" )));
1373+
1374+ InputStream failingStream =
1375+ new InputStream () {
1376+ @ Override
1377+ public int read () {
1378+ return -1 ;
1379+ }
1380+
1381+ @ Override
1382+ public void close () throws IOException {
1383+ throw new IOException ("stream close error" );
1384+ }
1385+ };
1386+
1387+ ResumableUploadFuture <String > future =
1388+ callable .futureCall ("resource-path" , failingStream , null );
1389+ ExecutionException exception = assertThrows (ExecutionException .class , future ::get );
1390+ assertThat (exception .getCause ()).isInstanceOf (IllegalStateException .class );
1391+ assertThat (exception .getCause ().getSuppressed ()).asList ().hasSize (1 );
1392+ assertThat (exception .getCause ().getSuppressed ()[0 ]).isInstanceOf (IOException .class );
1393+ assertThat (exception .getCause ().getSuppressed ()[0 ])
1394+ .hasMessageThat ()
1395+ .contains ("stream close error" );
1396+ }
1397+
12321398 private static class HttpStatusStatusCode implements StatusCode {
12331399 private final int httpStatus ;
12341400 private final StatusCode .Code code ;
0 commit comments