Skip to content

Commit ebf651d

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 b1a0571 commit ebf651d

6 files changed

Lines changed: 751 additions & 0 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,17 @@ 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
}
164+
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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
// Status table lookup on raw HTTP transport code.
107+
Object transportCode = apiException.getStatusCode().getTransportCode();
108+
if (transportCode instanceof Integer) {
109+
Category category = HTTP_STATUS_MAP.get(transportCode);
110+
if (category != null) {
111+
// The START command cannot enter recovery since no upload session exists yet.
112+
if (command == UploadCommand.START && category == Category.RECOVERABLE) {
113+
return Category.FATAL;
114+
}
115+
return category;
116+
}
117+
}
118+
return Category.FATAL;
119+
}
120+
121+
// Plain I/O or timeout exceptions that bypassed ApiException wrapping.
122+
if (t instanceof IOException) {
123+
return Category.TRANSIENT;
124+
}
125+
126+
// Unrecognized errors fail the upload immediately. Never default to retryable.
127+
return Category.FATAL;
128+
}
129+
130+
/**
131+
* Classifies a missing upload status response header according to the wire command.
132+
*
133+
* @param command the upload command that received a response lacking the status header
134+
* @return the classified error category
135+
*/
136+
static Category classifyMissingStatusHeader(UploadCommand command) {
137+
switch (command) {
138+
case START:
139+
return Category.TRANSIENT;
140+
case UPLOAD:
141+
case FINALIZE:
142+
case UPLOAD_FINALIZE:
143+
return Category.RECOVERABLE;
144+
case QUERY:
145+
case CANCEL:
146+
default:
147+
return Category.FATAL;
148+
}
149+
}
150+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 com.google.api.gax.retrying.ResultRetryAlgorithmWithContext;
34+
import com.google.api.gax.retrying.RetryingContext;
35+
import java.util.Objects;
36+
import java.util.concurrent.CancellationException;
37+
import org.jspecify.annotations.NullMarked;
38+
import org.jspecify.annotations.Nullable;
39+
40+
/**
41+
* An adapter that integrates {@link UploadErrorClassifier} into GAX retrying machinery via
42+
* {@link ResultRetryAlgorithmWithContext}.
43+
*
44+
* <p>Retries transient errors with the identical request.
45+
*
46+
* @param <ResponseT> the response type of the upload attempt
47+
*/
48+
@NullMarked
49+
final class UploadResultRetryAlgorithm<ResponseT>
50+
extends BasicResultRetryAlgorithm<ResponseT> {
51+
52+
private final UploadCommand command;
53+
54+
UploadResultRetryAlgorithm(UploadCommand command) {
55+
this.command = Objects.requireNonNull(command);
56+
}
57+
58+
@Override
59+
public boolean shouldRetry(
60+
@Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) {
61+
if (previousThrowable == null || previousThrowable instanceof CancellationException) {
62+
return false;
63+
}
64+
UploadErrorCategory category = UploadErrorClassifier.classify(previousThrowable, command);
65+
// Transient errors are retried directly with the identical request.
66+
return category == UploadErrorCategory.TRANSIENT;
67+
}
68+
69+
@Override
70+
public boolean shouldRetry(
71+
RetryingContext context,
72+
@Nullable Throwable previousThrowable,
73+
@Nullable ResponseT previousResponse) {
74+
return shouldRetry(previousThrowable, previousResponse);
75+
}
76+
}

0 commit comments

Comments
 (0)