Skip to content

Commit fcab824

Browse files
Fetch retry on http releated errors (#75)
* Add retry mechanism for transient HTTP errors in ConfigFetcher. WIP * Implement retry logic for fetch responses in ConfigFetcher * Fix cfRayId after merge and test cases * Add tests for retry logic on socket timeout and unexpected errors in ConfigFetcher * Add test for handling unexpected errors in ConfigFetcher * Improve error handling in ConfigFetcher on failure * Refactor ConfigCatClient to use HttpOptions for HTTP client configuration and enhance connection pool management in ConfigFetcher * Small fixes * Fix formatting of EVICT_ALL_THRESHOLD_MS constant in ConfigFetcher * Add tests for HTTP options in ConfigCatClient to verify default values and timeout settings * SonarQube issue fix * Update eviction threshold to nanoseconds and adjust related logic in ConfigFetcher * Bump version to 10.0.0 in Constants.java and gradle.properties
1 parent c927bbe commit fcab824

12 files changed

Lines changed: 453 additions & 50 deletions

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
version=9.4.5
1+
version=10.0.0
22
SONATYPE_CONNECT_TIMEOUT_SECONDS=120

src/main/java/com/configcat/ConfigCatClient.java

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import org.slf4j.LoggerFactory;
55

66
import java.io.IOException;
7+
import java.net.Proxy;
78
import java.util.*;
89
import java.util.concurrent.CompletableFuture;
10+
import java.util.concurrent.TimeUnit;
911
import java.util.concurrent.atomic.AtomicBoolean;
1012
import java.util.function.Consumer;
1113

@@ -40,11 +42,8 @@ private ConfigCatClient(String sdkKey, Options options) {
4042
this.configCatHooks = options.configCatHooks;
4143

4244
if (this.overrideBehaviour != OverrideBehaviour.LOCAL_ONLY) {
43-
ConfigFetcher fetcher = new ConfigFetcher(options.httpClient == null
44-
? new OkHttpClient
45-
.Builder()
46-
.build()
47-
: options.httpClient,
45+
ConfigFetcher fetcher = new ConfigFetcher(
46+
createHttpClient(options.httpOptions()),
4847
this.logger,
4948
sdkKey,
5049
!options.isBaseURLCustom()
@@ -63,6 +62,22 @@ private ConfigCatClient(String sdkKey, Options options) {
6362
this.defaultUser = options.defaultUser;
6463
}
6564

65+
private static OkHttpClient createHttpClient(Options.HttpOptions httpOptions) {
66+
OkHttpClient.Builder builder = new OkHttpClient.Builder();
67+
68+
if (httpOptions.getConnectTimeoutMillis() != null) {
69+
builder.connectTimeout(httpOptions.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS);
70+
}
71+
if (httpOptions.getReadTimeoutMillis() != null) {
72+
builder.readTimeout(httpOptions.getReadTimeoutMillis(), TimeUnit.MILLISECONDS);
73+
}
74+
if (httpOptions.getProxy() != null) {
75+
builder.proxy(httpOptions.getProxy());
76+
}
77+
78+
return builder.build();
79+
}
80+
6681
@Override
6782
public <T> T getValue(Class<T> classOfT, String key, T defaultValue) {
6883
return this.getValue(classOfT, key, null, defaultValue);
@@ -696,7 +711,7 @@ private <T> EvaluationDetails<T> evaluate(Class<T> classOfT, Setting setting, St
696711
* Options for configuring {@link ConfigCatClient} instance.
697712
*/
698713
public static class Options {
699-
private OkHttpClient httpClient;
714+
private final HttpOptions httpOptions = new HttpOptions();
700715
private ConfigCache cache = new NullConfigCache();
701716
private String baseUrl;
702717
private PollingMode pollingMode = PollingModes.autoPoll();
@@ -709,16 +724,6 @@ public static class Options {
709724
private final ConfigCatHooks configCatHooks = new ConfigCatHooks();
710725
private LogFilterFunction logFilter;
711726

712-
713-
/**
714-
* Sets the underlying http client which will be used to fetch the latest configuration.
715-
*
716-
* @param httpClient the http client.
717-
*/
718-
public void httpClient(OkHttpClient httpClient) {
719-
this.httpClient = httpClient;
720-
}
721-
722727
/**
723728
* Sets the internal cache implementation.
724729
*
@@ -814,6 +819,14 @@ public ConfigCatHooks hooks() {
814819
return configCatHooks;
815820
}
816821

822+
/**
823+
* HTTP related options for {@link ConfigCatClient}.
824+
**/
825+
public HttpOptions httpOptions() {
826+
return this.httpOptions;
827+
}
828+
829+
817830
/**
818831
* Set the client's log filter callback function. When logFilterFunction returns false, the ConfigCatLogger skips the log event.
819832
*/
@@ -824,5 +837,56 @@ public void logFilter(LogFilterFunction logFilter) {
824837
private boolean isBaseURLCustom() {
825838
return this.baseUrl != null && !this.baseUrl.isEmpty();
826839
}
840+
841+
/**
842+
* HTTP configuration options for a {@link ConfigCatClient} instance.
843+
*/
844+
public static class HttpOptions {
845+
private Integer connectTimeoutMillis;
846+
private Integer readTimeoutMillis;
847+
private Proxy proxy;
848+
849+
/**
850+
* Sets HTTP connect timeout in milliseconds.
851+
*
852+
* @param connectTimeoutMillis the connect timeout in milliseconds.
853+
*/
854+
public HttpOptions connectTimeoutMillis(int connectTimeoutMillis) {
855+
this.connectTimeoutMillis = connectTimeoutMillis;
856+
return this;
857+
}
858+
859+
/**
860+
* Sets the HTTP read timeout in milliseconds.
861+
*
862+
* @param readTimeoutMillis the read timeout in milliseconds.
863+
*/
864+
public HttpOptions readTimeoutMillis(int readTimeoutMillis) {
865+
this.readTimeoutMillis = readTimeoutMillis;
866+
return this;
867+
}
868+
869+
/**
870+
* Sets the HTTP proxy.
871+
*
872+
* @param proxy the HTTP proxy.
873+
*/
874+
public HttpOptions proxy(Proxy proxy) {
875+
this.proxy = proxy;
876+
return this;
877+
}
878+
879+
Integer getConnectTimeoutMillis() {
880+
return connectTimeoutMillis;
881+
}
882+
883+
Integer getReadTimeoutMillis() {
884+
return readTimeoutMillis;
885+
}
886+
887+
Proxy getProxy() {
888+
return proxy;
889+
}
890+
}
827891
}
828892
}

src/main/java/com/configcat/ConfigFetcher.java

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@
1010
import java.util.concurrent.atomic.AtomicBoolean;
1111

1212
class ConfigFetcher implements Closeable {
13+
14+
private static final long RETRY_DELAY_MS = 50;
15+
16+
private static final long EVICT_ALL_THRESHOLD_NS = 30_000_000_000L; // 30 seconds in nanoseconds
17+
1318
private final AtomicBoolean isClosed = new AtomicBoolean(false);
1419
private final ConfigCatLogger logger;
1520
private final OkHttpClient httpClient;
1621
private final String mode;
1722

23+
private long lastEvictAllTimestamp = Long.MIN_VALUE;
24+
1825
private final String sdkKey;
1926
private final boolean urlIsCustom;
2027

@@ -45,7 +52,7 @@ public CompletableFuture<FetchResponse> fetchAsync(String eTag) {
4552
}
4653

4754
private CompletableFuture<FetchResponse> executeFetchAsync(int executionCount, String eTag) {
48-
return this.getResponseAsync(eTag).thenComposeAsync(fetchResponse -> {
55+
return this.fetchWithRetryAsync(eTag).thenComposeAsync(fetchResponse -> {
4956
if (!fetchResponse.isFetched()) {
5057
return CompletableFuture.completedFuture(fetchResponse);
5158
}
@@ -97,64 +104,101 @@ private CompletableFuture<FetchResponse> getResponseAsync(final String eTag) {
97104
this.httpClient.newCall(request).enqueue(new Callback() {
98105
@Override
99106
public void onFailure(@NotNull Call call, @NotNull IOException e) {
100-
int logEventId = 1103;
101-
Object message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(null);
102-
if (!isClosed.get()) {
103-
if (e instanceof SocketTimeoutException) {
104-
logEventId = 1102;
105-
message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpClient.connectTimeoutMillis(), httpClient.readTimeoutMillis(), httpClient.writeTimeoutMillis(), null);
107+
FetchResponse fetchResponse = null;
108+
try{
109+
int logEventId = 1103;
110+
Object message = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(null);
111+
if (!isClosed.get()) {
112+
if (e instanceof SocketTimeoutException) {
113+
logEventId = 1102;
114+
message = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpClient.connectTimeoutMillis(), httpClient.readTimeoutMillis(), httpClient.writeTimeoutMillis(), null);
115+
}
116+
logger.error(logEventId, message, e);
117+
}
118+
fetchResponse = FetchResponse.failed(message, false, null, true);
119+
} finally {
120+
if(fetchResponse == null) {
121+
FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(null);
122+
fetchResponse = FetchResponse.failed(formattableLogMessage,false, null, false);
106123
}
107-
logger.error(logEventId, message, e);
124+
future.complete(fetchResponse);
108125
}
109-
future.complete(FetchResponse.failed(message, false, null));
110126
}
111127

112128
@Override
113129
public void onResponse(@NotNull Call call, @NotNull Response response) {
114130
String cfRayId = null;
131+
FetchResponse fetchResponse = null;
115132
try (ResponseBody body = response.body()) {
116133
cfRayId = response.header("CF-RAY");
117134
if (response.code() == 200) {
118135
String content = body != null ? body.string() : null;
119136
String eTag = response.header("ETag");
120137
Result<Config> result = deserializeConfig(content, cfRayId);
121138
if (result.error() != null) {
122-
future.complete(FetchResponse.failed(result.error(), false, cfRayId));
123-
return;
139+
fetchResponse = FetchResponse.failed(result.error(), false, cfRayId, false);
140+
} else {
141+
fetchResponse = FetchResponse.fetched(new Entry(result.value(), eTag, content, System.currentTimeMillis()), cfRayId);
142+
logger.debug("Fetch was successful: new config fetched.");
124143
}
125-
logger.debug("Fetch was successful: new config fetched.");
126-
future.complete(FetchResponse.fetched(new Entry(result.value(), eTag, content, System.currentTimeMillis()), cfRayId));
127144
} else if (response.code() == 304) {
145+
fetchResponse = FetchResponse.notModified(cfRayId);
128146
if(cfRayId != null) {
129147
logger.debug(String.format("Fetch was successful: config not modified. %s", ConfigCatLogMessages.getCFRayIdPostFix(cfRayId)));
130148
} else {
131149
logger.debug("Fetch was successful: config not modified.");
132150
}
133-
future.complete(FetchResponse.notModified(cfRayId));
134151
} else if (response.code() == 403 || response.code() == 404) {
135152
FormattableLogMessage message = ConfigCatLogMessages.getFetchFailedDueToInvalidSDKKey(cfRayId);
153+
fetchResponse = FetchResponse.failed(message, true, cfRayId, false);
136154
logger.error(1100, message);
137-
future.complete(FetchResponse.failed(message, true, cfRayId));
138155
} else {
139156
FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedHttpResponse(response.code(), response.message(), cfRayId);
157+
fetchResponse = FetchResponse.failed(formattableLogMessage, false, cfRayId, true);
140158
logger.error(1101, formattableLogMessage);
141-
future.complete(FetchResponse.failed(formattableLogMessage, false, cfRayId));
142159
}
143160
} catch (SocketTimeoutException e) {
144161
FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToRequestTimeout(httpClient.connectTimeoutMillis(), httpClient.readTimeoutMillis(), httpClient.writeTimeoutMillis(), cfRayId);
162+
fetchResponse = FetchResponse.failed(formattableLogMessage, false, cfRayId, true);
145163
logger.error(1102, formattableLogMessage, e);
146-
future.complete(FetchResponse.failed(formattableLogMessage, false, cfRayId));
147164
} catch (Exception e) {
148165
FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId);
166+
fetchResponse = FetchResponse.failed(formattableLogMessage, false, cfRayId, true);
149167
logger.error(1103, formattableLogMessage, e);
150-
future.complete(FetchResponse.failed(formattableLogMessage, false, cfRayId));
168+
} finally {
169+
if(fetchResponse == null) {
170+
FormattableLogMessage formattableLogMessage = ConfigCatLogMessages.getFetchFailedDueToUnexpectedError(cfRayId);
171+
fetchResponse = FetchResponse.failed(formattableLogMessage,false, cfRayId, false);
172+
}
173+
future.complete(fetchResponse);
151174
}
152175
}
153176
});
154177

155178
return future;
156179
}
157180

181+
private CompletableFuture<FetchResponse> fetchWithRetryAsync(final String eTag) {
182+
return this.getResponseAsync(eTag).thenComposeAsync(response -> {
183+
if (response.shouldRetry()) {
184+
try {
185+
long now = System.nanoTime();
186+
if (lastEvictAllTimestamp == Long.MIN_VALUE || (now - lastEvictAllTimestamp) >= EVICT_ALL_THRESHOLD_NS) {
187+
this.httpClient.connectionPool().evictAll();
188+
lastEvictAllTimestamp = now;
189+
}
190+
Thread.sleep(RETRY_DELAY_MS);
191+
return this.getResponseAsync(eTag);
192+
} catch (InterruptedException e) {
193+
this.logger.error(0, "Thread interrupted.", e);
194+
Thread.currentThread().interrupt();
195+
return CompletableFuture.completedFuture(response);
196+
}
197+
}
198+
return CompletableFuture.completedFuture(response);
199+
});
200+
}
201+
158202
@Override
159203
public void close() throws IOException {
160204
if (!this.isClosed.compareAndSet(false, true)) {

src/main/java/com/configcat/Constants.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ private Constants() { /* prevent from instantiation*/ }
77
static final long DISTANT_PAST = 0;
88
static final String CONFIG_JSON_NAME = "config_v6.json";
99
static final String SERIALIZATION_FORMAT_VERSION = "v2";
10-
static final String VERSION = "9.4.5";
10+
static final String VERSION = "10.0.0";
1111

1212
static final String SDK_KEY_PROXY_PREFIX = "configcat-proxy/";
1313
static final String SDK_KEY_PREFIX = "configcat-sdk-1";

src/main/java/com/configcat/FetchResponse.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public enum Status {
1212
private final Object error;
1313
private final boolean fetchTimeUpdatable;
1414
private final String cfRayId;
15+
private final boolean shouldRetry;
1516

1617
public boolean isFetched() {
1718
return this.status == Status.FETCHED;
@@ -39,23 +40,26 @@ public Object error() {
3940

4041
public String cfRayId() {return this.cfRayId;}
4142

42-
FetchResponse(Status status, Entry entry, Object error, boolean fetchTimeUpdatable, String cfRayId) {
43+
public boolean shouldRetry() {return shouldRetry;}
44+
45+
FetchResponse(Status status, Entry entry, Object error, boolean fetchTimeUpdatable, String cfRayId, boolean shouldRetry) {
4346
this.status = status;
4447
this.entry = entry;
4548
this.error = error;
4649
this.fetchTimeUpdatable = fetchTimeUpdatable;
4750
this.cfRayId = cfRayId;
51+
this.shouldRetry = shouldRetry;
4852
}
4953

5054
public static FetchResponse fetched(Entry entry, String cfRayId) {
51-
return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, false, cfRayId);
55+
return new FetchResponse(Status.FETCHED, entry == null ? Entry.EMPTY : entry, null, false, cfRayId, false);
5256
}
5357

5458
public static FetchResponse notModified(String cfRayId) {
55-
return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, true, cfRayId);
59+
return new FetchResponse(Status.NOT_MODIFIED, Entry.EMPTY, null, true, cfRayId, false);
5660
}
5761

58-
public static FetchResponse failed(Object error, boolean fetchTimeUpdatable, String cfRayId) {
59-
return new FetchResponse(Status.FAILED, Entry.EMPTY, error, fetchTimeUpdatable, cfRayId);
62+
public static FetchResponse failed(Object error, boolean fetchTimeUpdatable, String cfRayId, boolean shouldRetry) {
63+
return new FetchResponse(Status.FAILED, Entry.EMPTY, error, fetchTimeUpdatable, cfRayId, shouldRetry);
6064
}
6165
}

src/test/java/com/configcat/AutoPollingTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public void get() throws InterruptedException, ExecutionException, IOException {
6969
@Test
7070
public void getFail() throws InterruptedException, ExecutionException, IOException {
7171
this.server.enqueue(new MockResponse().setResponseCode(500).setBody(""));
72+
this.server.enqueue(new MockResponse().setResponseCode(500).setBody(""));
7273

7374
ConfigCache cache = new NullConfigCache();
7475
PollingMode pollingMode = PollingModes.autoPoll(2);
@@ -133,6 +134,7 @@ public void getMany() throws InterruptedException, ExecutionException, IOExcepti
133134
public void getWithFailedRefresh() throws InterruptedException, ExecutionException, IOException {
134135
this.server.enqueue(new MockResponse().setResponseCode(200).setBody(String.format(TEST_JSON, "test")));
135136
this.server.enqueue(new MockResponse().setResponseCode(500));
137+
this.server.enqueue(new MockResponse().setResponseCode(500));
136138

137139
ConfigCache cache = new NullConfigCache();
138140
PollingMode pollingMode = PollingModes.autoPoll(2);

src/test/java/com/configcat/ConfigCatClientIntegrationTest.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.configcat;
22

3-
import okhttp3.OkHttpClient;
43
import okhttp3.mockwebserver.MockResponse;
54
import okhttp3.mockwebserver.MockWebServer;
65
import org.junit.jupiter.api.AfterEach;
@@ -33,7 +32,6 @@ void setUp() throws IOException {
3332
this.server.start();
3433

3534
this.client = ConfigCatClient.get(Helpers.SDK_KEY, options -> {
36-
options.httpClient(new OkHttpClient.Builder().build());
3735
options.pollingMode(PollingModes.lazyLoad(2));
3836
options.baseUrl(this.server.url("/").toString());
3937
});
@@ -195,7 +193,7 @@ void invalidateCacheFail() {
195193

196194
@Test
197195
void getConfigurationJsonStringWithDefaultConfigTimeout() {
198-
ConfigCatClient cl = ConfigCatClient.get("configcat-sdk-1/TEST_KEY1-123456789012/1234567890123456789012", options -> options.httpClient(new OkHttpClient.Builder().readTimeout(2, TimeUnit.SECONDS).build()));
196+
ConfigCatClient cl = ConfigCatClient.get("configcat-sdk-1/TEST_KEY1-123456789012/1234567890123456789012", options -> options.httpOptions().readTimeoutMillis(2000));
199197

200198
// makes a call to a real url which would fail, null expected
201199
String config = cl.getValue(String.class, "test", null);

0 commit comments

Comments
 (0)