Skip to content

Commit 26db9e3

Browse files
committed
chore: add RetryContext in preparation for retry settings bounded read object range handling
1 parent 3ca4123 commit 26db9e3

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.storage;
18+
19+
import com.google.api.gax.grpc.GrpcStatusCode;
20+
import com.google.api.gax.retrying.ResultRetryAlgorithm;
21+
import com.google.api.gax.rpc.ApiException;
22+
import com.google.api.gax.rpc.ApiExceptionFactory;
23+
import com.google.cloud.storage.Retrying.RetryingDependencies;
24+
import io.grpc.Status.Code;
25+
import java.util.LinkedList;
26+
import java.util.List;
27+
28+
final class RetryContext {
29+
30+
private static final GrpcStatusCode CANCELLED = GrpcStatusCode.of(Code.CANCELLED);
31+
private final RetryingDependencies retryingDependencies;
32+
private final ResultRetryAlgorithm<?> algorithm;
33+
private List<Throwable> failures;
34+
35+
private RetryContext(
36+
RetryingDependencies retryingDependencies, ResultRetryAlgorithm<?> algorithm) {
37+
this.retryingDependencies = retryingDependencies;
38+
this.algorithm = algorithm;
39+
}
40+
41+
void reset() {
42+
failures = new LinkedList<>();
43+
}
44+
45+
public void recordError(Throwable e) {
46+
int failureCount = failures.size() + 1 /* include e */;
47+
int maxAttempts = retryingDependencies.getRetrySettings().getMaxAttempts();
48+
boolean shouldRetry = algorithm.shouldRetry(e, null);
49+
String msgPrefix = null;
50+
if (shouldRetry && failureCount >= maxAttempts) {
51+
msgPrefix = "Operation failed to complete within retry limit";
52+
} else if (!shouldRetry) {
53+
msgPrefix = "Unretryable error";
54+
}
55+
56+
if (msgPrefix == null) {
57+
failures.add(e);
58+
} else {
59+
String msg =
60+
String.format("%s (attempts: %d, maxAttempts: %d)", msgPrefix, failureCount, maxAttempts);
61+
ApiException cancelled = ApiExceptionFactory.createException(msg, e, CANCELLED, false);
62+
for (Throwable failure : failures) {
63+
cancelled.addSuppressed(failure);
64+
}
65+
throw cancelled;
66+
}
67+
}
68+
69+
static RetryContext of(
70+
RetryingDependencies retryingDependencies, ResultRetryAlgorithm<?> algorithm) {
71+
RetryContext retryContext = new RetryContext(retryingDependencies, algorithm);
72+
retryContext.reset();
73+
return retryContext;
74+
}
75+
76+
static RetryContext neverRetry() {
77+
return new RetryContext(RetryingDependencies.attemptOnce(), Retrying.neverRetry());
78+
}
79+
80+
static RetryContextProvider providerFrom(RetryingDependencies deps, ResultRetryAlgorithm<?> alg) {
81+
return () -> RetryContext.of(deps, alg);
82+
}
83+
84+
@FunctionalInterface
85+
interface RetryContextProvider {
86+
87+
RetryContext create();
88+
}
89+
}

google-cloud-storage/src/main/java/com/google/cloud/storage/Retrying.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ public boolean shouldRetry(Throwable previousThrowable, Object previousResponse)
142142
};
143143
}
144144

145+
static ResultRetryAlgorithm<?> alwaysRetry() {
146+
return new BasicResultRetryAlgorithm<Object>() {
147+
@Override
148+
public boolean shouldRetry(Throwable previousThrowable, Object previousResponse) {
149+
return true;
150+
}
151+
};
152+
}
153+
145154
/**
146155
* Rather than requiring a full set of {@link StorageOptions} to be passed specify what we
147156
* actually need and have StorageOptions implement this interface.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.storage;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertThrows;
21+
22+
import com.google.api.core.ApiClock;
23+
import com.google.api.core.NanoClock;
24+
import com.google.api.gax.grpc.GrpcStatusCode;
25+
import com.google.api.gax.retrying.BasicResultRetryAlgorithm;
26+
import com.google.api.gax.retrying.RetrySettings;
27+
import com.google.api.gax.rpc.ApiException;
28+
import com.google.api.gax.rpc.ApiExceptionFactory;
29+
import com.google.api.gax.rpc.CancelledException;
30+
import com.google.api.gax.rpc.ResourceExhaustedException;
31+
import com.google.cloud.storage.Retrying.RetryingDependencies;
32+
import io.grpc.Status.Code;
33+
import org.junit.Test;
34+
35+
public final class RetryContextTest {
36+
37+
private static final Throwable T1 = apiException(Code.UNAVAILABLE, "{unavailable}");
38+
private static final Throwable T2 = apiException(Code.INTERNAL, "{internal}");
39+
private static final Throwable T3 = apiException(Code.RESOURCE_EXHAUSTED, "{resource exhausted}");
40+
41+
@Test
42+
public void retriable_cancelledException_when_maxAttemptBudget_consumed() {
43+
RetryContext ctx = RetryContext.of(maxAttempts(1), Retrying.alwaysRetry());
44+
45+
CancelledException cancelled =
46+
assertThrows(CancelledException.class, () -> ctx.recordError(T1));
47+
48+
assertThat(cancelled).hasCauseThat().isEqualTo(T1);
49+
}
50+
51+
@Test
52+
public void retriable_maxAttemptBudget_still_available() {
53+
RetryContext ctx = RetryContext.of(maxAttempts(2), Retrying.alwaysRetry());
54+
55+
ctx.recordError(T1);
56+
}
57+
58+
@Test
59+
public void
60+
retriable_cancelledException_when_maxAttemptBudget_multipleAttempts_previousErrorsIncludedAsSuppressed() {
61+
RetryContext ctx = RetryContext.of(maxAttempts(3), Retrying.alwaysRetry());
62+
63+
ctx.recordError(T1);
64+
ctx.recordError(T2);
65+
66+
CancelledException cancelled =
67+
assertThrows(CancelledException.class, () -> ctx.recordError(T3));
68+
69+
assertThat(cancelled).hasCauseThat().isEqualTo(T3);
70+
Throwable[] suppressed = cancelled.getSuppressed();
71+
assertThat(suppressed).asList().containsExactly(T1, T2);
72+
}
73+
74+
@Test
75+
public void nonRetriable_cancelledException_regardlessOfAttemptBudget() {
76+
RetryContext ctx = RetryContext.of(maxAttempts(3), Retrying.neverRetry());
77+
78+
CancelledException cancelled =
79+
assertThrows(CancelledException.class, () -> ctx.recordError(T1));
80+
81+
assertThat(cancelled).hasCauseThat().isEqualTo(T1);
82+
}
83+
84+
@Test
85+
public void
86+
nonRetriable_cancelledException_regardlessOfAttemptBudget_previousErrorsIncludedAsSuppressed() {
87+
RetryContext ctx =
88+
RetryContext.of(
89+
maxAttempts(6),
90+
new BasicResultRetryAlgorithm<Object>() {
91+
@Override
92+
public boolean shouldRetry(Throwable previousThrowable, Object previousResponse) {
93+
return !(previousThrowable instanceof ResourceExhaustedException);
94+
}
95+
});
96+
97+
ctx.recordError(T1);
98+
ctx.recordError(T2);
99+
100+
CancelledException cancelled =
101+
assertThrows(CancelledException.class, () -> ctx.recordError(T3));
102+
103+
assertThat(cancelled).hasCauseThat().isEqualTo(T3);
104+
Throwable[] suppressed = cancelled.getSuppressed();
105+
assertThat(suppressed).asList().containsExactly(T1, T2);
106+
}
107+
108+
@Test
109+
public void resetDiscardsPreviousErrors() {
110+
RetryContext ctx =
111+
RetryContext.of(
112+
maxAttempts(6),
113+
new BasicResultRetryAlgorithm<Object>() {
114+
@Override
115+
public boolean shouldRetry(Throwable previousThrowable, Object previousResponse) {
116+
return !(previousThrowable instanceof ResourceExhaustedException);
117+
}
118+
});
119+
120+
ctx.recordError(T1);
121+
ctx.recordError(T2);
122+
ctx.reset();
123+
124+
CancelledException cancelled =
125+
assertThrows(CancelledException.class, () -> ctx.recordError(T3));
126+
127+
assertThat(cancelled).hasCauseThat().isEqualTo(T3);
128+
Throwable[] suppressed = cancelled.getSuppressed();
129+
assertThat(suppressed).asList().isEmpty();
130+
}
131+
132+
private static ApiException apiException(Code code, String message) {
133+
return ApiExceptionFactory.createException(message, null, GrpcStatusCode.of(code), false);
134+
}
135+
136+
private static MaxAttemptRetryingDependencies maxAttempts(int maxAttempts) {
137+
return new MaxAttemptRetryingDependencies(
138+
RetrySettings.newBuilder().setMaxAttempts(maxAttempts).build(),
139+
NanoClock.getDefaultClock());
140+
}
141+
142+
private static final class MaxAttemptRetryingDependencies implements RetryingDependencies {
143+
private final RetrySettings settings;
144+
private final ApiClock clock;
145+
146+
private MaxAttemptRetryingDependencies(RetrySettings settings, ApiClock clock) {
147+
this.settings = settings;
148+
this.clock = clock;
149+
}
150+
151+
@Override
152+
public RetrySettings getRetrySettings() {
153+
return settings;
154+
}
155+
156+
@Override
157+
public ApiClock getClock() {
158+
return clock;
159+
}
160+
}
161+
}

0 commit comments

Comments
 (0)