Skip to content

Commit b437cfb

Browse files
committed
feat(gax): surface actionable error messages with upload session URL and stream requirements
Improve error messages across resumable upload failure paths to provide actionable context for debugging and recovery. - In ResumableUploadChunkCoordinator, augment outgoing exception messages on terminal failures with the active upload session URL (or the endpoint if session initiation failed), ensuring callers have the session URL required for diagnostic queries and manual resume. Also include the upload session URL when reporting an incomplete upload status after transmitting the final chunk. - In RewindableStreamBuffer, clarify the exception message when a server committed offset falls below the buffer base offset by explicitly explaining that a seekable stream is required to rewind to earlier offsets. - Add comprehensive unit tests in ResumableUploadCallableImplTest and RewindableStreamBufferTest verifying that the upload URL or endpoint appears in error messages across start failures, chunk transfer failures, recovery query failures, global timeout expirations, and rewind-below-base violations.
1 parent f4f204e commit b437cfb

4 files changed

Lines changed: 174 additions & 5 deletions

File tree

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadChunkCoordinator.java‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,49 @@ private void finish(@Nullable ResponseT response, @Nullable Throwable error) {
274274
progressTracker.onFinalized(totalBytes);
275275
result.set(response);
276276
} else {
277+
Throwable augmented = augmentWithUrl(error);
277278
if (closeError != null) {
278-
error.addSuppressed(closeError);
279+
augmented.addSuppressed(closeError);
279280
}
280-
progressTracker.onFailed(error, uploadSessionUrl);
281-
result.setException(error);
281+
progressTracker.onFailed(augmented, uploadSessionUrl);
282+
result.setException(augmented);
282283
}
283284
}
284285

286+
private Throwable augmentWithUrl(Throwable t) {
287+
String url = uploadSessionUrl;
288+
if (url == null || url.isEmpty()) {
289+
return t;
290+
}
291+
String message = t.getMessage();
292+
if (message != null && message.contains(url)) {
293+
return t;
294+
}
295+
String augmentedMessage =
296+
(message != null ? message : t.getClass().getSimpleName()) + " (upload URL: " + url + ")";
297+
Throwable augmented = t;
298+
if (t instanceof ApiException) {
299+
ApiException apiException = (ApiException) t;
300+
augmented =
301+
ApiExceptionFactory.createException(
302+
augmentedMessage,
303+
apiException,
304+
apiException.getStatusCode(),
305+
apiException.isRetryable(),
306+
apiException.getErrorDetails());
307+
} else if (t instanceof IllegalStateException) {
308+
augmented = new IllegalStateException(augmentedMessage, t);
309+
} else if (t instanceof IOException) {
310+
augmented = new IOException(augmentedMessage, t);
311+
}
312+
if (augmented != t) {
313+
for (Throwable suppressed : t.getSuppressed()) {
314+
augmented.addSuppressed(suppressed);
315+
}
316+
}
317+
return augmented;
318+
}
319+
285320
private @Nullable IOException closePayload() {
286321
synchronized (lock) {
287322
if (payloadClosed) {

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/RewindableStreamBuffer.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ void realignTo(long committedOffset) throws IOException {
101101
throw UploadErrors.protocolViolation(
102102
String.format(
103103
"Server committed offset %d is below buffer base offset %d for upload URL %s; cannot"
104-
+ " rewind stream before buffer base",
104+
+ " rewind stream before buffer base. A seekable stream is required to rewind to"
105+
+ " earlier offsets.",
105106
committedOffset, bufferBaseOffset, uploadUrl));
106107
}
107108

‎sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java‎

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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;

‎sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/RewindableStreamBufferTest.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ void testRealignToBelowBaseOffset_throwsFailedPreconditionException_classifiedFa
154154
assertThat(exception.getMessage()).contains("4");
155155
assertThat(exception.getMessage()).contains("8");
156156
assertThat(exception.getMessage()).contains(UPLOAD_URL);
157+
assertThat(exception.getMessage()).contains("seekable stream");
157158

158159
// Must be classified as FATAL by UploadErrorClassifier
159160
UploadErrorClassifier.Category category =

0 commit comments

Comments
 (0)