Skip to content

Commit 85cef05

Browse files
committed
feat(gax): detect and defer failure on unsupported resumable upload transport configs
1 parent f1db2bb commit 85cef05

4 files changed

Lines changed: 327 additions & 3 deletions

File tree

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,14 +251,24 @@ private void fail(Throwable t) {
251251
}
252252

253253
private ApiException toTerminalException(Throwable t) {
254-
if (t instanceof ApiException && uploadSessionUrl == null) {
254+
boolean hasNonSettingsTransportCustomization =
255+
ResumableUploadTransportBridge.hasNonSettingsTransportCustomization(callContext);
256+
if (t instanceof ApiException
257+
&& uploadSessionUrl == null
258+
&& !hasNonSettingsTransportCustomization) {
255259
return (ApiException) t;
256260
}
257261
String message = firstNonNull(t.getMessage(), t.getClass().getSimpleName());
258262
if (uploadSessionUrl != null) {
259-
message = message + " (upload URL: " + uploadSessionUrl + ")";
263+
message = String.format("%s (upload URL: %s)", message, uploadSessionUrl);
260264
}
261-
// Rethrow with URL included in message.
265+
if (hasNonSettingsTransportCustomization) {
266+
message =
267+
String.format(
268+
"%s %s",
269+
message, ResumableUploadTransportBridge.NON_SETTINGS_TRANSPORT_CUSTOMIZATION_HINT);
270+
}
271+
// Rethrow with URL and hint included in message.
262272
if (t instanceof ApiException) {
263273
ApiException apiException = (ApiException) t;
264274
return ApiExceptionFactory.createException(
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.rpc;
31+
32+
import com.google.api.core.BetaApi;
33+
import com.google.api.core.InternalApi;
34+
import com.google.common.annotations.VisibleForTesting;
35+
import java.io.IOException;
36+
import org.jspecify.annotations.NullMarked;
37+
import org.jspecify.annotations.Nullable;
38+
39+
/**
40+
* Helpers for bridging a stub's transport configuration to the HTTP/JSON {@link ClientContext} used
41+
* for resumable uploads. Designed for use by generated code.
42+
*/
43+
@BetaApi
44+
@InternalApi
45+
@NullMarked
46+
public final class ResumableUploadTransportBridge {
47+
48+
private static final ApiCallContext.Key<Boolean> NON_SETTINGS_TRANSPORT_CUSTOMIZATION_KEY =
49+
ApiCallContext.Key.create("resumable_upload_non_settings_transport_customization");
50+
51+
private static final String CLIENT_CONTEXT_FAILURE_MESSAGE =
52+
"Failed to initialize the HTTP/JSON client context required for resumable uploads."
53+
+ " Resumable uploads run over HTTP/JSON using only the endpoint, credentials, and"
54+
+ " headers configured on the service Settings; create the client or stub from Settings"
55+
+ " rather than a ClientContext.";
56+
57+
static final String NON_SETTINGS_TRANSPORT_CUSTOMIZATION_HINT =
58+
"This client configures the endpoint, credentials, or headers outside the service Settings"
59+
+ " (on the TransportChannelProvider or via a pre-built channel). Resumable uploads do not"
60+
+ " use the TransportChannelProvider or pre-built channel; configure the endpoint,"
61+
+ " credentials, and headers on the Settings instead.";
62+
63+
private ResumableUploadTransportBridge() {}
64+
65+
/**
66+
* Creates the HTTP/JSON {@link ClientContext} used for resumable uploads. If {@code settings}
67+
* already uses an HTTP/JSON transport, the context is created from {@code settings} unchanged.
68+
* Otherwise the transport channel provider and internal headers are replaced with the given
69+
* HTTP/JSON equivalents, and if the original transport channel provider was customized outside
70+
* the settings, the context is marked so that upload failures can surface an actionable hint.
71+
*/
72+
public static ClientContext createUploadClientContext(
73+
StubSettings<?> settings,
74+
TransportChannelProvider httpJsonChannelProvider,
75+
HeaderProvider httpJsonInternalHeaderProvider)
76+
throws IOException {
77+
TransportChannelProvider original = settings.getTransportChannelProvider();
78+
if (original.getTransportName().equals(httpJsonChannelProvider.getTransportName())) {
79+
return ClientContext.create(settings);
80+
}
81+
StubSettings.Builder<?, ?> builder = settings.toBuilder();
82+
builder.setInternalHeaderProvider(httpJsonInternalHeaderProvider);
83+
builder.setTransportChannelProvider(httpJsonChannelProvider);
84+
ClientContext uploadContext = ClientContext.create(builder.build());
85+
if (!isCustomizedOutsideSettings(original)) {
86+
return uploadContext;
87+
}
88+
return markNonSettingsTransportCustomization(uploadContext);
89+
}
90+
91+
@VisibleForTesting
92+
static ClientContext markNonSettingsTransportCustomization(ClientContext uploadContext) {
93+
return uploadContext.toBuilder()
94+
.setDefaultCallContext(
95+
uploadContext
96+
.getDefaultCallContext()
97+
.withOption(NON_SETTINGS_TRANSPORT_CUSTOMIZATION_KEY, true))
98+
.build();
99+
}
100+
101+
/**
102+
* Creates a {@link ResumableUploadCallable} that synchronously throws an {@link
103+
* IllegalStateException} with the given cause when invoked because the HTTP/JSON client context
104+
* required for resumable uploads could not be created.
105+
*/
106+
public static <RequestT, ResponseT> ResumableUploadCallable<RequestT, ResponseT> failingCallable(
107+
Throwable cause) {
108+
return new FailingResumableUploadCallable<>(cause);
109+
}
110+
111+
/**
112+
* Returns {@code true} if {@code callContext} was marked by {@link #createUploadClientContext}
113+
* because the original transport channel provider was customized outside the stub's settings.
114+
*/
115+
static boolean hasNonSettingsTransportCustomization(ApiCallContext callContext) {
116+
return Boolean.TRUE.equals(callContext.getOption(NON_SETTINGS_TRANSPORT_CUSTOMIZATION_KEY));
117+
}
118+
119+
/**
120+
* Returns {@code true} if {@code provider} does not need the endpoint, credentials, or headers
121+
* from the stub's settings, meaning they were configured on the provider directly or the provider
122+
* wraps a pre-built channel.
123+
*/
124+
private static boolean isCustomizedOutsideSettings(TransportChannelProvider provider) {
125+
return !provider.needsHeaders() || !provider.needsCredentials() || !provider.needsEndpoint();
126+
}
127+
128+
private static final class FailingResumableUploadCallable<RequestT, ResponseT>
129+
extends ResumableUploadCallable<RequestT, ResponseT> {
130+
131+
private final Throwable cause;
132+
133+
private FailingResumableUploadCallable(Throwable cause) {
134+
this.cause = cause;
135+
}
136+
137+
@Override
138+
public ResumableUploadFuture<ResponseT> futureCall(
139+
RequestT request,
140+
InputStreamSupplier payload,
141+
@Nullable ApiCallContext context,
142+
@Nullable ResumableUploadOptions options) {
143+
throw new IllegalStateException(CLIENT_CONTEXT_FAILURE_MESSAGE, cause);
144+
}
145+
146+
@Override
147+
public ResumableUploadFuture<ResponseT> resumeCall(
148+
String sessionUrl, InputStreamSupplier payload, @Nullable ResumableUploadOptions options) {
149+
throw new IllegalStateException(CLIENT_CONTEXT_FAILURE_MESSAGE, cause);
150+
}
151+
}
152+
}

‎sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallableImplTest.java‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,26 @@ void testActionableErrors_preservesErrorDetailsAndCauseChain() {
13031303
assertThat(future.getUploadSessionUrl()).isEqualTo(sessionUrl);
13041304
}
13051305

1306+
@Test
1307+
void testActionableErrors_nonSettingsTransportCustomization_appendsHint() {
1308+
ApiException startError = createApiException(401, StatusCode.Code.UNAUTHENTICATED);
1309+
when(mockStartCallable.futureCall(any(), any()))
1310+
.thenReturn(ApiFutures.immediateFailedFuture(startError));
1311+
ResumableUploadCallableImpl<String, String> customizedCallable =
1312+
new ResumableUploadCallableImpl<>(
1313+
mockClient,
1314+
defaultSettings,
1315+
ResumableUploadTransportBridge.markNonSettingsTransportCustomization(clientContext));
1316+
1317+
ResumableUploadFuture<String> future =
1318+
customizedCallable.futureCall("resource-path", payloadOf("hello"), null);
1319+
1320+
ExecutionException ex = assertThrows(ExecutionException.class, future::get);
1321+
assertThat(ex).hasCauseThat().isInstanceOf(UnauthenticatedException.class);
1322+
assertThat(ex).hasCauseThat().hasMessageThat().startsWith(startError.getMessage());
1323+
assertThat(ex).hasCauseThat().hasMessageThat().contains("outside the service Settings");
1324+
}
1325+
13061326
@Test
13071327
void testUploadCallable_failureOutcome_attachesCloseExceptionViaAddSuppressed() {
13081328
when(mockStartCallable.futureCall(any(), any()))
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.rpc;
31+
32+
import static com.google.common.truth.Truth.assertThat;
33+
import static org.junit.jupiter.api.Assertions.assertThrows;
34+
import static org.mockito.Mockito.mock;
35+
import static org.mockito.Mockito.when;
36+
import static org.mockito.Mockito.withSettings;
37+
38+
import com.google.api.gax.core.NoCredentialsProvider;
39+
import com.google.api.gax.rpc.testing.FakeChannel;
40+
import com.google.api.gax.rpc.testing.FakeStubSettings;
41+
import com.google.api.gax.rpc.testing.FakeTransportChannel;
42+
import java.io.ByteArrayInputStream;
43+
import java.io.IOException;
44+
import org.junit.jupiter.api.Test;
45+
46+
class ResumableUploadTransportBridgeTest {
47+
48+
private static final HeaderProvider HTTP_JSON_INTERNAL_HEADERS =
49+
FixedHeaderProvider.create("x-goog-api-client", "httpjson-token");
50+
51+
@Test
52+
void testCreateUploadClientContext_sameTransport() throws IOException {
53+
FakeTransportChannel originalChannel = FakeTransportChannel.create(new FakeChannel());
54+
StubSettings<?> settings =
55+
FakeStubSettings.newBuilder()
56+
.setCredentialsProvider(NoCredentialsProvider.create())
57+
.setTransportChannelProvider(FixedTransportChannelProvider.create(originalChannel))
58+
.build();
59+
60+
ClientContext context =
61+
ResumableUploadTransportBridge.createUploadClientContext(
62+
settings,
63+
FixedTransportChannelProvider.create(FakeTransportChannel.create(new FakeChannel())),
64+
HTTP_JSON_INTERNAL_HEADERS);
65+
66+
assertThat(context.getTransportChannel()).isSameInstanceAs(originalChannel);
67+
assertThat(
68+
ResumableUploadTransportBridge.hasNonSettingsTransportCustomization(
69+
context.getDefaultCallContext()))
70+
.isFalse();
71+
}
72+
73+
@Test
74+
void testCreateUploadClientContext_differentTransport() throws IOException {
75+
TransportChannelProvider originalProvider =
76+
mock(TransportChannelProvider.class, withSettings().withoutAnnotations());
77+
when(originalProvider.getTransportName()).thenReturn("grpc");
78+
StubSettings<?> settings =
79+
FakeStubSettings.newBuilder()
80+
.setCredentialsProvider(NoCredentialsProvider.create())
81+
.setTransportChannelProvider(originalProvider)
82+
.build();
83+
FakeTransportChannel httpJsonChannel = FakeTransportChannel.create(new FakeChannel());
84+
85+
ClientContext context =
86+
ResumableUploadTransportBridge.createUploadClientContext(
87+
settings,
88+
FixedTransportChannelProvider.create(httpJsonChannel),
89+
HTTP_JSON_INTERNAL_HEADERS);
90+
91+
assertThat(context.getTransportChannel()).isSameInstanceAs(httpJsonChannel);
92+
assertThat(context.getInternalHeaders()).containsEntry("x-goog-api-client", "httpjson-token");
93+
assertThat(
94+
ResumableUploadTransportBridge.hasNonSettingsTransportCustomization(
95+
context.getDefaultCallContext()))
96+
.isTrue();
97+
}
98+
99+
@Test
100+
void testCreateUploadClientContext_differentTransport_uncustomized() throws IOException {
101+
TransportChannelProvider originalProvider =
102+
mock(TransportChannelProvider.class, withSettings().withoutAnnotations());
103+
when(originalProvider.getTransportName()).thenReturn("grpc");
104+
when(originalProvider.needsHeaders()).thenReturn(true);
105+
when(originalProvider.needsCredentials()).thenReturn(true);
106+
when(originalProvider.needsEndpoint()).thenReturn(true);
107+
StubSettings<?> settings =
108+
FakeStubSettings.newBuilder()
109+
.setCredentialsProvider(NoCredentialsProvider.create())
110+
.setTransportChannelProvider(originalProvider)
111+
.build();
112+
113+
ClientContext context =
114+
ResumableUploadTransportBridge.createUploadClientContext(
115+
settings,
116+
FixedTransportChannelProvider.create(FakeTransportChannel.create(new FakeChannel())),
117+
HTTP_JSON_INTERNAL_HEADERS);
118+
119+
assertThat(
120+
ResumableUploadTransportBridge.hasNonSettingsTransportCustomization(
121+
context.getDefaultCallContext()))
122+
.isFalse();
123+
}
124+
125+
@Test
126+
void testFailingCallable() {
127+
IOException cause = new IOException("no credentials");
128+
ResumableUploadCallable<String, String> callable =
129+
ResumableUploadTransportBridge.failingCallable(cause);
130+
InputStreamSupplier payload = () -> new ByteArrayInputStream(new byte[0]);
131+
132+
IllegalStateException futureCallException =
133+
assertThrows(
134+
IllegalStateException.class, () -> callable.futureCall("request", payload, null, null));
135+
assertThat(futureCallException).hasCauseThat().isSameInstanceAs(cause);
136+
IllegalStateException resumeCallException =
137+
assertThrows(
138+
IllegalStateException.class,
139+
() -> callable.resumeCall("https://example.com/session", payload, null));
140+
assertThat(resumeCallException).hasCauseThat().isSameInstanceAs(cause);
141+
}
142+
}

0 commit comments

Comments
 (0)