Skip to content

Commit ff7b2fc

Browse files
committed
feat(gax): add upload-status header plumbing
Add nullable getUploadStatus() accessors to ChunkUploadResponse, QueryStatusResponse, and ResumableUploadSession, and plumb the X-Goog-Upload-Status response header through the HTTP/JSON callables. On HTTP 200 chunk responses where X-Goog-Upload-Status is absent, return a ChunkUploadResponse with a null uploadStatus rather than throwing a wire-level exception, allowing higher-level upload coordinators to classify the missing header and trigger protocol recovery. Existing test uploadChunk_missingUploadStatusHeader_throwsInternalException was updated to uploadChunk_missingUploadStatusHeader_returnsNullUploadStatusOnHttp200 to reflect that the missing status header on HTTP 200 is now surfaced via a null status property on ChunkUploadResponse instead of throwing an InternalException at the transport layer. Note: No end-to-end integration test is included because the test server always returns the X-Goog-Upload-Status header on success, making header absence uninjectable end-to-end.
1 parent bd363f6 commit ff7b2fc

8 files changed

Lines changed: 149 additions & 101 deletions

File tree

‎sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadChunkCallable.java‎

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,9 @@
3333
import com.google.api.core.ApiFuture;
3434
import com.google.api.gax.resumable.ChunkUploadRequest;
3535
import com.google.api.gax.resumable.ChunkUploadResponse;
36+
import com.google.api.gax.resumable.ResumableUploadStatus;
3637
import com.google.api.gax.rpc.ApiCallContext;
37-
import com.google.api.gax.rpc.ApiExceptionFactory;
3838
import com.google.api.gax.rpc.ClientContext;
39-
import com.google.api.gax.rpc.StatusCode;
4039
import com.google.api.gax.rpc.UnaryCallable;
4140
import com.google.api.pathtemplate.PathTemplate;
4241
import com.google.common.base.Preconditions;
@@ -59,7 +58,6 @@ class ResumableUploadChunkCallable<ResponseT>
5958
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
6059
private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset";
6160
private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status";
62-
private static final String STATUS_FINAL = "final";
6361

6462
private static final String COMMAND_UPLOAD = "upload";
6563
private static final String COMMAND_FINALIZE = "finalize";
@@ -165,7 +163,7 @@ private static class ChunkUploadResponseListener<ResponseT>
165163

166164
private final ResumableUploadHttpJsonFuture<ChunkUploadResponse<ResponseT>> future;
167165
private final HttpResponseParser<ResponseT> responseParser;
168-
@Nullable private String uploadStatus = null;
166+
private ResumableUploadStatus uploadStatus = ResumableUploadStatus.UNKNOWN;
169167
private String responseBody = "";
170168

171169
private ChunkUploadResponseListener(
@@ -178,7 +176,9 @@ private ChunkUploadResponseListener(
178176
@Override
179177
public void onHeaders(HttpJsonMetadata responseHeaders) {
180178
Map<String, Object> headers = responseHeaders.getHeaders();
181-
this.uploadStatus = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER);
179+
this.uploadStatus =
180+
ResumableUploadStatus.fromHeader(
181+
HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER));
182182
}
183183

184184
@Override
@@ -192,26 +192,13 @@ public void onMessage(@Nullable String message) {
192192
public void onClose(int statusCode, HttpJsonMetadata trailers) {
193193
try {
194194
if (statusCode >= 200 && statusCode < 300) {
195-
if (uploadStatus == null) {
196-
future.setException(
197-
ApiExceptionFactory.createException(
198-
"Upload chunk response did not contain valid "
199-
+ UPLOAD_STATUS_HEADER
200-
+ " header",
201-
/* cause= */ null,
202-
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
203-
/* retryable= */ false));
204-
return;
205-
}
206-
boolean isComplete = STATUS_FINAL.equalsIgnoreCase(uploadStatus);
207-
ChunkUploadResponse.Builder<ResponseT> chunkResponseBuilder =
208-
ChunkUploadResponse.<ResponseT>newBuilder().setComplete(isComplete);
209-
if (isComplete) {
195+
ResponseT response = null;
196+
if (uploadStatus == ResumableUploadStatus.FINAL) {
210197
InputStream stream =
211198
new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
212-
chunkResponseBuilder.setResponse(responseParser.parse(stream));
199+
response = responseParser.parse(stream);
213200
}
214-
future.set(chunkResponseBuilder.build());
201+
future.set(ChunkUploadResponse.create(uploadStatus, response));
215202
} else {
216203
Throwable cause = trailers.getException();
217204
future.setException(

‎sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadQueryStatusCallable.java‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.google.api.core.ApiFuture;
3434
import com.google.api.gax.resumable.QueryStatusRequest;
3535
import com.google.api.gax.resumable.QueryStatusResponse;
36+
import com.google.api.gax.resumable.ResumableUploadStatus;
3637
import com.google.api.gax.rpc.ApiCallContext;
3738
import com.google.api.gax.rpc.ApiExceptionFactory;
3839
import com.google.api.gax.rpc.ClientContext;
@@ -63,7 +64,6 @@ class ResumableUploadQueryStatusCallable<ResponseT>
6364
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
6465
private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status";
6566
private static final String UPLOAD_SIZE_RECEIVED_HEADER = "X-Goog-Upload-Size-Received";
66-
private static final String STATUS_FINAL = "final";
6767
private static final String COMMAND_QUERY = "query";
6868

6969
private static final Map<String, List<String>> QUERY_STATUS_HEADERS =
@@ -182,7 +182,7 @@ private static class QueryStatusResponseListener<ResponseT>
182182

183183
private final ResumableUploadHttpJsonFuture<QueryStatusResponse<ResponseT>> future;
184184
private final HttpResponseParser<ResponseT> responseParser;
185-
@Nullable private String uploadStatus = null;
185+
private ResumableUploadStatus uploadStatus = ResumableUploadStatus.UNKNOWN;
186186
@Nullable private Long committedOffset = null;
187187
@Nullable private Throwable headerParsingException;
188188
private String responseBody = "";
@@ -197,7 +197,9 @@ private QueryStatusResponseListener(
197197
@Override
198198
public void onHeaders(HttpJsonMetadata responseHeaders) {
199199
Map<String, Object> headers = responseHeaders.getHeaders();
200-
this.uploadStatus = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER);
200+
this.uploadStatus =
201+
ResumableUploadStatus.fromHeader(
202+
HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER));
201203
try {
202204
this.committedOffset = parseSizeReceived(responseHeaders);
203205
} catch (Throwable t) {
@@ -220,19 +222,19 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {
220222
future.setException(headerParsingException);
221223
return;
222224
}
223-
boolean isComplete = STATUS_FINAL.equalsIgnoreCase(uploadStatus);
224-
if (isComplete) {
225-
QueryStatusResponse.Builder<ResponseT> queryResponseBuilder =
226-
QueryStatusResponse.<ResponseT>newBuilder().setComplete(true);
225+
if (uploadStatus == ResumableUploadStatus.FINAL) {
227226
InputStream stream =
228227
new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
229-
queryResponseBuilder.setResponse(responseParser.parse(stream));
230-
future.set(queryResponseBuilder.build());
228+
future.set(
229+
QueryStatusResponse.<ResponseT>newBuilder()
230+
.setUploadStatus(uploadStatus)
231+
.setResponse(responseParser.parse(stream))
232+
.build());
231233
} else if (committedOffset != null) {
232234
future.set(
233235
QueryStatusResponse.<ResponseT>newBuilder()
234-
.setComplete(false)
235236
.setCommittedOffset(committedOffset)
237+
.setUploadStatus(uploadStatus)
236238
.build());
237239
} else {
238240
future.setException(

‎sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java‎

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@
4545
import com.google.api.gax.resumable.QueryStatusRequest;
4646
import com.google.api.gax.resumable.QueryStatusResponse;
4747
import com.google.api.gax.resumable.ResumableUploadSession;
48+
import com.google.api.gax.resumable.ResumableUploadStatus;
4849
import com.google.api.gax.rpc.AbortedException;
4950
import com.google.api.gax.rpc.ApiCallContext;
50-
import com.google.api.gax.rpc.ApiException;
5151
import com.google.api.gax.rpc.ClientContext;
5252
import com.google.api.gax.rpc.InternalException;
5353
import com.google.api.gax.rpc.NotFoundException;
@@ -260,8 +260,8 @@ void uploadChunk_intermediateChunk_sendsUploadCommandAndReturnsActiveStatus() {
260260

261261
ChunkUploadResponse<String> response = client.uploadChunkCallable().call(request);
262262

263-
assertThat(response.isComplete()).isFalse();
264263
assertThat(response.getResponse()).isNull();
264+
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.ACTIVE);
265265

266266
assertThat(transport.capturedUrl).isEqualTo(TEST_UPLOAD_URL);
267267
assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("upload");
@@ -289,9 +289,9 @@ void uploadChunk_finalChunk_sendsUploadFinalizeAndReturnsResponseBody() {
289289

290290
ChunkUploadResponse<String> response = client.uploadChunkCallable().call(request);
291291

292-
assertThat(response.isComplete()).isTrue();
293292
assertThat(response.getResponse())
294293
.isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":524288}");
294+
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.FINAL);
295295

296296
assertThat(transport.capturedHeaders.get("x-goog-upload-command"))
297297
.containsExactly("upload, finalize");
@@ -318,9 +318,9 @@ void uploadChunk_emptyPayloadFinal_sendsFinalizeCommandAndReturnsResponseBody()
318318

319319
ChunkUploadResponse<String> response = client.uploadChunkCallable().call(request);
320320

321-
assertThat(response.isComplete()).isTrue();
322321
assertThat(response.getResponse())
323322
.isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}");
323+
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.FINAL);
324324

325325
assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("finalize");
326326
assertThat(transport.capturedHeaders).doesNotContainKey("x-goog-upload-offset");
@@ -379,7 +379,7 @@ void uploadChunk_serverReturnsConflictOrError_throwsException() {
379379
}
380380

381381
@Test
382-
void uploadChunk_missingUploadStatusHeader_throwsInternalException() {
382+
void uploadChunk_missingUploadStatusHeader_returnsUnknownUploadStatusOnHttp200() {
383383
MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
384384
httpResponse.setStatusCode(200);
385385

@@ -391,39 +391,10 @@ void uploadChunk_missingUploadStatusHeader_throwsInternalException() {
391391
.setOffset(0L)
392392
.build();
393393

394-
ExecutionException exception =
395-
assertThrows(
396-
ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get());
397-
398-
assertThat(exception.getCause()).isInstanceOf(InternalException.class);
399-
assertThat(exception.getCause())
400-
.hasMessageThat()
401-
.contains("Upload chunk response did not contain valid X-Goog-Upload-Status header");
402-
}
403-
404-
@Test
405-
void uploadChunk_serverReturnsFinalStatusOnNon200_marksExceptionNonRetryable() {
406-
MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
407-
httpResponse.setStatusCode(503);
408-
httpResponse.addHeader("X-Goog-Upload-Status", "final");
409-
httpResponse.setContent("{\"error\":{\"message\":\"Upload rejected by backend\"}}");
410-
411-
HttpJsonResumableUploadClient<TestRequest, String> client = createClient(httpResponse);
412-
ChunkUploadRequest request =
413-
ChunkUploadRequest.newBuilder()
414-
.setUploadUrl(TEST_UPLOAD_URL)
415-
.setPayload("data".getBytes(StandardCharsets.UTF_8))
416-
.setOffset(0L)
417-
.build();
418-
419-
ExecutionException exception =
420-
assertThrows(
421-
ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get());
394+
ChunkUploadResponse<String> response = client.uploadChunkCallable().call(request);
422395

423-
assertThat(exception.getCause()).isInstanceOf(ApiException.class);
424-
ApiException apiException = (ApiException) exception.getCause();
425-
assertThat(apiException.isRetryable()).isFalse();
426-
assertThat(apiException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.UNAVAILABLE);
396+
assertThat(response.getResponse()).isNull();
397+
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.UNKNOWN);
427398
}
428399

429400
@Test
@@ -439,9 +410,9 @@ void queryStatus_activeUpload_returnsCommittedOffset() {
439410

440411
QueryStatusResponse<String> response = client.queryStatusCallable().call(request);
441412

442-
assertThat(response.isComplete()).isFalse();
443413
assertThat(response.getCommittedOffset()).isEqualTo(524288L);
444414
assertThat(response.getResponse()).isNull();
415+
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.ACTIVE);
445416

446417
assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("query");
447418
}
@@ -458,10 +429,10 @@ void queryStatus_finalUpload_returnsCompleteAndResponseBody() {
458429

459430
QueryStatusResponse<String> response = client.queryStatusCallable().call(request);
460431

461-
assertThat(response.isComplete()).isTrue();
462432
assertThat(response.getCommittedOffset()).isNull();
463433
assertThat(response.getResponse())
464434
.isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}");
435+
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.FINAL);
465436
}
466437

467438
@Test

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,35 +46,36 @@
4646
@AutoValue
4747
public abstract class ChunkUploadResponse<ResponseT> {
4848

49-
/** Whether the overall resumable upload stream has finalized and completed on the server. */
50-
public abstract boolean isComplete();
51-
5249
/**
5350
* The response object returned by the server upon final completion (e.g. metadata of the uploaded
5451
* resource), or {@code null} if the upload is still in progress.
5552
*/
5653
public abstract @Nullable ResponseT getResponse();
5754

55+
/** Returns the status of the upload session returned by the server. */
56+
public abstract ResumableUploadStatus getUploadStatus();
57+
5858
public abstract Builder<ResponseT> toBuilder();
5959

6060
public static <ResponseT> Builder<ResponseT> newBuilder() {
61-
return new AutoValue_ChunkUploadResponse.Builder<ResponseT>().setComplete(false);
61+
return new AutoValue_ChunkUploadResponse.Builder<ResponseT>()
62+
.setUploadStatus(ResumableUploadStatus.ACTIVE);
6263
}
6364

6465
public static <ResponseT> ChunkUploadResponse<ResponseT> create(
65-
boolean isComplete, @Nullable ResponseT response) {
66+
ResumableUploadStatus uploadStatus, @Nullable ResponseT response) {
6667
return new AutoValue_ChunkUploadResponse.Builder<ResponseT>()
67-
.setComplete(isComplete)
68+
.setUploadStatus(uploadStatus)
6869
.setResponse(response)
6970
.build();
7071
}
7172

7273
@AutoValue.Builder
7374
public abstract static class Builder<ResponseT> {
74-
public abstract Builder<ResponseT> setComplete(boolean isComplete);
75-
7675
public abstract Builder<ResponseT> setResponse(@Nullable ResponseT response);
7776

77+
public abstract Builder<ResponseT> setUploadStatus(ResumableUploadStatus uploadStatus);
78+
7879
public abstract ChunkUploadResponse<ResponseT> build();
7980
}
8081
}

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusResponse.java‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,34 +51,35 @@ public abstract class QueryStatusResponse<ResponseT> {
5151
* null} if the server did not return a committed offset (e.g. if the upload is already
5252
* finalized).
5353
*
54-
* <p>When {@link #isComplete()} is {@code false}, this value is guaranteed to be non-null and
55-
* represents the starting offset for resuming the upload.
54+
* <p>When {@link #getUploadStatus()} is {@link ResumableUploadStatus#ACTIVE}, this value is
55+
* guaranteed to be non-null and represents the starting offset for resuming the upload.
5656
*/
5757
public abstract @Nullable Long getCommittedOffset();
5858

59-
/** Whether the resumable upload session has finalized and completed on the server. */
60-
public abstract boolean isComplete();
61-
6259
/**
6360
* The response object returned by the server upon final completion (e.g. metadata of the uploaded
6461
* resource), or {@code null} if the upload is still in progress.
6562
*/
6663
public abstract @Nullable ResponseT getResponse();
6764

65+
/** Returns the status of the upload session returned by the server. */
66+
public abstract ResumableUploadStatus getUploadStatus();
67+
6868
public abstract Builder<ResponseT> toBuilder();
6969

7070
public static <ResponseT> Builder<ResponseT> newBuilder() {
71-
return new AutoValue_QueryStatusResponse.Builder<ResponseT>().setComplete(false);
71+
return new AutoValue_QueryStatusResponse.Builder<ResponseT>()
72+
.setUploadStatus(ResumableUploadStatus.ACTIVE);
7273
}
7374

7475
@AutoValue.Builder
7576
public abstract static class Builder<ResponseT> {
7677
public abstract Builder<ResponseT> setCommittedOffset(@Nullable Long committedOffset);
7778

78-
public abstract Builder<ResponseT> setComplete(boolean isComplete);
79-
8079
public abstract Builder<ResponseT> setResponse(@Nullable ResponseT response);
8180

81+
public abstract Builder<ResponseT> setUploadStatus(ResumableUploadStatus uploadStatus);
82+
8283
public abstract QueryStatusResponse<ResponseT> build();
8384
}
8485
}

0 commit comments

Comments
 (0)