Skip to content

Commit 3144f98

Browse files
committed
feat(gax): add ResumableUploadResultRetryAlgorithm
1 parent 6e8ee1a commit 3144f98

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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.resumable;
31+
32+
import com.google.api.core.BetaApi;
33+
import com.google.api.core.InternalApi;
34+
import com.google.api.gax.retrying.BasicResultRetryAlgorithm;
35+
import com.google.api.gax.retrying.ResultRetryAlgorithm;
36+
import com.google.api.gax.retrying.RetryingContext;
37+
import com.google.api.gax.rpc.ApiException;
38+
import com.google.api.gax.rpc.StatusCode;
39+
import com.google.common.collect.ImmutableSet;
40+
import java.io.IOException;
41+
import java.util.Set;
42+
import java.util.concurrent.CancellationException;
43+
import org.jspecify.annotations.NullMarked;
44+
import org.jspecify.annotations.Nullable;
45+
46+
/**
47+
* Implementation of {@link ResultRetryAlgorithm} for resumable uploads based on the Unified
48+
* Resumable Upload Protocol specification.
49+
*
50+
* <p>Differentiates between:
51+
*
52+
* <ul>
53+
* <li><b>Category 1 (Transient)</b>: Retriable without modification (e.g. UNAVAILABLE,
54+
* DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, network I/O errors).
55+
* <li><b>Category 2 (Recoverable)</b>: Retriable with modification (e.g. OUT_OF_RANGE,
56+
* INVALID_ARGUMENT, FAILED_PRECONDITION, ABORTED, INTERNAL) where the upload offset must be
57+
* recovered via queryStatus.
58+
* <li><b>Category 3 (Terminal)</b>: Fatal errors (e.g. NOT_FOUND, UNAUTHENTICATED,
59+
* PERMISSION_DENIED, CancellationException) which abort immediately.
60+
* </ul>
61+
*/
62+
@BetaApi
63+
@InternalApi
64+
@NullMarked
65+
public class ResumableUploadResultRetryAlgorithm<ResponseT>
66+
extends BasicResultRetryAlgorithm<ResponseT> {
67+
68+
private static final Set<StatusCode.Code> DEFAULT_RETRYABLE_CODES =
69+
ImmutableSet.of(
70+
// Category 1: Transient errors
71+
StatusCode.Code.UNAVAILABLE,
72+
StatusCode.Code.DEADLINE_EXCEEDED,
73+
StatusCode.Code.RESOURCE_EXHAUSTED,
74+
// Category 2: Recoverable errors (offset mismatch, precondition, missing header)
75+
StatusCode.Code.OUT_OF_RANGE,
76+
StatusCode.Code.INVALID_ARGUMENT,
77+
StatusCode.Code.FAILED_PRECONDITION,
78+
StatusCode.Code.ABORTED,
79+
StatusCode.Code.INTERNAL);
80+
81+
private final Set<StatusCode.Code> retryableCodes;
82+
83+
public static <ResponseT> ResumableUploadResultRetryAlgorithm<ResponseT> create() {
84+
return new ResumableUploadResultRetryAlgorithm<>(DEFAULT_RETRYABLE_CODES);
85+
}
86+
87+
public static <ResponseT> ResumableUploadResultRetryAlgorithm<ResponseT> create(
88+
Set<StatusCode.Code> retryableCodes) {
89+
return new ResumableUploadResultRetryAlgorithm<>(retryableCodes);
90+
}
91+
92+
public ResumableUploadResultRetryAlgorithm() {
93+
this(DEFAULT_RETRYABLE_CODES);
94+
}
95+
96+
public ResumableUploadResultRetryAlgorithm(Set<StatusCode.Code> retryableCodes) {
97+
this.retryableCodes = ImmutableSet.copyOf(retryableCodes);
98+
}
99+
100+
public Set<StatusCode.Code> getRetryableCodes() {
101+
return retryableCodes;
102+
}
103+
104+
@Override
105+
public boolean shouldRetry(
106+
@Nullable Throwable previousThrowable, @Nullable ResponseT previousResponse) {
107+
if (previousThrowable == null) {
108+
return false;
109+
}
110+
if (previousThrowable instanceof CancellationException) {
111+
return false;
112+
}
113+
if (previousThrowable instanceof ApiException) {
114+
StatusCode.Code code = ((ApiException) previousThrowable).getStatusCode().getCode();
115+
return retryableCodes.contains(code);
116+
}
117+
if (previousThrowable instanceof IOException) {
118+
return true;
119+
}
120+
return false;
121+
}
122+
123+
@Override
124+
public boolean shouldRetry(
125+
RetryingContext context,
126+
@Nullable Throwable previousThrowable,
127+
@Nullable ResponseT previousResponse) {
128+
if (context.getRetryableCodes() != null) {
129+
if (previousThrowable instanceof ApiException) {
130+
return context
131+
.getRetryableCodes()
132+
.contains(((ApiException) previousThrowable).getStatusCode().getCode());
133+
}
134+
}
135+
return shouldRetry(previousThrowable, previousResponse);
136+
}
137+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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.resumable;
31+
32+
import static com.google.common.truth.Truth.assertThat;
33+
34+
import com.google.api.gax.rpc.AbortedException;
35+
import com.google.api.gax.rpc.DeadlineExceededException;
36+
import com.google.api.gax.rpc.FailedPreconditionException;
37+
import com.google.api.gax.rpc.InternalException;
38+
import com.google.api.gax.rpc.InvalidArgumentException;
39+
import com.google.api.gax.rpc.NotFoundException;
40+
import com.google.api.gax.rpc.OutOfRangeException;
41+
import com.google.api.gax.rpc.PermissionDeniedException;
42+
import com.google.api.gax.rpc.ResourceExhaustedException;
43+
import com.google.api.gax.rpc.StatusCode;
44+
import com.google.api.gax.rpc.UnauthenticatedException;
45+
import com.google.api.gax.rpc.UnavailableException;
46+
import com.google.api.gax.rpc.testing.FakeCallContext;
47+
import com.google.api.gax.rpc.testing.FakeStatusCode;
48+
import com.google.common.collect.ImmutableSet;
49+
import java.io.IOException;
50+
import java.util.Collections;
51+
import java.util.concurrent.CancellationException;
52+
import org.junit.jupiter.api.Test;
53+
54+
class ResumableUploadResultRetryAlgorithmTest {
55+
56+
private final ResumableUploadResultRetryAlgorithm<String> algorithm =
57+
ResumableUploadResultRetryAlgorithm.create();
58+
59+
@Test
60+
void testCategory1TransientErrors_shouldRetry() {
61+
// 503 Unavailable
62+
UnavailableException unavailable =
63+
new UnavailableException(
64+
"unavailable", null, FakeStatusCode.of(StatusCode.Code.UNAVAILABLE), false);
65+
assertThat(algorithm.shouldRetry(unavailable, null)).isTrue();
66+
67+
// 504 DeadlineExceeded
68+
DeadlineExceededException deadlineExceeded =
69+
new DeadlineExceededException(
70+
"deadline exceeded", null, FakeStatusCode.of(StatusCode.Code.DEADLINE_EXCEEDED), false);
71+
assertThat(algorithm.shouldRetry(deadlineExceeded, null)).isTrue();
72+
73+
// 429 ResourceExhausted
74+
ResourceExhaustedException resourceExhausted =
75+
new ResourceExhaustedException(
76+
"quota exceeded", null, FakeStatusCode.of(StatusCode.Code.RESOURCE_EXHAUSTED), false);
77+
assertThat(algorithm.shouldRetry(resourceExhausted, null)).isTrue();
78+
79+
// Network / Socket I/O exception
80+
IOException ioException = new IOException("connection reset by peer");
81+
assertThat(algorithm.shouldRetry(ioException, null)).isTrue();
82+
}
83+
84+
@Test
85+
void testCategory2RecoverableErrors_shouldRetry() {
86+
// 416 OutOfRange (chunk offset mismatch)
87+
OutOfRangeException outOfRange =
88+
new OutOfRangeException(
89+
"out of range", null, FakeStatusCode.of(StatusCode.Code.OUT_OF_RANGE), false);
90+
assertThat(algorithm.shouldRetry(outOfRange, null)).isTrue();
91+
92+
// 400 InvalidArgument (chunk offset / payload mismatch)
93+
InvalidArgumentException invalidArgument =
94+
new InvalidArgumentException(
95+
"invalid argument", null, FakeStatusCode.of(StatusCode.Code.INVALID_ARGUMENT), false);
96+
assertThat(algorithm.shouldRetry(invalidArgument, null)).isTrue();
97+
98+
// 412 FailedPrecondition
99+
FailedPreconditionException failedPrecondition =
100+
new FailedPreconditionException(
101+
"failed precondition",
102+
null,
103+
FakeStatusCode.of(StatusCode.Code.FAILED_PRECONDITION),
104+
false);
105+
assertThat(algorithm.shouldRetry(failedPrecondition, null)).isTrue();
106+
107+
// 409 Aborted (conflict)
108+
AbortedException aborted =
109+
new AbortedException("aborted", null, FakeStatusCode.of(StatusCode.Code.ABORTED), false);
110+
assertThat(algorithm.shouldRetry(aborted, null)).isTrue();
111+
112+
// 500 / Protocol Internal error (missing status headers)
113+
InternalException internal =
114+
new InternalException(
115+
"internal protocol error", null, FakeStatusCode.of(StatusCode.Code.INTERNAL), false);
116+
assertThat(algorithm.shouldRetry(internal, null)).isTrue();
117+
}
118+
119+
@Test
120+
void testCategory3FatalTerminalErrors_shouldNotRetry() {
121+
// 404 NotFound (session expired or invalid)
122+
NotFoundException notFound =
123+
new NotFoundException(
124+
"session not found", null, FakeStatusCode.of(StatusCode.Code.NOT_FOUND), false);
125+
assertThat(algorithm.shouldRetry(notFound, null)).isFalse();
126+
127+
// 401 Unauthenticated
128+
UnauthenticatedException unauthenticated =
129+
new UnauthenticatedException(
130+
"unauthenticated", null, FakeStatusCode.of(StatusCode.Code.UNAUTHENTICATED), false);
131+
assertThat(algorithm.shouldRetry(unauthenticated, null)).isFalse();
132+
133+
// 403 PermissionDenied
134+
PermissionDeniedException permissionDenied =
135+
new PermissionDeniedException(
136+
"permission denied", null, FakeStatusCode.of(StatusCode.Code.PERMISSION_DENIED), false);
137+
assertThat(algorithm.shouldRetry(permissionDenied, null)).isFalse();
138+
139+
// Cancellation
140+
CancellationException cancellation = new CancellationException("cancelled");
141+
assertThat(algorithm.shouldRetry(cancellation, null)).isFalse();
142+
143+
// Generic RuntimeException
144+
RuntimeException runtime = new RuntimeException("unexpected");
145+
assertThat(algorithm.shouldRetry(runtime, null)).isFalse();
146+
}
147+
148+
@Test
149+
void testRetryingContextOverride() {
150+
FakeCallContext contextWithEmptyCodes =
151+
FakeCallContext.createDefault().withRetryableCodes(Collections.emptySet());
152+
153+
UnavailableException unavailable =
154+
new UnavailableException(
155+
"unavailable", null, FakeStatusCode.of(StatusCode.Code.UNAVAILABLE), false);
156+
157+
// Default algorithm retries UNAVAILABLE, but context with empty codes forbids it
158+
assertThat(algorithm.shouldRetry(contextWithEmptyCodes, unavailable, null)).isFalse();
159+
160+
FakeCallContext contextWithCustomCodes =
161+
FakeCallContext.createDefault()
162+
.withRetryableCodes(ImmutableSet.of(StatusCode.Code.NOT_FOUND));
163+
164+
NotFoundException notFound =
165+
new NotFoundException(
166+
"not found", null, FakeStatusCode.of(StatusCode.Code.NOT_FOUND), false);
167+
168+
// Custom context allows NOT_FOUND
169+
assertThat(algorithm.shouldRetry(contextWithCustomCodes, notFound, null)).isTrue();
170+
}
171+
}

0 commit comments

Comments
 (0)