Skip to content

Commit d8fd094

Browse files
committed
feat(gax): add resumable upload error classification and retry algorithm
Introduce UploadCommand, UploadErrorClassifier, and UploadResultRetryAlgorithm to classify HTTP response codes and transport-level exceptions during resumable upload sessions into protocol error categories (TRANSIENT, RECOVERABLE, FATAL). Implements the classification order: 1. CancellationException is terminal (FATAL) and never retried. 2. ApiException with StatusCode.Code.UNKNOWN unwraps cause. GAX wraps unrecognized runtime throwables into Code.UNKNOWN, which carries a synthetic HTTP 500 transport code. Without this explicit step, local bugs and NPEs would be misclassified as transient 500s and retried indefinitely. Real wire 500 responses arrive with Code.INTERNAL and are TRANSIENT. 3. Table lookup on raw HTTP transport code (408, 429, 500, 502, 503, 504 are TRANSIENT; 400, 409, 412, 416 are RECOVERABLE; 401, 403, 404, 405, 410, 413, 415 are FATAL). Note that wire 408 and 412 both map to FAILED_PRECONDITION under HttpJsonStatusCode, but diverge based on raw HTTP transport code. 4. Plain I/O or timeout exceptions that bypassed ApiException wrapping are TRANSIENT; anything else unrecognized is FATAL.
1 parent e1c9326 commit d8fd094

7 files changed

Lines changed: 746 additions & 0 deletions

File tree

‎sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactoryTest.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,16 @@ void testCreate_withoutErrorDetails() {
148148
assertThat(apiException.getErrorDetails()).isNotNull();
149149
assertThat(apiException.getErrorDetails().getErrorInfo()).isNull();
150150
}
151+
152+
@Test
153+
void testCreate_fromHttpJsonStatusRuntimeException() {
154+
HttpJsonStatusRuntimeException statusException =
155+
new HttpJsonStatusRuntimeException(503, "Failed to upload chunk", null);
156+
HttpJsonApiExceptionFactory factory =
157+
new HttpJsonApiExceptionFactory(ImmutableSet.of(Code.UNAVAILABLE));
158+
ApiException apiException = factory.create(statusException);
159+
160+
assertThat(apiException.getStatusCode().getTransportCode()).isEqualTo(503);
161+
assertThat(apiException.getStatusCode().getCode()).isEqualTo(Code.UNAVAILABLE);
162+
}
151163
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
* A ResumableUploadCallable is an API-transport-independent wrapper for the Resumable Upload
3939
* protocol. Operates directly on the request object and input stream payload.
4040
*
41+
* <p>Headers, retry settings, and timeouts configured on an {@link ApiCallContext} apply only to
42+
* the initial session initiation ({@code start}) request, and do not apply to subsequent chunk
43+
* upload or status query requests.
44+
*
4145
* @param <RequestT> the type of the initial request message that initiates the upload session
4246
* @param <ResponseT> the type of the final response message returned once the upload completes
4347
*/
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 org.jspecify.annotations.NullMarked;
33+
34+
/**
35+
* Resumable upload wire commands whose responses and errors are subject to protocol classification
36+
* and retries.
37+
*/
38+
@NullMarked
39+
enum UploadCommand {
40+
START,
41+
UPLOAD,
42+
FINALIZE,
43+
UPLOAD_FINALIZE,
44+
QUERY,
45+
CANCEL
46+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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.common.collect.ImmutableMap;
33+
import java.io.IOException;
34+
import java.util.concurrent.CancellationException;
35+
import org.jspecify.annotations.NullMarked;
36+
import org.jspecify.annotations.Nullable;
37+
38+
/**
39+
* Classifies exceptions encountered during resumable upload commands into protocol error
40+
* categories.
41+
*/
42+
@NullMarked
43+
final class UploadErrorClassifier {
44+
45+
enum Category {
46+
TRANSIENT,
47+
RECOVERABLE,
48+
FATAL
49+
}
50+
51+
private static final ImmutableMap<Integer, Category> HTTP_STATUS_MAP =
52+
ImmutableMap.<Integer, Category>builder()
53+
.put(408, Category.TRANSIENT)
54+
.put(429, Category.TRANSIENT)
55+
.put(500, Category.TRANSIENT)
56+
.put(502, Category.TRANSIENT)
57+
.put(503, Category.TRANSIENT)
58+
.put(504, Category.TRANSIENT)
59+
.put(400, Category.RECOVERABLE)
60+
.put(409, Category.RECOVERABLE)
61+
.put(412, Category.RECOVERABLE)
62+
.put(416, Category.RECOVERABLE)
63+
.put(401, Category.FATAL)
64+
.put(403, Category.FATAL)
65+
.put(404, Category.FATAL)
66+
.put(405, Category.FATAL)
67+
.put(410, Category.FATAL)
68+
.put(413, Category.FATAL)
69+
.put(415, Category.FATAL)
70+
.build();
71+
72+
private UploadErrorClassifier() {}
73+
74+
/**
75+
* Classifies an exception for the given upload command according to protocol rules.
76+
*
77+
* @param t the error to classify
78+
* @param command the upload command that produced the error
79+
* @return the classified error category
80+
*/
81+
static Category classify(@Nullable Throwable t, UploadCommand command) {
82+
if (t == null) {
83+
return Category.FATAL;
84+
}
85+
86+
// Cancellation is terminal and never retryable.
87+
if (t instanceof CancellationException) {
88+
return Category.FATAL;
89+
}
90+
91+
// Handle ApiExceptions.
92+
if (t instanceof ApiException) {
93+
ApiException apiException = (ApiException) t;
94+
95+
// Code.UNKNOWN indicates an unrecognised throwable where synthetic HTTP 500
96+
// should be ignored.
97+
// Real HTTP 500 responses arrive with Code.INTERNAL.
98+
if (apiException.getStatusCode().getCode() == StatusCode.Code.UNKNOWN) {
99+
Throwable cause = apiException.getCause();
100+
if (cause instanceof IOException) {
101+
return Category.TRANSIENT;
102+
}
103+
return Category.FATAL;
104+
}
105+
106+
// Server rejections (final/cancelled on non-2xx) and protocol violations map to
107+
// FAILED_PRECONDITION and are terminal. HTTP 408 and 412 also map to FAILED_PRECONDITION in
108+
// HttpJsonStatusCode but have distinct protocol categories.
109+
Object transportCode = apiException.getStatusCode().getTransportCode();
110+
if (apiException.getStatusCode().getCode() == StatusCode.Code.FAILED_PRECONDITION
111+
&& !Integer.valueOf(408).equals(transportCode)
112+
&& !Integer.valueOf(412).equals(transportCode)) {
113+
return Category.FATAL;
114+
}
115+
116+
// Status table lookup on raw HTTP transport code.
117+
if (transportCode instanceof Integer) {
118+
Category category = HTTP_STATUS_MAP.get(transportCode);
119+
if (category != null) {
120+
return category;
121+
}
122+
}
123+
return Category.FATAL;
124+
}
125+
126+
// Plain I/O or timeout exceptions that bypassed ApiException wrapping.
127+
if (t instanceof IOException) {
128+
return Category.TRANSIENT;
129+
}
130+
131+
// Unrecognized errors fail the upload immediately. Never default to retryable.
132+
return Category.FATAL;
133+
}
134+
135+
/**
136+
* Classifies a missing upload status response header according to the wire command.
137+
*
138+
* @param command the upload command that received a response lacking the status header
139+
* @return the classified error category
140+
*/
141+
static Category classifyMissingStatusHeader(UploadCommand command) {
142+
switch (command) {
143+
case UPLOAD:
144+
case FINALIZE:
145+
case UPLOAD_FINALIZE:
146+
return Category.RECOVERABLE;
147+
case QUERY:
148+
case CANCEL:
149+
default:
150+
return Category.FATAL;
151+
}
152+
}
153+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.gax.retrying.BasicResultRetryAlgorithm;
33+
import java.util.Objects;
34+
import java.util.concurrent.CancellationException;
35+
import org.jspecify.annotations.NullMarked;
36+
import org.jspecify.annotations.Nullable;
37+
38+
/**
39+
* An adapter that integrates {@link UploadErrorClassifier} into GAX retrying machinery.
40+
*
41+
* <p>Retries transient errors with the identical request.
42+
*
43+
* @param <ResponseT> the response type of the upload attempt
44+
*/
45+
@NullMarked
46+
final class UploadResultRetryAlgorithm<ResponseT> extends BasicResultRetryAlgorithm<ResponseT> {
47+
48+
private final UploadCommand command;
49+
50+
UploadResultRetryAlgorithm(UploadCommand command) {
51+
this.command = Objects.requireNonNull(command);
52+
}
53+
54+
@Override
55+
public boolean shouldRetry(
56+
@Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) {
57+
if (previousThrowable == null || previousThrowable instanceof CancellationException) {
58+
return false;
59+
}
60+
UploadErrorClassifier.Category category =
61+
UploadErrorClassifier.classify(previousThrowable, command);
62+
// Transient errors are retried directly with the identical request.
63+
return category == UploadErrorClassifier.Category.TRANSIENT;
64+
}
65+
}

0 commit comments

Comments
 (0)