Skip to content

Commit a2b7fd6

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 ddf430f commit a2b7fd6

5 files changed

Lines changed: 502 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
/** Enumeration of the supported resumable upload wire commands. */
35+
@NullMarked
36+
enum ResumableUploadCommand {
37+
START,
38+
UPLOAD,
39+
FINALIZE,
40+
UPLOAD_FINALIZE,
41+
QUERY
42+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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.Objects;
35+
import org.jspecify.annotations.NullMarked;
36+
37+
/**
38+
* Classifies exceptions encountered during resumable upload commands to determine whether/how to
39+
* recover.
40+
*/
41+
@NullMarked
42+
final class ResumableUploadErrorClassifier {
43+
44+
enum Category {
45+
/** An errored command that can be retried directly. */
46+
TRANSIENT,
47+
48+
/** An errored command that requires status from the server before recovery. */
49+
RECOVERABLE,
50+
51+
/** An errored command that immediately fails the upload operation. */
52+
FATAL
53+
}
54+
55+
private static final ImmutableMap<Integer, Category> HTTP_STATUS_MAP =
56+
ImmutableMap.<Integer, Category>builder()
57+
.put(408, Category.TRANSIENT)
58+
.put(429, Category.TRANSIENT)
59+
.put(500, Category.TRANSIENT)
60+
.put(502, Category.TRANSIENT)
61+
.put(503, Category.TRANSIENT)
62+
.put(504, Category.TRANSIENT)
63+
.put(400, Category.RECOVERABLE)
64+
.put(409, Category.RECOVERABLE)
65+
.put(412, Category.RECOVERABLE)
66+
.put(416, Category.RECOVERABLE)
67+
.build();
68+
69+
private ResumableUploadErrorClassifier() {}
70+
71+
/**
72+
* Classifies an exception for the given upload command according to resumable upload protocol rules.
73+
*
74+
* @param t the error to classify
75+
* @param command the upload command that produced the error
76+
* @return the classified error category
77+
*/
78+
static Category classify(Throwable t, ResumableUploadCommand command) {
79+
Objects.requireNonNull(t, "t must not be null");
80+
Objects.requireNonNull(command, "command must not be null");
81+
82+
if (!(t instanceof ApiException)) {
83+
return Category.FATAL;
84+
}
85+
ApiException apiException = (ApiException) t;
86+
StatusCode statusCode = apiException.getStatusCode();
87+
88+
// HttpJsonApiExceptionFactory wraps non-HTTP errors as UNKNOWN.
89+
if (statusCode.getCode() == StatusCode.Code.UNKNOWN) {
90+
if (apiException.getCause() instanceof IOException) {
91+
return Category.TRANSIENT;
92+
}
93+
return Category.FATAL;
94+
}
95+
96+
Category category = HTTP_STATUS_MAP.getOrDefault(statusCode.getTransportCode(), Category.FATAL);
97+
if (category == Category.RECOVERABLE && !isRecoverableCommand(command)) {
98+
return Category.FATAL;
99+
}
100+
return category;
101+
}
102+
103+
/**
104+
* Classifies a missing upload status response header based on the wire command.
105+
*
106+
* @param command the upload command that received a response lacking the status header
107+
* @return the classified error category
108+
*/
109+
static Category classifyMissingStatusHeader(ResumableUploadCommand command) {
110+
Objects.requireNonNull(command, "command must not be null");
111+
switch (command) {
112+
case START:
113+
return Category.TRANSIENT;
114+
case UPLOAD:
115+
case FINALIZE:
116+
case UPLOAD_FINALIZE:
117+
return Category.RECOVERABLE;
118+
case QUERY:
119+
default:
120+
return Category.FATAL;
121+
}
122+
}
123+
124+
private static boolean isRecoverableCommand(ResumableUploadCommand command) {
125+
switch (command) {
126+
case UPLOAD:
127+
case FINALIZE:
128+
case UPLOAD_FINALIZE:
129+
return true;
130+
case START:
131+
case QUERY:
132+
default:
133+
return false;
134+
}
135+
}
136+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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.api.gax.rpc.ResumableUploadErrorClassifier.Category.TRANSIENT;
33+
34+
import com.google.api.gax.retrying.BasicResultRetryAlgorithm;
35+
import com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category;
36+
import java.util.Objects;
37+
import org.jspecify.annotations.NullMarked;
38+
import org.jspecify.annotations.Nullable;
39+
40+
/**
41+
* An adapter that integrates {@link ResumableUploadErrorClassifier} into GAX retrying machinery.
42+
*
43+
* <p>Only transient errors should retry with an identical request; other recoverable errors will
44+
* need to query the upload server to determine the appropriate next request.
45+
*
46+
* @param <ResponseT> the response type of the upload attempt
47+
*/
48+
@NullMarked
49+
final class ResumableUploadResultRetryAlgorithm<ResponseT>
50+
extends BasicResultRetryAlgorithm<ResponseT> {
51+
52+
private final ResumableUploadCommand command;
53+
54+
ResumableUploadResultRetryAlgorithm(ResumableUploadCommand command) {
55+
this.command = Objects.requireNonNull(command);
56+
}
57+
58+
@Override
59+
public boolean shouldRetry(
60+
@Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) {
61+
// Successful commands should not retry.
62+
if (previousThrowable == null) {
63+
return false;
64+
}
65+
// Transient errors are retried directly with the identical request, others are not.
66+
Category category = ResumableUploadErrorClassifier.classify(previousThrowable, command);
67+
return category == TRANSIENT;
68+
}
69+
}

0 commit comments

Comments
 (0)