Skip to content

Commit 04958ce

Browse files
committed
feat(gax): surface actionable error messages with upload session URL and stream requirements
Augments terminal failure exceptions with the active upload session URL to aid debugging and session recovery. Clarifies error messages when a server committed offset falls below the buffer base offset.
1 parent 9a55379 commit 04958ce

4 files changed

Lines changed: 212 additions & 5 deletions

File tree

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,48 @@ private void fail(Throwable t) {
270270
if (inFlight != null) {
271271
inFlight.cancel(true);
272272
}
273-
progressTracker.onFailed(t, uploadSessionUrl);
274-
closePayload();
275-
resultFuture.setException(t);
273+
Throwable augmented = augmentWithUrl(t);
274+
progressTracker.onFailed(augmented, uploadSessionUrl);
275+
try {
276+
payload.close();
277+
} catch (Throwable closeException) {
278+
augmented.addSuppressed(closeException);
279+
}
280+
resultFuture.setException(augmented);
281+
}
282+
283+
private Throwable augmentWithUrl(Throwable t) {
284+
String url = uploadSessionUrl;
285+
if (url == null || url.isEmpty()) {
286+
return t;
287+
}
288+
String message = t.getMessage();
289+
if (message != null && message.contains(url)) {
290+
return t;
291+
}
292+
String baseMessage = message != null ? message : t.getClass().getSimpleName();
293+
String augmentedMessage = baseMessage + " (upload URL: " + url + ")";
294+
Throwable augmented = t;
295+
if (t instanceof ApiException) {
296+
ApiException apiException = (ApiException) t;
297+
augmented =
298+
ApiExceptionFactory.createException(
299+
augmentedMessage,
300+
apiException,
301+
apiException.getStatusCode(),
302+
apiException.isRetryable(),
303+
apiException.getErrorDetails());
304+
} else if (t instanceof IllegalStateException) {
305+
augmented = new IllegalStateException(augmentedMessage, t);
306+
} else if (t instanceof IOException) {
307+
augmented = new IOException(augmentedMessage, t);
308+
}
309+
if (augmented != t) {
310+
for (Throwable suppressed : t.getSuppressed()) {
311+
augmented.addSuppressed(suppressed);
312+
}
313+
}
314+
return augmented;
276315
}
277316

278317
private void closePayload() {

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
@@ -95,7 +95,8 @@ void realignTo(long committedOffset) throws IOException {
9595
throw protocolViolation(
9696
String.format(
9797
"Server committed offset %d is below buffer base offset %d for upload URL %s; cannot"
98-
+ " rewind stream before buffer base",
98+
+ " rewind stream before buffer base. A seekable stream is required to rewind to"
99+
+ " earlier offsets.",
99100
committedOffset, bufferBaseOffset, uploadUrl));
100101
}
101102

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

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

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
@@ -150,6 +150,7 @@ void testRealignToBelowBaseOffset_throwsFailedPreconditionException() throws IOE
150150
assertThat(exception.getMessage()).contains("4");
151151
assertThat(exception.getMessage()).contains("8");
152152
assertThat(exception.getMessage()).contains(UPLOAD_URL);
153+
assertThat(exception.getMessage()).contains("seekable stream");
153154
}
154155

155156
@Test

0 commit comments

Comments
 (0)