From 0721d6f99412b597e37bc21f4be2bda57a4503e5 Mon Sep 17 00:00:00 2001 From: whowes Date: Wed, 19 Aug 2026 23:06:21 +0000 Subject: [PATCH] feat(gax): implement queryStatus in HttpJsonResumableUploadClient --- .../HttpJsonResumableUploadClient.java | 178 ++++++++++++++++-- .../HttpJsonResumableUploadClientTest.java | 147 +++++++++++++++ .../api/gax/resumable/QueryStatusRequest.java | 48 +++++ .../gax/resumable/QueryStatusResponse.java | 67 +++++++ .../gax/resumable/ResumableUploadClient.java | 3 + 5 files changed, 425 insertions(+), 18 deletions(-) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusResponse.java diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java index 1eb3b6e95e8c..b45c6ab21036 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java @@ -35,6 +35,8 @@ import com.google.api.core.InternalApi; import com.google.api.gax.resumable.ChunkUploadRequest; import com.google.api.gax.resumable.ChunkUploadResponse; +import com.google.api.gax.resumable.QueryStatusRequest; +import com.google.api.gax.resumable.QueryStatusResponse; import com.google.api.gax.resumable.ResumableUploadClient; import com.google.api.gax.resumable.ResumableUploadSession; import com.google.api.gax.rpc.ApiCallContext; @@ -85,6 +87,9 @@ public final class HttpJsonResumableUploadClient private static final PathTemplate PATH_TEMPLATE = PathTemplate.create("{+path}"); + private static final Map> QUERY_STATUS_HEADERS = + ImmutableMap.of(UPLOAD_COMMAND_HEADER, ImmutableList.of("query")); + private static final ApiMethodDescriptor UPLOAD_CHUNK_DESCRIPTOR = ApiMethodDescriptor.newBuilder() .setFullMethodName("ResumableUpload/UploadChunk") @@ -115,10 +120,42 @@ public PathTemplate getPathTemplate() { .setResponseParser(ResumableUploadResponseParser.create()) .build(); + private static final ApiMethodDescriptor QUERY_STATUS_DESCRIPTOR = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("ResumableUpload/QueryStatus") + .setHttpMethod(HttpMethods.POST) + .setType(ApiMethodDescriptor.MethodType.UNARY) + .setRequestFormatter( + new HttpRequestFormatter() { + @Override + public Map> getQueryParamNames(QueryStatusRequest request) { + return Collections.emptyMap(); + } + + @Override + public String getRequestBody(QueryStatusRequest request) { + return ""; + } + + @Override + public String getPath(QueryStatusRequest request) { + return request.getUploadUrl(); + } + + @Override + public PathTemplate getPathTemplate() { + return PATH_TEMPLATE; + } + }) + .setResponseParser(ResumableUploadResponseParser.create()) + .build(); + private final ApiMethodDescriptor startUploadDescriptor; private final UnaryCallable startUploadCallable; private final UnaryCallable> uploadChunkCallable; + private final UnaryCallable> + queryStatusCallable; public static HttpJsonResumableUploadClient create( ClientContext clientContext, ApiMethodDescriptor methodDescriptor) { @@ -142,6 +179,7 @@ private HttpJsonResumableUploadClient( .build(); this.startUploadCallable = createStartUploadCallable(clientContext); this.uploadChunkCallable = createUploadChunkCallable(clientContext, responseParser); + this.queryStatusCallable = createQueryStatusCallable(clientContext, responseParser); } @Override @@ -154,6 +192,11 @@ public UnaryCallable> uploadC return uploadChunkCallable; } + @Override + public UnaryCallable> queryStatusCallable() { + return queryStatusCallable; + } + private UnaryCallable createStartUploadCallable( ClientContext clientContext) { UnaryCallable rawCallable = @@ -222,6 +265,35 @@ public ApiFuture> futureCall( return createClientCallable(rawCallable, clientContext); } + private UnaryCallable> + createQueryStatusCallable( + ClientContext clientContext, HttpResponseParser responseParser) { + UnaryCallable> rawCallable = + new UnaryCallable>() { + @Override + public ApiFuture> futureCall( + QueryStatusRequest request, @Nullable ApiCallContext inputContext) { + Preconditions.checkNotNull(request); + HttpJsonCallContext context = + createCallContext(clientContext, inputContext, QUERY_STATUS_HEADERS); + + HttpJsonClientCall clientCall = + HttpJsonClientCalls.newCall(QUERY_STATUS_DESCRIPTOR, context); + + HttpJsonCallFuture> future = + new HttpJsonCallFuture<>(clientCall); + HttpJsonClientCalls.startUnaryCall( + clientCall, + request, + context, + new QueryStatusResponseListener<>(future, responseParser)); + + return future; + } + }; + return createClientCallable(rawCallable, clientContext); + } + private static HttpJsonCallContext createCallContext( ClientContext clientContext, @Nullable ApiCallContext inputContext, @@ -243,6 +315,22 @@ private static UnaryCallable createCl return callable.withDefaultCallContext(clientContext.getDefaultCallContext()); } + @Nullable + private static ResponseT parseResponseBody( + String responseBody, HttpResponseParser responseParser) { + InputStream stream = new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); + return responseParser.parse(stream); + } + + @Nullable + private static String getUploadStatus(HttpJsonMetadata responseHeaders) { + return HttpHeadersUtils.getSingleHeader(responseHeaders.getHeaders(), UPLOAD_STATUS_HEADER); + } + + private static boolean isUploadFinal(HttpJsonMetadata responseHeaders) { + return STATUS_FINAL.equalsIgnoreCase(getUploadStatus(responseHeaders)); + } + @Nullable private static Long parseSizeReceived(HttpJsonMetadata responseHeaders) { String sizeReceivedStr = @@ -257,6 +345,15 @@ private static Long parseSizeReceived(HttpJsonMetadata responseHeaders) { return null; } + private static Throwable createStatusException( + int statusCode, HttpJsonMetadata trailers, String actionMessage) { + Throwable cause = trailers.getException(); + return cause != null + ? cause + : new HttpJsonStatusRuntimeException( + statusCode, actionMessage + " with status code: " + statusCode, null); + } + /** * An {@link ApiFuture} that cancels the underlying {@link HttpJsonClientCall} to prevent * connection leaks. @@ -398,16 +495,11 @@ private static class ChunkUploadResponseListener @Override public void onHeaders(HttpJsonMetadata responseHeaders) { - Map headers = responseHeaders.getHeaders(); - - String statusStr = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER); + String statusStr = getUploadStatus(responseHeaders); if (statusStr != null) { this.hasUploadStatusHeader = true; - if (STATUS_FINAL.equalsIgnoreCase(statusStr)) { - this.isComplete = true; - } + this.isComplete = STATUS_FINAL.equalsIgnoreCase(statusStr); } - this.committedOffset = parseSizeReceived(responseHeaders); } @@ -435,20 +527,70 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) { committedOffset != null ? committedOffset : request.getOffset() + request.getPayload().size(); - ResponseT response = null; - if (isComplete) { - InputStream stream = - new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8)); - response = responseParser.parse(stream); - } + ResponseT response = isComplete ? parseResponseBody(responseBody, responseParser) : null; future.set(ChunkUploadResponse.create(confirmedOffset, isComplete, response)); } else { - Throwable cause = trailers.getException(); future.setException( - cause != null - ? cause - : new HttpJsonStatusRuntimeException( - statusCode, "Failed to upload chunk with status code: " + statusCode, null)); + createStatusException(statusCode, trailers, "Failed to upload chunk")); + } + } catch (Throwable t) { + future.setException(t); + } + } + } + + /** A listener that parses query response headers to produce the {@link QueryStatusResponse}. */ + private static class QueryStatusResponseListener + extends HttpJsonClientCall.Listener { + + private final HttpJsonCallFuture> future; + private final HttpResponseParser responseParser; + private boolean isComplete = false; + @Nullable private Long committedOffset = null; + private String responseBody = ""; + + QueryStatusResponseListener( + HttpJsonCallFuture> future, + HttpResponseParser responseParser) { + this.future = future; + this.responseParser = responseParser; + } + + @Override + public void onHeaders(HttpJsonMetadata responseHeaders) { + this.isComplete = isUploadFinal(responseHeaders); + this.committedOffset = parseSizeReceived(responseHeaders); + } + + @Override + public void onMessage(@Nullable String message) { + if (message != null) { + this.responseBody = message; + } + } + + @Override + public void onClose(int statusCode, HttpJsonMetadata trailers) { + try { + if (statusCode >= 200 && statusCode < 300) { + if (isComplete || committedOffset != null) { + ResponseT response = + isComplete ? parseResponseBody(responseBody, responseParser) : null; + future.set( + QueryStatusResponse.create( + committedOffset != null ? committedOffset : 0L, isComplete, response)); + } else { + future.setException( + ApiExceptionFactory.createException( + "Query status response did not contain valid X-Goog-Upload-Size-Received" + + " header", + /* cause= */ null, + HttpJsonStatusCode.of(StatusCode.Code.INTERNAL), + /* retryable= */ false)); + } + } else { + future.setException( + createStatusException(statusCode, trailers, "Failed to query upload status")); } } catch (Throwable t) { future.setException(t); diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java index 81ab8d84aa3a..7cc0c50b1469 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java @@ -42,6 +42,8 @@ import com.google.api.core.InternalApi; import com.google.api.gax.resumable.ChunkUploadRequest; import com.google.api.gax.resumable.ChunkUploadResponse; +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.rpc.AbortedException; import com.google.api.gax.rpc.ApiCallContext; @@ -469,6 +471,151 @@ void uploadChunk_serverReturnsFinalStatusOnNon200_marksExceptionNonRetryable() { } } + @Nested + class QueryStatus { + + @Test + void queryStatus_activeUpload_returnsCommittedOffset() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + httpResponse.addHeader("X-Goog-Upload-Size-Received", "524288"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + QueryStatusRequest request = QueryStatusRequest.create(TEST_UPLOAD_URL); + + QueryStatusResponse response = client.queryStatusCallable().call(request); + + assertThat(response.isComplete()).isFalse(); + assertThat(response.getCommittedOffset()).isEqualTo(524288L); + assertThat(response.getResponse()).isNull(); + + assertThat(transport.capturedHeaders.get("x-goog-upload-command")).containsExactly("query"); + } + + @Test + void queryStatus_finalUpload_returnsCompleteAndResponseBody() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "final"); + httpResponse.addHeader("X-Goog-Upload-Size-Received", "1048576"); + httpResponse.setContent("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + QueryStatusRequest request = QueryStatusRequest.create(TEST_UPLOAD_URL); + + QueryStatusResponse response = client.queryStatusCallable().call(request); + + assertThat(response.isComplete()).isTrue(); + assertThat(response.getCommittedOffset()).isEqualTo(1048576L); + assertThat(response.getResponse()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + } + + @Test + void queryStatus_finalUploadWithoutSizeReceivedHeader_returnsCompleteAndResponseBody() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "final"); + httpResponse.setContent("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + QueryStatusRequest request = QueryStatusRequest.create(TEST_UPLOAD_URL); + + QueryStatusResponse response = client.queryStatusCallable().call(request); + + assertThat(response.isComplete()).isTrue(); + assertThat(response.getCommittedOffset()).isEqualTo(0L); + assertThat(response.getResponse()) + .isEqualTo("{\"name\":\"uploaded-file.txt\",\"size\":1048576}"); + } + + @Test + void queryStatus_withCustomExtraHeaders_preservesHeaders() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(200); + httpResponse.addHeader("X-Goog-Upload-Status", "active"); + httpResponse.addHeader("X-Goog-Upload-Size-Received", "256"); + + CapturingHttpTransport transport = new CapturingHttpTransport(httpResponse); + HttpJsonResumableUploadClient client = createClient(transport); + QueryStatusRequest request = QueryStatusRequest.create(TEST_UPLOAD_URL); + + Map> customHeaders = + Collections.singletonMap( + "X-Custom-Query-Header", Collections.singletonList("CustomQueryValue")); + + ApiCallContext callContext = + HttpJsonCallContext.createDefault().withExtraHeaders(customHeaders); + + client.queryStatusCallable().call(request, callContext); + + assertThat(transport.capturedHeaders.get("x-custom-query-header")) + .containsExactly("CustomQueryValue"); + } + + @Test + void queryStatus_serverReturnsError_throwsApiException() { + MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse(); + httpResponse.setStatusCode(404); + httpResponse.setContent("{\"error\":{\"message\":\"Session not found\"}}"); + + HttpJsonResumableUploadClient client = createClient(httpResponse); + QueryStatusRequest request = + QueryStatusRequest.create("https://test.googleapis.com/upload/session/invalid"); + + ExecutionException exception = + assertThrows( + ExecutionException.class, + () -> client.queryStatusCallable().futureCall(request).get()); + + assertThat(exception.getCause()).isInstanceOf(NotFoundException.class); + NotFoundException notFoundException = (NotFoundException) exception.getCause(); + assertThat(notFoundException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.NOT_FOUND); + } + + @Test + void queryStatus_missingOrMalformedSizeReceivedHeader_throwsException() { + QueryStatusRequest request = QueryStatusRequest.create(TEST_UPLOAD_URL); + + // Missing header + MockLowLevelHttpResponse missingHeaderResponse = new MockLowLevelHttpResponse(); + missingHeaderResponse.setStatusCode(200); + missingHeaderResponse.addHeader("X-Goog-Upload-Status", "active"); + + HttpJsonResumableUploadClient missingClient = + createClient(missingHeaderResponse); + ExecutionException missingException = + assertThrows( + ExecutionException.class, + () -> missingClient.queryStatusCallable().futureCall(request).get()); + assertThat(missingException.getCause()).isInstanceOf(InternalException.class); + assertThat(missingException.getCause()) + .hasMessageThat() + .contains( + "Query status response did not contain valid X-Goog-Upload-Size-Received header"); + + // Malformed header + MockLowLevelHttpResponse malformedHeaderResponse = new MockLowLevelHttpResponse(); + malformedHeaderResponse.setStatusCode(200); + malformedHeaderResponse.addHeader("X-Goog-Upload-Status", "active"); + malformedHeaderResponse.addHeader("X-Goog-Upload-Size-Received", "not-a-number"); + + HttpJsonResumableUploadClient malformedClient = + createClient(malformedHeaderResponse); + ExecutionException malformedException = + assertThrows( + ExecutionException.class, + () -> malformedClient.queryStatusCallable().futureCall(request).get()); + assertThat(malformedException.getCause()).isInstanceOf(InternalException.class); + assertThat(malformedException.getCause()) + .hasMessageThat() + .contains( + "Query status response did not contain valid X-Goog-Upload-Size-Received header"); + } + } + private static HttpJsonResumableUploadClient createClient( HttpTransport transport) { ManagedHttpJsonChannel channel = diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusRequest.java new file mode 100644 index 000000000000..9acda075793a --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusRequest.java @@ -0,0 +1,48 @@ +/* + * 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.InternalApi; +import com.google.auto.value.AutoValue; +import org.jspecify.annotations.NullMarked; + +/** Request value object for querying the status of an active resumable upload session. */ +@NullMarked +@InternalApi +@AutoValue +public abstract class QueryStatusRequest { + + /** Returns the upload session URL to query. */ + public abstract String getUploadUrl(); + + public static QueryStatusRequest create(String uploadUrl) { + return new AutoValue_QueryStatusRequest(uploadUrl); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusResponse.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusResponse.java new file mode 100644 index 000000000000..6ec3e5fdc508 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/QueryStatusResponse.java @@ -0,0 +1,67 @@ +/* + * 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.InternalApi; +import com.google.auto.value.AutoValue; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Response value object representing the status and committed offset of a resumable upload session. + * + * @param response type of the upload operation + */ +@NullMarked +@InternalApi +@AutoValue +public abstract class QueryStatusResponse { + + /** + * The total number of bytes successfully received and committed by the server so far. + * + *

This value is the starting offset for resuming the upload. + */ + public abstract 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(); + + public static QueryStatusResponse create( + long committedOffset, boolean isComplete, @Nullable ResponseT response) { + return new AutoValue_QueryStatusResponse<>(committedOffset, isComplete, response); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java index d867996344bb..774e9c391aba 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ResumableUploadClient.java @@ -48,4 +48,7 @@ public interface ResumableUploadClient { /** Returns a {@link UnaryCallable} to transmit an individual chunk. */ UnaryCallable> uploadChunkCallable(); + + /** Returns a {@link UnaryCallable} to query the status and offset of an active upload session. */ + UnaryCallable> queryStatusCallable(); }