From 80d36d4c4cbfa4acaf7481d9fe1d3105d67d3d17 Mon Sep 17 00:00:00 2001 From: whowes Date: Mon, 12 Oct 2026 20:17:41 +0000 Subject: [PATCH] feat(gax): detect and defer failure on unsupported resumable upload transport configs --- .../gax/rpc/ResumableUploadFutureImpl.java | 16 +- .../rpc/ResumableUploadTransportBridge.java | 152 ++++++++++++++++++ .../rpc/ResumableUploadCallableImplTest.java | 20 +++ .../ResumableUploadTransportBridgeTest.java | 142 ++++++++++++++++ 4 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadTransportBridgeTest.java diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java index d7a23f865a32..3874addf4932 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java @@ -251,14 +251,24 @@ private void fail(Throwable t) { } private ApiException toTerminalException(Throwable t) { - if (t instanceof ApiException && uploadSessionUrl == null) { + boolean hasNonSettingsTransportCustomization = + ResumableUploadTransportBridge.hasNonSettingsTransportCustomization(callContext); + if (t instanceof ApiException + && uploadSessionUrl == null + && !hasNonSettingsTransportCustomization) { return (ApiException) t; } String message = firstNonNull(t.getMessage(), t.getClass().getSimpleName()); if (uploadSessionUrl != null) { - message = message + " (upload URL: " + uploadSessionUrl + ")"; + message = String.format("%s (upload URL: %s)", message, uploadSessionUrl); } - // Rethrow with URL included in message. + if (hasNonSettingsTransportCustomization) { + message = + String.format( + "%s %s", + message, ResumableUploadTransportBridge.NON_SETTINGS_TRANSPORT_CUSTOMIZATION_HINT); + } + // Rethrow with URL and hint included in message. if (t instanceof ApiException) { ApiException apiException = (ApiException) t; return ApiExceptionFactory.createException( diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java new file mode 100644 index 000000000000..be473f3231e7 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java @@ -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 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 ResumableUploadCallable failingCallable( + 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 + extends ResumableUploadCallable { + + private final Throwable cause; + + private FailingResumableUploadCallable(Throwable cause) { + this.cause = cause; + } + + @Override + public ResumableUploadFuture futureCall( + RequestT request, + InputStreamSupplier payload, + @Nullable ApiCallContext context, + @Nullable ResumableUploadOptions options) { + throw new IllegalStateException(CLIENT_CONTEXT_FAILURE_MESSAGE, cause); + } + + @Override + public ResumableUploadFuture resumeCall( + String sessionUrl, InputStreamSupplier payload, @Nullable ResumableUploadOptions options) { + throw new IllegalStateException(CLIENT_CONTEXT_FAILURE_MESSAGE, cause); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java index f03c94bb0e64..51c4a53797d7 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java @@ -1303,6 +1303,26 @@ void testActionableErrors_preservesErrorDetailsAndCauseChain() { assertThat(future.getUploadSessionUrl()).isEqualTo(sessionUrl); } + @Test + void testActionableErrors_nonSettingsTransportCustomization_appendsHint() { + ApiException startError = createApiException(401, StatusCode.Code.UNAUTHENTICATED); + when(mockStartCallable.futureCall(any(), any())) + .thenReturn(ApiFutures.immediateFailedFuture(startError)); + ResumableUploadCallableImpl customizedCallable = + new ResumableUploadCallableImpl<>( + mockClient, + defaultSettings, + ResumableUploadTransportBridge.markNonSettingsTransportCustomization(clientContext)); + + ResumableUploadFuture future = + customizedCallable.futureCall("resource-path", payloadOf("hello"), null); + + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertThat(ex).hasCauseThat().isInstanceOf(UnauthenticatedException.class); + assertThat(ex).hasCauseThat().hasMessageThat().startsWith(startError.getMessage()); + assertThat(ex).hasCauseThat().hasMessageThat().contains("outside the service Settings"); + } + @Test void testUploadCallable_failureOutcome_attachesCloseExceptionViaAddSuppressed() { when(mockStartCallable.futureCall(any(), any())) diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadTransportBridgeTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadTransportBridgeTest.java new file mode 100644 index 000000000000..325fd1984d1d --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadTransportBridgeTest.java @@ -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 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); + } +}