Skip to content

Commit 3d8d99c

Browse files
authored
[FFM-10821] - Add Retry-After HTTP header support (#184)
* [FFM-10821] - Add Retry-After HTTP header support What If Retry-After header is detected, use that first when retrying a failed API connection Why Allow backend to control how SDKs retry Testing Manual + unit testing
1 parent 9b0d30f commit 3d8d99c

File tree

5 files changed

+87
-19
lines changed

5 files changed

+87
-19
lines changed

examples/src/main/java/io/harness/ff/examples/GettingStarted.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.harness.ff.examples;
22

33
import io.harness.cf.client.api.*;
4+
import io.harness.cf.client.connector.HarnessConfig;
5+
import io.harness.cf.client.connector.HarnessConnector;
46
import io.harness.cf.client.dto.Target;
57

68
import java.util.concurrent.Executors;
@@ -19,8 +21,13 @@ public class GettingStarted {
1921
public static void main(String[] args) {
2022
System.out.println("Harness SDK Getting Started");
2123

24+
final HarnessConnector connector = new HarnessConnector(apiKey, HarnessConfig.builder()
25+
//.configUrl("http://localhost:8000/api/1.0")
26+
//.eventUrl("http://localhost:8001/api/1.0")
27+
.build());
28+
2229
//Create a Feature Flag Client
23-
try (CfClient cfClient = new CfClient(apiKey, BaseConfig.builder().build())) {
30+
try (CfClient cfClient = new CfClient(connector)) {
2431
cfClient.waitForInitialization();
2532

2633
// Create a target (different targets can get different results based on rules. This includes a custom attribute 'location')

src/main/java/io/harness/cf/client/api/PollingProcessor.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@
88
import io.harness.cf.model.FeatureConfig;
99
import io.harness.cf.model.Segment;
1010
import java.util.List;
11-
import java.util.concurrent.CompletableFuture;
12-
import java.util.concurrent.Executors;
13-
import java.util.concurrent.ScheduledExecutorService;
14-
import java.util.concurrent.ScheduledFuture;
11+
import java.util.concurrent.*;
1512
import lombok.NonNull;
1613
import lombok.extern.slf4j.Slf4j;
1714

@@ -81,7 +78,12 @@ public CompletableFuture<List<Segment>> retrieveSegments() {
8178
}
8279

8380
public void retrieveAll() {
84-
CompletableFuture.allOf(retrieveFlags(), retrieveSegments()).join();
81+
try {
82+
CompletableFuture.allOf(retrieveFlags(), retrieveSegments()).join();
83+
} catch (CompletionException | CancellationException ex) {
84+
log.warn("retrieveAll failed: {} - {}", ex.getClass().getSimpleName(), ex.getMessage());
85+
log.trace("retrieveAll failed", ex);
86+
}
8587
}
8688

8789
private void runOneIteration() {

src/main/java/io/harness/cf/client/connector/NewRetryInterceptor.java

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
package io.harness.cf.client.connector;
22

33
import java.io.IOException;
4+
import java.text.ParseException;
5+
import java.text.SimpleDateFormat;
6+
import java.time.Duration;
7+
import java.time.Instant;
8+
import java.util.Date;
9+
import java.util.Locale;
410
import java.util.concurrent.TimeUnit;
5-
import lombok.extern.slf4j.Slf4j;
611
import okhttp3.*;
712
import org.jetbrains.annotations.NotNull;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
815

9-
@Slf4j
1016
public class NewRetryInterceptor implements Interceptor {
1117

18+
private static final Logger log = LoggerFactory.getLogger(NewRetryInterceptor.class);
19+
private static final SimpleDateFormat imfDateFormat =
20+
new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
1221
private final long retryBackoffDelay;
1322
private final long maxTryCount;
1423

@@ -27,6 +36,7 @@ public NewRetryInterceptor(long maxTryCount, long retryBackoffDelay) {
2736
public Response intercept(@NotNull Chain chain) throws IOException {
2837
int tryCount = 1;
2938
boolean successful;
39+
boolean limitReached = false;
3040
Response response = null;
3141
String msg = "";
3242
do {
@@ -36,7 +46,9 @@ public Response intercept(@NotNull Chain chain) throws IOException {
3646
response = chain.proceed(chain.request());
3747
successful = response.isSuccessful();
3848
if (!successful) {
39-
msg = String.format("httpCode=%d %s", response.code(), response.message());
49+
msg =
50+
String.format(
51+
Locale.getDefault(), "httpCode=%d %s", response.code(), response.message());
4052
if (!shouldRetryHttpErrorCode(response.code())) {
4153
return response;
4254
}
@@ -47,34 +59,81 @@ public Response intercept(@NotNull Chain chain) throws IOException {
4759

4860
} catch (Exception ex) {
4961
log.trace("Error while attempting to make request", ex);
50-
msg = ex.getMessage();
62+
msg = ex.getClass().getSimpleName() + ": " + ex.getMessage();
5163
response = makeErrorResp(chain, msg);
5264
successful = false;
5365
if (!shouldRetryException(ex)) {
5466
return response;
5567
}
5668
}
5769
if (!successful) {
58-
final boolean limitReached = tryCount > maxTryCount;
70+
int retryAfterHeaderValue = getRetryAfterHeaderInSeconds(response);
71+
long backOffDelayMs;
72+
if (retryAfterHeaderValue > 0) {
73+
// Use Retry-After header if detected first
74+
log.trace("Retry-After header detected: {} seconds", retryAfterHeaderValue);
75+
backOffDelayMs = retryAfterHeaderValue * 1000L;
76+
} else {
77+
// Else fallback to a randomized exponential backoff
78+
backOffDelayMs = retryBackoffDelay * tryCount;
79+
}
80+
81+
limitReached = tryCount >= maxTryCount;
5982
log.warn(
6083
"Request attempt {} to {} was not successful, [{}]{}",
6184
tryCount,
6285
chain.request().url(),
6386
msg,
6487
limitReached
6588
? ", retry limited reached"
66-
: String.format(", retrying in %dms", retryBackoffDelay * tryCount));
89+
: String.format(
90+
Locale.getDefault(),
91+
", retrying in %dms (retry-after hdr: %b)",
92+
backOffDelayMs,
93+
retryAfterHeaderValue > 0));
6794

6895
if (!limitReached) {
69-
sleep(retryBackoffDelay * tryCount);
96+
sleep(backOffDelayMs);
7097
}
7198
}
72-
} while (!successful && tryCount++ <= maxTryCount);
99+
tryCount++;
100+
} while (!successful && !limitReached);
73101

74102
return response;
75103
}
76104

105+
int getRetryAfterHeaderInSeconds(Response response) {
106+
final String retryAfterValue = response.header("Retry-After");
107+
if (retryAfterValue == null) {
108+
return 0;
109+
}
110+
111+
int seconds = 0;
112+
try {
113+
seconds = Integer.parseInt(retryAfterValue);
114+
} catch (NumberFormatException ignored) {
115+
}
116+
117+
if (seconds <= 0) {
118+
try {
119+
final Date then = imfDateFormat.parse(retryAfterValue);
120+
if (then != null) {
121+
seconds = (int) Duration.between(Instant.now(), then.toInstant()).getSeconds();
122+
}
123+
} catch (ParseException ignored) {
124+
}
125+
}
126+
127+
if (seconds < 0) {
128+
seconds = 0;
129+
}
130+
131+
return Math.min(seconds, 3600);
132+
}
133+
77134
private boolean shouldRetryException(Exception ex) {
135+
log.debug(
136+
"should retry exception check: {} - {}", ex.getClass().getSimpleName(), ex.getMessage());
78137
return true;
79138
}
80139

@@ -87,7 +146,7 @@ private boolean shouldRetryHttpErrorCode(int httpCode) {
87146

88147
private Response makeErrorResp(Chain chain, String msg) {
89148
return new Response.Builder()
90-
.code(404) /* dummy response: real reason is in the message */
149+
.code(400) /* dummy response: real reason is in the message */
91150
.request(chain.request())
92151
.protocol(Protocol.HTTP_2)
93152
.message(msg)

src/test/java/io/harness/cf/client/api/CfClientTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ void shouldAllowEvaluationsToContinueWhenAuthFails(boolean shouldWaitForInit)
148148
.streamEnabled(true)
149149
.build();
150150

151-
Http4xxOnAuthDispatcher webserverDispatcher = new Http4xxOnAuthDispatcher(429, 4); // auth error
151+
Http4xxOnAuthDispatcher webserverDispatcher = new Http4xxOnAuthDispatcher(429, 3); // auth error
152152

153153
try (MockWebServer mockSvr = new MockWebServer()) {
154154
mockSvr.setDispatcher(webserverDispatcher);
@@ -205,7 +205,7 @@ void shouldAllowEvaluationsToContinueWhenAuthFails(boolean shouldWaitForInit)
205205
webserverDispatcher.waitForAllConnections(15);
206206

207207
assertTrue(
208-
webserverDispatcher.getUrlMap().get(AUTH_ENDPOINT) >= (3 + 1),
208+
webserverDispatcher.getUrlMap().get(AUTH_ENDPOINT) >= (2 + 1),
209209
"not enough authentication attempts");
210210

211211
assertEquals(
@@ -465,7 +465,7 @@ void shouldRetryThenReAuthenticateWithoutThrowingIllegalStateException() throws
465465
// First 3 attempts to connect to auth endpoint will return a 408, followed by a 200 success
466466
webserverDispatcher.waitForAllConnections(15);
467467

468-
final int expectedAuths = 3 + 3 + 1; // 3+3 failed retries (4xx), 1 success (200)
468+
final int expectedAuths = 2 + 2 + 1; // 2+2 failed retries (4xx), 1 success (200)
469469

470470
assertEquals(
471471
expectedAuths,

src/test/java/io/harness/cf/client/api/dispatchers/Http4xxOnFirstAuthDispatcher.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public Http4xxOnFirstAuthDispatcher(int minAuthAttempts) {
2525
private MockResponse dispatchAuthResp() {
2626
System.out.println("DISPATCH authAttempts = " + authAttempts.get());
2727

28-
if (authAttempts.getAndIncrement() < 6) {
28+
if (authAttempts.getAndIncrement() < 4) {
2929
System.out.println("--> 408");
3030
return makeAuthResponse(408);
3131
}

0 commit comments

Comments
 (0)