Repository navigation
feat(gax): add helpers to detect and surface unsupported resumable upload configurations on failure #14652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(gax): add helpers to detect and surface unsupported resumable upload configurations on failure #14652
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| /* | ||
| * 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.rpc; | ||
|
|
||
| import com.google.api.core.BetaApi; | ||
| import com.google.api.core.InternalApi; | ||
| import com.google.common.annotations.VisibleForTesting; | ||
| import java.io.IOException; | ||
| import org.jspecify.annotations.NullMarked; | ||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
| /** | ||
| * Helpers for bridging a stub's transport configuration to the HTTP/JSON {@link ClientContext} used | ||
| * for resumable uploads. Designed for use by generated code. | ||
| */ | ||
| @BetaApi | ||
| @InternalApi | ||
| @NullMarked | ||
| public final class ResumableUploadTransportBridge { | ||
|
|
||
| private static final ApiCallContext.Key<Boolean> NON_SETTINGS_TRANSPORT_CUSTOMIZATION_KEY = | ||
| ApiCallContext.Key.create("resumable_upload_non_settings_transport_customization"); | ||
|
|
||
| private static final String CLIENT_CONTEXT_FAILURE_MESSAGE = | ||
| "Failed to initialize the HTTP/JSON client context required for resumable uploads." | ||
| + " Resumable uploads run over HTTP/JSON using only the endpoint, credentials, and" | ||
| + " headers configured on the service Settings; create the client or stub from Settings" | ||
| + " rather than a ClientContext."; | ||
|
|
||
| static final String NON_SETTINGS_TRANSPORT_CUSTOMIZATION_HINT = | ||
| "This client configures the endpoint, credentials, or headers outside the service Settings" | ||
| + " (on the TransportChannelProvider or via a pre-built channel). Resumable uploads do" | ||
| + " not use the TransportChannelProvider or pre-built channel; configure the endpoint," | ||
| + " credentials, and headers on the Settings instead."; | ||
|
|
||
| private ResumableUploadTransportBridge() {} | ||
|
|
||
| /** | ||
| * Creates the HTTP/JSON {@link ClientContext} used for resumable uploads. If {@code settings} | ||
| * already uses an HTTP/JSON transport, the context is created from {@code settings} unchanged. | ||
| * Otherwise the transport channel provider and internal headers are replaced with the given | ||
| * HTTP/JSON equivalents, and if the original transport channel provider was customized outside | ||
| * the settings, the context is marked so that upload failures can surface an actionable hint. | ||
| */ | ||
| public static ClientContext createUploadClientContext( | ||
| StubSettings<?> settings, | ||
| TransportChannelProvider httpJsonChannelProvider, | ||
| HeaderProvider httpJsonInternalHeaderProvider) | ||
| throws IOException { | ||
| TransportChannelProvider original = settings.getTransportChannelProvider(); | ||
| if (original.getTransportName().equals(httpJsonChannelProvider.getTransportName())) { | ||
| return ClientContext.create(settings); | ||
| } | ||
| StubSettings.Builder<?, ?> builder = settings.toBuilder(); | ||
| builder.setInternalHeaderProvider(httpJsonInternalHeaderProvider); | ||
| builder.setTransportChannelProvider(httpJsonChannelProvider); | ||
| ClientContext uploadContext = ClientContext.create(builder.build()); | ||
| if (!isCustomizedOutsideSettings(original)) { | ||
| return uploadContext; | ||
| } | ||
| return markNonSettingsTransportCustomization(uploadContext); | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| static ClientContext markNonSettingsTransportCustomization(ClientContext uploadContext) { | ||
| return uploadContext.toBuilder() | ||
| .setDefaultCallContext( | ||
| uploadContext | ||
| .getDefaultCallContext() | ||
| .withOption(NON_SETTINGS_TRANSPORT_CUSTOMIZATION_KEY, true)) | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * Creates a {@link ResumableUploadCallable} that synchronously throws an {@link | ||
| * IllegalStateException} with the given cause when invoked because the HTTP/JSON client context | ||
| * required for resumable uploads could not be created. | ||
| */ | ||
| public static <RequestT, ResponseT> ResumableUploadCallable<RequestT, ResponseT> failingCallable( | ||
|
Check warning on line 106 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java
|
||
| Throwable cause) { | ||
| return new FailingResumableUploadCallable<>(cause); | ||
| } | ||
|
|
||
| /** | ||
| * Returns {@code true} if {@code callContext} was marked by {@link #createUploadClientContext} | ||
| * because the original transport channel provider was customized outside the stub's settings. | ||
| */ | ||
| static boolean hasNonSettingsTransportCustomization(ApiCallContext callContext) { | ||
| return Boolean.TRUE.equals(callContext.getOption(NON_SETTINGS_TRANSPORT_CUSTOMIZATION_KEY)); | ||
| } | ||
|
|
||
| /** | ||
| * Returns {@code true} if {@code provider} does not need the endpoint, credentials, or headers | ||
| * from the stub's settings, meaning they were configured on the provider directly or the provider | ||
| * wraps a pre-built channel. | ||
| */ | ||
| private static boolean isCustomizedOutsideSettings(TransportChannelProvider provider) { | ||
| return !provider.needsHeaders() || !provider.needsCredentials() || !provider.needsEndpoint(); | ||
| } | ||
|
|
||
| private static final class FailingResumableUploadCallable<RequestT, ResponseT> | ||
|
Check warning on line 128 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java
|
||
| extends ResumableUploadCallable<RequestT, ResponseT> { | ||
|
|
||
| private final Throwable cause; | ||
|
|
||
| private FailingResumableUploadCallable(Throwable cause) { | ||
| this.cause = cause; | ||
| } | ||
|
|
||
| @Override | ||
| public ResumableUploadFuture<ResponseT> futureCall( | ||
| RequestT request, | ||
| InputStreamSupplier payload, | ||
| @Nullable ApiCallContext context, | ||
| @Nullable ResumableUploadOptions options) { | ||
| throw new IllegalStateException(CLIENT_CONTEXT_FAILURE_MESSAGE, cause); | ||
| } | ||
|
|
||
| @Override | ||
| public ResumableUploadFuture<ResponseT> resumeCall( | ||
| String sessionUrl, InputStreamSupplier payload, @Nullable ResumableUploadOptions options) { | ||
| throw new IllegalStateException(CLIENT_CONTEXT_FAILURE_MESSAGE, cause); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| /* | ||
| * 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.rpc; | ||
|
|
||
| import static com.google.common.truth.Truth.assertThat; | ||
| import static org.junit.jupiter.api.Assertions.assertThrows; | ||
| import static org.mockito.Mockito.mock; | ||
| import static org.mockito.Mockito.when; | ||
| import static org.mockito.Mockito.withSettings; | ||
|
|
||
| import com.google.api.gax.core.NoCredentialsProvider; | ||
| import com.google.api.gax.rpc.testing.FakeChannel; | ||
| import com.google.api.gax.rpc.testing.FakeStubSettings; | ||
| import com.google.api.gax.rpc.testing.FakeTransportChannel; | ||
| import java.io.ByteArrayInputStream; | ||
| import java.io.IOException; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| class ResumableUploadTransportBridgeTest { | ||
|
|
||
| private static final HeaderProvider HTTP_JSON_INTERNAL_HEADERS = | ||
| FixedHeaderProvider.create("x-goog-api-client", "httpjson-token"); | ||
|
|
||
| @Test | ||
| void testCreateUploadClientContext_sameTransport() throws IOException { | ||
| FakeTransportChannel originalChannel = FakeTransportChannel.create(new FakeChannel()); | ||
| StubSettings<?> settings = | ||
| FakeStubSettings.newBuilder() | ||
| .setCredentialsProvider(NoCredentialsProvider.create()) | ||
| .setTransportChannelProvider(FixedTransportChannelProvider.create(originalChannel)) | ||
| .build(); | ||
|
|
||
| ClientContext context = | ||
| ResumableUploadTransportBridge.createUploadClientContext( | ||
| settings, | ||
| FixedTransportChannelProvider.create(FakeTransportChannel.create(new FakeChannel())), | ||
| HTTP_JSON_INTERNAL_HEADERS); | ||
|
|
||
| assertThat(context.getTransportChannel()).isSameInstanceAs(originalChannel); | ||
| assertThat( | ||
| ResumableUploadTransportBridge.hasNonSettingsTransportCustomization( | ||
| context.getDefaultCallContext())) | ||
| .isFalse(); | ||
| } | ||
|
|
||
| @Test | ||
| void testCreateUploadClientContext_differentTransport() throws IOException { | ||
| TransportChannelProvider originalProvider = | ||
| mock(TransportChannelProvider.class, withSettings().withoutAnnotations()); | ||
| when(originalProvider.getTransportName()).thenReturn("grpc"); | ||
| StubSettings<?> settings = | ||
| FakeStubSettings.newBuilder() | ||
| .setCredentialsProvider(NoCredentialsProvider.create()) | ||
| .setTransportChannelProvider(originalProvider) | ||
| .build(); | ||
| FakeTransportChannel httpJsonChannel = FakeTransportChannel.create(new FakeChannel()); | ||
|
|
||
| ClientContext context = | ||
| ResumableUploadTransportBridge.createUploadClientContext( | ||
| settings, | ||
| FixedTransportChannelProvider.create(httpJsonChannel), | ||
| HTTP_JSON_INTERNAL_HEADERS); | ||
|
|
||
| assertThat(context.getTransportChannel()).isSameInstanceAs(httpJsonChannel); | ||
| assertThat(context.getInternalHeaders()).containsEntry("x-goog-api-client", "httpjson-token"); | ||
| assertThat( | ||
| ResumableUploadTransportBridge.hasNonSettingsTransportCustomization( | ||
| context.getDefaultCallContext())) | ||
| .isTrue(); | ||
| } | ||
|
|
||
| @Test | ||
| void testCreateUploadClientContext_differentTransport_uncustomized() throws IOException { | ||
| TransportChannelProvider originalProvider = | ||
| mock(TransportChannelProvider.class, withSettings().withoutAnnotations()); | ||
| when(originalProvider.getTransportName()).thenReturn("grpc"); | ||
| when(originalProvider.needsHeaders()).thenReturn(true); | ||
| when(originalProvider.needsCredentials()).thenReturn(true); | ||
| when(originalProvider.needsEndpoint()).thenReturn(true); | ||
| StubSettings<?> settings = | ||
| FakeStubSettings.newBuilder() | ||
| .setCredentialsProvider(NoCredentialsProvider.create()) | ||
| .setTransportChannelProvider(originalProvider) | ||
| .build(); | ||
|
|
||
| ClientContext context = | ||
| ResumableUploadTransportBridge.createUploadClientContext( | ||
| settings, | ||
| FixedTransportChannelProvider.create(FakeTransportChannel.create(new FakeChannel())), | ||
| HTTP_JSON_INTERNAL_HEADERS); | ||
|
|
||
| assertThat( | ||
| ResumableUploadTransportBridge.hasNonSettingsTransportCustomization( | ||
| context.getDefaultCallContext())) | ||
| .isFalse(); | ||
| } | ||
|
|
||
| @Test | ||
| void testFailingCallable() { | ||
| IOException cause = new IOException("no credentials"); | ||
| ResumableUploadCallable<String, String> callable = | ||
| ResumableUploadTransportBridge.failingCallable(cause); | ||
| InputStreamSupplier payload = () -> new ByteArrayInputStream(new byte[0]); | ||
|
|
||
| IllegalStateException futureCallException = | ||
| assertThrows( | ||
| IllegalStateException.class, () -> callable.futureCall("request", payload, null, null)); | ||
| assertThat(futureCallException).hasCauseThat().isSameInstanceAs(cause); | ||
| IllegalStateException resumeCallException = | ||
| assertThrows( | ||
| IllegalStateException.class, | ||
| () -> callable.resumeCall("https://example.com/session", payload, null)); | ||
| assertThat(resumeCallException).hasCauseThat().isSameInstanceAs(cause); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flagging the use of
ApiCallContext.withOptionhere to make the customization status available toResumableUploadFutureImpl.toTerminalExceptionat call time.Passing this explicitly would mean modifying public surface on three layers (
HttpJsonCallableFactory→Callables→ResumableUploadCallableImpl). This is set once on the upload context's default call context, and only as a boolean when the provider was customized outside Settings. I think it's a reasonable use ofwithOptiongiven its original intent (gax-java #1413, gax-java #1435), but LMK if you'd prefer a different approach.