Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,9 @@
import com.google.api.core.ApiFuture;
import com.google.api.gax.resumable.ChunkUploadRequest;
import com.google.api.gax.resumable.ChunkUploadResponse;
import com.google.api.gax.resumable.ResumableUploadStatus;
import com.google.api.gax.rpc.ApiCallContext;
import com.google.api.gax.rpc.ApiExceptionFactory;
import com.google.api.gax.rpc.ClientContext;
import com.google.api.gax.rpc.StatusCode;
import com.google.api.gax.rpc.UnaryCallable;
import com.google.api.pathtemplate.PathTemplate;
import com.google.common.base.Preconditions;
Expand All @@ -59,7 +58,6 @@ class ResumableUploadChunkCallable<ResponseT>
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset";
private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status";
private static final String STATUS_FINAL = "final";

private static final String COMMAND_UPLOAD = "upload";
private static final String COMMAND_FINALIZE = "finalize";
Expand Down Expand Up @@ -165,7 +163,7 @@ private static class ChunkUploadResponseListener<ResponseT>

private final ResumableUploadHttpJsonFuture<ChunkUploadResponse<ResponseT>> future;
private final HttpResponseParser<ResponseT> responseParser;
@Nullable private String uploadStatus = null;
private ResumableUploadStatus uploadStatus = ResumableUploadStatus.UNKNOWN;
private String responseBody = "";

private ChunkUploadResponseListener(
Expand All @@ -178,7 +176,9 @@ private ChunkUploadResponseListener(
@Override
public void onHeaders(HttpJsonMetadata responseHeaders) {
Map<String, Object> headers = responseHeaders.getHeaders();
this.uploadStatus = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER);
this.uploadStatus =
ResumableUploadStatus.fromHeader(
HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER));
}

@Override
Expand All @@ -192,21 +192,9 @@ public void onMessage(@Nullable String message) {
public void onClose(int statusCode, HttpJsonMetadata trailers) {
try {
if (statusCode >= 200 && statusCode < 300) {
if (uploadStatus == null) {
future.setException(
ApiExceptionFactory.createException(
"Upload chunk response did not contain valid "
+ UPLOAD_STATUS_HEADER
+ " header",
/* cause= */ null,
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
/* retryable= */ false));
return;
}
boolean isComplete = STATUS_FINAL.equalsIgnoreCase(uploadStatus);
ChunkUploadResponse.Builder<ResponseT> chunkResponseBuilder =
ChunkUploadResponse.<ResponseT>newBuilder().setComplete(isComplete);
if (isComplete) {
ChunkUploadResponse.<ResponseT>newBuilder().setUploadStatus(uploadStatus);
if (uploadStatus == ResumableUploadStatus.FINAL) {
InputStream stream =
new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
chunkResponseBuilder.setResponse(responseParser.parse(stream));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.google.api.core.ApiFuture;
import com.google.api.gax.resumable.QueryStatusRequest;
import com.google.api.gax.resumable.QueryStatusResponse;
import com.google.api.gax.resumable.ResumableUploadStatus;
import com.google.api.gax.rpc.ApiCallContext;
import com.google.api.gax.rpc.ApiExceptionFactory;
import com.google.api.gax.rpc.ClientContext;
Expand Down Expand Up @@ -63,7 +64,6 @@ class ResumableUploadQueryStatusCallable<ResponseT>
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status";
private static final String UPLOAD_SIZE_RECEIVED_HEADER = "X-Goog-Upload-Size-Received";
private static final String STATUS_FINAL = "final";
private static final String COMMAND_QUERY = "query";

private static final Map<String, List<String>> QUERY_STATUS_HEADERS =
Expand Down Expand Up @@ -182,7 +182,7 @@ private static class QueryStatusResponseListener<ResponseT>

private final ResumableUploadHttpJsonFuture<QueryStatusResponse<ResponseT>> future;
private final HttpResponseParser<ResponseT> responseParser;
@Nullable private String uploadStatus = null;
private ResumableUploadStatus uploadStatus = ResumableUploadStatus.UNKNOWN;
@Nullable private Long committedOffset = null;
@Nullable private Throwable headerParsingException;
private String responseBody = "";
Expand All @@ -197,7 +197,9 @@ private QueryStatusResponseListener(
@Override
public void onHeaders(HttpJsonMetadata responseHeaders) {
Map<String, Object> headers = responseHeaders.getHeaders();
this.uploadStatus = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER);
this.uploadStatus =
ResumableUploadStatus.fromHeader(
HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER));
try {
this.committedOffset = parseSizeReceived(responseHeaders);
} catch (Throwable t) {
Expand All @@ -220,19 +222,19 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {
future.setException(headerParsingException);
return;
}
boolean isComplete = STATUS_FINAL.equalsIgnoreCase(uploadStatus);
if (isComplete) {
QueryStatusResponse.Builder<ResponseT> queryResponseBuilder =
QueryStatusResponse.<ResponseT>newBuilder().setComplete(true);
if (uploadStatus == ResumableUploadStatus.FINAL) {
InputStream stream =
new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
queryResponseBuilder.setResponse(responseParser.parse(stream));
future.set(queryResponseBuilder.build());
future.set(
QueryStatusResponse.<ResponseT>newBuilder()
.setUploadStatus(uploadStatus)
.setResponse(responseParser.parse(stream))
.build());
} else if (committedOffset != null) {
future.set(
QueryStatusResponse.<ResponseT>newBuilder()
.setComplete(false)
.setCommittedOffset(committedOffset)
.setUploadStatus(uploadStatus)
.build());
} else {
future.setException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@
import com.google.api.gax.resumable.QueryStatusRequest;
import com.google.api.gax.resumable.QueryStatusResponse;
import com.google.api.gax.resumable.ResumableUploadSession;
import com.google.api.gax.resumable.ResumableUploadStatus;
import com.google.api.gax.rpc.AbortedException;
import com.google.api.gax.rpc.ApiCallContext;
import com.google.api.gax.rpc.ApiException;
import com.google.api.gax.rpc.ClientContext;
import com.google.api.gax.rpc.InternalException;
import com.google.api.gax.rpc.NotFoundException;
Expand Down Expand Up @@ -260,8 +260,8 @@ void uploadChunk_intermediateChunk_sendsUploadCommandAndReturnsActiveStatus() {

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

assertThat(response.isComplete()).isFalse();
assertThat(response.getResponse()).isNull();
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.ACTIVE);

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

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

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

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

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

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

assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("finalize");
assertThat(transport.capturedHeaders).doesNotContainKey("x-goog-upload-offset");
Expand Down Expand Up @@ -379,7 +379,7 @@ void uploadChunk_serverReturnsConflictOrError_throwsException() {
}

@Test
void uploadChunk_missingUploadStatusHeader_throwsInternalException() {
void uploadChunk_missingUploadStatusHeader_returnsUnknownUploadStatusOnHttp200() {
MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
httpResponse.setStatusCode(200);

Expand All @@ -391,39 +391,10 @@ void uploadChunk_missingUploadStatusHeader_throwsInternalException() {
.setOffset(0L)
.build();

ExecutionException exception =
assertThrows(
ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get());

assertThat(exception.getCause()).isInstanceOf(InternalException.class);
assertThat(exception.getCause())
.hasMessageThat()
.contains("Upload chunk response did not contain valid X-Goog-Upload-Status header");
}

@Test
void uploadChunk_serverReturnsFinalStatusOnNon200_marksExceptionNonRetryable() {
MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
httpResponse.setStatusCode(503);
httpResponse.addHeader("X-Goog-Upload-Status", "final");
httpResponse.setContent("{\"error\":{\"message\":\"Upload rejected by backend\"}}");

HttpJsonResumableUploadClient<TestRequest, String> client = createClient(httpResponse);
ChunkUploadRequest request =
ChunkUploadRequest.newBuilder()
.setUploadUrl(TEST_UPLOAD_URL)
.setPayload("data".getBytes(StandardCharsets.UTF_8))
.setOffset(0L)
.build();

ExecutionException exception =
assertThrows(
ExecutionException.class, () -> client.uploadChunkCallable().futureCall(request).get());
ChunkUploadResponse<String> response = client.uploadChunkCallable().call(request);

assertThat(exception.getCause()).isInstanceOf(ApiException.class);
ApiException apiException = (ApiException) exception.getCause();
assertThat(apiException.isRetryable()).isFalse();
assertThat(apiException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.UNAVAILABLE);
assertThat(response.getResponse()).isNull();
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.UNKNOWN);
}

@Test
Expand All @@ -439,9 +410,9 @@ void queryStatus_activeUpload_returnsCommittedOffset() {

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

assertThat(response.isComplete()).isFalse();
assertThat(response.getCommittedOffset()).isEqualTo(524288L);
assertThat(response.getResponse()).isNull();
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.ACTIVE);

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

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

assertThat(response.isComplete()).isTrue();
assertThat(response.getCommittedOffset()).isNull();
assertThat(response.getResponse())
.isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}");
assertThat(response.getUploadStatus()).isEqualTo(ResumableUploadStatus.FINAL);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,35 +46,36 @@
@AutoValue
public abstract class ChunkUploadResponse<ResponseT> {

/** Whether the overall resumable upload stream has finalized and completed on the server. */
public abstract boolean isComplete();

/**
* The response object returned by the server upon final completion (e.g. metadata of the uploaded
* resource), or {@code null} if the upload is still in progress.
*/
public abstract @Nullable ResponseT getResponse();

/** Returns the status of the upload session returned by the server. */
public abstract ResumableUploadStatus getUploadStatus();

public abstract Builder<ResponseT> toBuilder();

public static <ResponseT> Builder<ResponseT> newBuilder() {
return new AutoValue_ChunkUploadResponse.Builder<ResponseT>().setComplete(false);
return new AutoValue_ChunkUploadResponse.Builder<ResponseT>()
.setUploadStatus(ResumableUploadStatus.ACTIVE);
}

public static <ResponseT> ChunkUploadResponse<ResponseT> create(
boolean isComplete, @Nullable ResponseT response) {
ResumableUploadStatus uploadStatus, @Nullable ResponseT response) {
return new AutoValue_ChunkUploadResponse.Builder<ResponseT>()
.setComplete(isComplete)
.setUploadStatus(uploadStatus)
.setResponse(response)
.build();
}

@AutoValue.Builder
public abstract static class Builder<ResponseT> {
public abstract Builder<ResponseT> setComplete(boolean isComplete);

public abstract Builder<ResponseT> setResponse(@Nullable ResponseT response);

public abstract Builder<ResponseT> setUploadStatus(ResumableUploadStatus uploadStatus);

public abstract ChunkUploadResponse<ResponseT> build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,34 +51,35 @@ public abstract class QueryStatusResponse<ResponseT> {
* null} if the server did not return a committed offset (e.g. if the upload is already
* finalized).
*
* <p>When {@link #isComplete()} is {@code false}, this value is guaranteed to be non-null and
* represents the starting offset for resuming the upload.
* <p>When {@link #getUploadStatus()} is {@link ResumableUploadStatus#ACTIVE}, this value is
* guaranteed to be non-null and represents the starting offset for resuming the upload.
*/
public abstract @Nullable Long getCommittedOffset();

/** Whether the resumable upload session has finalized and completed on the server. */
public abstract boolean isComplete();

/**
* The response object returned by the server upon final completion (e.g. metadata of the uploaded
* resource), or {@code null} if the upload is still in progress.
*/
public abstract @Nullable ResponseT getResponse();

/** Returns the status of the upload session returned by the server. */
public abstract ResumableUploadStatus getUploadStatus();

public abstract Builder<ResponseT> toBuilder();

public static <ResponseT> Builder<ResponseT> newBuilder() {
return new AutoValue_QueryStatusResponse.Builder<ResponseT>().setComplete(false);
return new AutoValue_QueryStatusResponse.Builder<ResponseT>()
.setUploadStatus(ResumableUploadStatus.ACTIVE);
}

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

public abstract Builder<ResponseT> setComplete(boolean isComplete);

public abstract Builder<ResponseT> setResponse(@Nullable ResponseT response);

public abstract Builder<ResponseT> setUploadStatus(ResumableUploadStatus uploadStatus);

public abstract QueryStatusResponse<ResponseT> build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2026 Google LLC
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google LLC nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.google.api.gax.resumable;

import com.google.api.core.BetaApi;
import com.google.api.core.InternalApi;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

/**
* Represents the session status returned by the server in the {@code X-Goog-Upload-Status} header.
*/
@NullMarked
@BetaApi
@InternalApi
public enum ResumableUploadStatus {
ACTIVE,
FINAL,
UNKNOWN;

/**
* Parses the {@code X-Goog-Upload-Status} header value into a {@link ResumableUploadStatus},
* returning {@link #UNKNOWN} if the header is absent or unrecognized.
*/
public static ResumableUploadStatus fromHeader(@Nullable String headerValue) {
if ("active".equalsIgnoreCase(headerValue)) {
return ACTIVE;
}
if ("final".equalsIgnoreCase(headerValue)) {
return FINAL;
}
return UNKNOWN;
}
}
Loading
Loading