Skip to content
Open
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 @@ -251,14 +251,24 @@
}

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(

Check warning on line 267 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Use string concatenation instead of String.format().

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaFBg77l-IrOSkEQ5o5H&open=AaFBg77l-IrOSkEQ5o5H&pullRequest=14652
"%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(
Expand Down
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))

Copy link
Copy Markdown
Contributor Author

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.withOption here to make the customization status available to ResumableUploadFutureImpl.toTerminalException at 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 of withOption given its original intent (gax-java #1413, gax-java #1435), but LMK if you'd prefer a different approach.

.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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaFBg71l-IrOSkEQ5o5D&open=AaFBg71l-IrOSkEQ5o5D&pullRequest=14652

Check warning on line 106 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaFBg71l-IrOSkEQ5o5E&open=AaFBg71l-IrOSkEQ5o5E&pullRequest=14652
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

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaFBg71l-IrOSkEQ5o5F&open=AaFBg71l-IrOSkEQ5o5F&pullRequest=14652

Check warning on line 128 in sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadTransportBridge.java

View check run for this annotation

SonarQubeCloud / [gapic-generator-java-root] SonarCloud Code Analysis

Rename this generic name to match the regular expression '^[A-Z][0-9]?$'.

See more on https://sonarcloud.io/project/issues?id=googleapis_google-cloud-java_showcase&issues=AaFBg71l-IrOSkEQ5o5G&open=AaFBg71l-IrOSkEQ5o5G&pullRequest=14652
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
Expand Up @@ -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<String, String> customizedCallable =
new ResumableUploadCallableImpl<>(
mockClient,
defaultSettings,
ResumableUploadTransportBridge.markNonSettingsTransportCustomization(clientContext));

ResumableUploadFuture<String> 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()))
Expand Down
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);
}
}
Loading