Skip to content

Commit b70396d

Browse files
authored
feat(gax): add resumable upload error classification and retry algorithm (#14419)
The intent is to centralize the logic that determines the appropriate action (fail fast, retry, chunk recovery) for the various types/codes of errors that a resumable upload operation might encounter. Since the requirements for which error should trigger which action during which operation are quite complex it's helpful for the logic to be consolidated in one place.
1 parent ffc745c commit b70396d

5 files changed

Lines changed: 484 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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(false),
38+
UPLOAD(true),
39+
FINALIZE(true),
40+
UPLOAD_FINALIZE(true),
41+
QUERY(false);
42+
43+
private final boolean isRecoverable;
44+
45+
ResumableUploadCommand(boolean isRecoverable) {
46+
this.isRecoverable = isRecoverable;
47+
}
48+
49+
boolean isRecoverable() {
50+
return isRecoverable;
51+
}
52+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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.net.SocketTimeoutException;
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
73+
* rules.
74+
*
75+
* @param t the error to classify
76+
* @param command the upload command that produced the error
77+
* @return the classified error category
78+
*/
79+
static Category classify(Throwable t, ResumableUploadCommand command) {
80+
Objects.requireNonNull(t, "t must not be null");
81+
Objects.requireNonNull(command, "command must not be null");
82+
83+
if (!(t instanceof ApiException)) {
84+
return Category.FATAL;
85+
}
86+
ApiException apiException = (ApiException) t;
87+
StatusCode statusCode = apiException.getStatusCode();
88+
89+
// HttpJsonApiExceptionFactory wraps low-level network timeouts as UNKNOWN.
90+
if (statusCode.getCode() == StatusCode.Code.UNKNOWN) {
91+
if (apiException.getCause() instanceof SocketTimeoutException) {
92+
return Category.TRANSIENT;
93+
}
94+
return Category.FATAL;
95+
}
96+
97+
Category category = HTTP_STATUS_MAP.getOrDefault(statusCode.getTransportCode(), Category.FATAL);
98+
if (category == Category.RECOVERABLE && !command.isRecoverable()) {
99+
return Category.FATAL;
100+
}
101+
return category;
102+
}
103+
}
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+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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.ResumableUploadCommand.FINALIZE;
33+
import static com.google.api.gax.rpc.ResumableUploadCommand.QUERY;
34+
import static com.google.api.gax.rpc.ResumableUploadCommand.START;
35+
import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD;
36+
import static com.google.api.gax.rpc.ResumableUploadCommand.UPLOAD_FINALIZE;
37+
import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.FATAL;
38+
import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.RECOVERABLE;
39+
import static com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category.TRANSIENT;
40+
import static com.google.common.truth.Truth.assertThat;
41+
import static org.junit.jupiter.api.Assertions.assertThrows;
42+
43+
import com.google.api.gax.rpc.StatusCode.Code;
44+
import java.io.IOException;
45+
import java.net.SocketException;
46+
import java.net.SocketTimeoutException;
47+
import java.net.UnknownHostException;
48+
import java.util.concurrent.CancellationException;
49+
import org.jspecify.annotations.Nullable;
50+
import org.junit.jupiter.api.Test;
51+
52+
class ResumableUploadErrorClassifierTest {
53+
54+
private static StatusCode statusCode(@Nullable Integer httpStatus, Code code) {
55+
return new StatusCode() {
56+
@Override
57+
public Code getCode() {
58+
return code;
59+
}
60+
61+
@Override
62+
public @Nullable Object getTransportCode() {
63+
return httpStatus;
64+
}
65+
};
66+
}
67+
68+
private static ApiException createApiException(@Nullable Integer httpStatus, Code code) {
69+
return createApiException(httpStatus, code, null);
70+
}
71+
72+
private static ApiException createApiException(
73+
@Nullable Integer httpStatus, Code code, @Nullable Throwable cause) {
74+
return ApiExceptionFactory.createException(
75+
"HTTP " + httpStatus, cause, statusCode(httpStatus, code), false);
76+
}
77+
78+
@Test
79+
void testTransientHttpErrors_areTransientAcrossCommands() {
80+
ApiException error503 = createApiException(503, Code.UNAVAILABLE);
81+
assertThat(ResumableUploadErrorClassifier.classify(error503, START)).isEqualTo(TRANSIENT);
82+
assertThat(ResumableUploadErrorClassifier.classify(error503, UPLOAD)).isEqualTo(TRANSIENT);
83+
assertThat(ResumableUploadErrorClassifier.classify(error503, QUERY)).isEqualTo(TRANSIENT);
84+
}
85+
86+
@Test
87+
void testUnknownStatusCode_transientOnlyWhenCausedBySocketTimeoutException() {
88+
ApiException wrappedTimeout =
89+
createApiException(500, Code.UNKNOWN, new SocketTimeoutException("read timeout"));
90+
assertThat(ResumableUploadErrorClassifier.classify(wrappedTimeout, START)).isEqualTo(TRANSIENT);
91+
assertThat(ResumableUploadErrorClassifier.classify(wrappedTimeout, UPLOAD))
92+
.isEqualTo(TRANSIENT);
93+
assertThat(ResumableUploadErrorClassifier.classify(wrappedTimeout, QUERY)).isEqualTo(TRANSIENT);
94+
95+
ApiException wrappedSocket =
96+
createApiException(500, Code.UNKNOWN, new SocketException("connection reset"));
97+
assertThat(ResumableUploadErrorClassifier.classify(wrappedSocket, UPLOAD)).isEqualTo(FATAL);
98+
99+
ApiException wrappedUnknownHost =
100+
createApiException(500, Code.UNKNOWN, new UnknownHostException("unknown host"));
101+
assertThat(ResumableUploadErrorClassifier.classify(wrappedUnknownHost, UPLOAD))
102+
.isEqualTo(FATAL);
103+
104+
ApiException genericIo =
105+
createApiException(500, Code.UNKNOWN, new IOException("generic io error"));
106+
assertThat(ResumableUploadErrorClassifier.classify(genericIo, UPLOAD)).isEqualTo(FATAL);
107+
108+
ApiException wrappedRuntime =
109+
createApiException(500, Code.UNKNOWN, new IllegalStateException("local bug"));
110+
assertThat(ResumableUploadErrorClassifier.classify(wrappedRuntime, UPLOAD)).isEqualTo(FATAL);
111+
}
112+
113+
@Test
114+
void testRecoverableHttpErrors_dependOnCommand() {
115+
ApiException error412 = createApiException(412, Code.FAILED_PRECONDITION);
116+
117+
// Chunk transmission commands can recover by querying server status.
118+
assertThat(ResumableUploadErrorClassifier.classify(error412, UPLOAD)).isEqualTo(RECOVERABLE);
119+
assertThat(ResumableUploadErrorClassifier.classify(error412, FINALIZE)).isEqualTo(RECOVERABLE);
120+
assertThat(ResumableUploadErrorClassifier.classify(error412, UPLOAD_FINALIZE))
121+
.isEqualTo(RECOVERABLE);
122+
123+
// Non-chunk commands cannot recover via byte-offset query.
124+
assertThat(ResumableUploadErrorClassifier.classify(error412, START)).isEqualTo(FATAL);
125+
assertThat(ResumableUploadErrorClassifier.classify(error412, QUERY)).isEqualTo(FATAL);
126+
}
127+
128+
@Test
129+
void testCommand_isRecoverable() {
130+
assertThat(START.isRecoverable()).isFalse();
131+
assertThat(UPLOAD.isRecoverable()).isTrue();
132+
assertThat(FINALIZE.isRecoverable()).isTrue();
133+
assertThat(UPLOAD_FINALIZE.isRecoverable()).isTrue();
134+
assertThat(QUERY.isRecoverable()).isFalse();
135+
}
136+
137+
@Test
138+
void testUnmappedHttpCodesAndNonApiExceptions_areFatal() {
139+
assertThat(
140+
ResumableUploadErrorClassifier.classify(
141+
createApiException(403, Code.PERMISSION_DENIED), UPLOAD))
142+
.isEqualTo(FATAL);
143+
assertThat(
144+
ResumableUploadErrorClassifier.classify(
145+
createApiException(null, Code.FAILED_PRECONDITION), UPLOAD))
146+
.isEqualTo(FATAL);
147+
assertThat(
148+
ResumableUploadErrorClassifier.classify(new CancellationException("cancelled"), UPLOAD))
149+
.isEqualTo(FATAL);
150+
assertThat(
151+
ResumableUploadErrorClassifier.classify(new IllegalStateException("bad state"), UPLOAD))
152+
.isEqualTo(FATAL);
153+
154+
assertThrows(
155+
NullPointerException.class, () -> ResumableUploadErrorClassifier.classify(null, UPLOAD));
156+
assertThrows(
157+
NullPointerException.class,
158+
() ->
159+
ResumableUploadErrorClassifier.classify(
160+
createApiException(null, Code.FAILED_PRECONDITION), null));
161+
}
162+
}

0 commit comments

Comments
 (0)