Skip to content

Commit 23e49c1

Browse files
authored
feat(gax): include upload-status header in resumable upload command response objects (#14420)
This is needed to meet the requirement that the protocol implementation inspect responses for the presence of this header, responding differently in its absence depending on the command (e.g. treat as transient error for start commands, recoverable error for upload, fatal error for query)
1 parent 3ece964 commit 23e49c1

8 files changed

Lines changed: 147 additions & 98 deletions

File tree

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

Lines changed: 7 additions & 19 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,21 +192,9 @@ 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);
207195
ChunkUploadResponse.Builder<ResponseT> chunkResponseBuilder =
208-
ChunkUploadResponse.<ResponseT>newBuilder().setComplete(isComplete);
209-
if (isComplete) {
196+
ChunkUploadResponse.<ResponseT>newBuilder().setUploadStatus(uploadStatus);
197+
if (uploadStatus == ResumableUploadStatus.FINAL) {
210198
InputStream stream =
211199
new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
212200
chunkResponseBuilder.setResponse(responseParser.parse(stream));

‎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
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.resumable;
31+
32+
import com.google.api.core.BetaApi;
33+
import com.google.api.core.InternalApi;
34+
import org.jspecify.annotations.NullMarked;
35+
import org.jspecify.annotations.Nullable;
36+
37+
/**
38+
* Represents the session status returned by the server in the {@code X-Goog-Upload-Status} header.
39+
*/
40+
@NullMarked
41+
@BetaApi
42+
@InternalApi
43+
public enum ResumableUploadStatus {
44+
ACTIVE,
45+
FINAL,
46+
UNKNOWN;
47+
48+
/**
49+
* Parses the {@code X-Goog-Upload-Status} header value into a {@link ResumableUploadStatus},
50+
* returning {@link #UNKNOWN} if the header is absent or unrecognized.
51+
*/
52+
public static ResumableUploadStatus fromHeader(@Nullable String headerValue) {
53+
if ("active".equalsIgnoreCase(headerValue)) {
54+
return ACTIVE;
55+
}
56+
if ("final".equalsIgnoreCase(headerValue)) {
57+
return FINAL;
58+
}
59+
return UNKNOWN;
60+
}
61+
}

0 commit comments

Comments
 (0)