Skip to content

Commit d7ed6fe

Browse files
authored
FFM-5111 - Java SDK - Polls non stop if /stream endpoint returns 5xx error (#132)
* FFM-5111 - Java SDK - Polls non stop if /stream endpoint returns 5xx error What If a /stream endpoint returns a 501 HTTP error code (unimplemented) then the SSE library attempts reconnect endlessly Why The code is assuming 501 is a transient http error - so keeps retrying it rather than backing off for good Testing Added new mock web server dispatcher to mimic a 501 response when /stream is connected to. New assertion were added to ensure SDK doesn't attempt to reconnect once when a 501 is returned.
1 parent 9b6cd85 commit d7ed6fe

File tree

9 files changed

+266
-138
lines changed

9 files changed

+266
-138
lines changed

examples/pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.harness.featureflags</groupId>
88
<artifactId>examples</artifactId>
9-
<version>1.1.9</version>
9+
<version>1.1.10-SNAPSHOT</version>
1010

1111
<properties>
1212
<maven.compiler.source>8</maven.compiler.source>
@@ -33,7 +33,7 @@
3333
<dependency>
3434
<groupId>io.harness</groupId>
3535
<artifactId>ff-java-server-sdk</artifactId>
36-
<version>1.1.9</version>
36+
<version>1.1.10-SNAPSHOT</version>
3737
</dependency>
3838

3939
<dependency>

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.harness</groupId>
88
<artifactId>ff-java-server-sdk</artifactId>
9-
<version>1.1.9</version>
9+
<version>1.1.10-SNAPSHOT</version>
1010
<packaging>jar</packaging>
1111
<name>Harness Feature Flag Java Server SDK</name>
1212
<description>Harness Feature Flag Java Server SDK</description>

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,9 @@ public void onDisconnected() {
235235
pollerStartedAt = new Date();
236236
} else {
237237
log.warn(
238-
"Poller was not restarted [closing={} terminated={} pollStartTime+interval={} now={} ]",
238+
"Poller was not restarted [closing={} state={} pollStartTime+interval={} now={} ]",
239239
closing,
240-
pollProcessor.state() == Service.State.TERMINATED,
240+
pollProcessor.state(),
241241
instant,
242242
now);
243243
}
@@ -396,4 +396,8 @@ public void close() {
396396
BaseConfig getOptions() {
397397
return options;
398398
}
399+
400+
PollingProcessor getPollProcessor() {
401+
return pollProcessor;
402+
}
399403
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,20 @@ public boolean onRetryError(
110110
throwable.getClass().getSimpleName(),
111111
throwable.getMessage());
112112
log.trace("onRetryError exception", throwable);
113+
113114
updater.onError();
114115
if (response != null) {
115-
return response.code() == 429 || response.code() >= 500;
116+
return shouldRetryForHttpErrorCode(response.code());
116117
}
117118
return true;
118119
}
119120

121+
private boolean shouldRetryForHttpErrorCode(int httpCode) {
122+
if (httpCode == 501) return false;
123+
124+
return httpCode == 429 || httpCode >= 500;
125+
}
126+
120127
@Override
121128
public void onClosed(ServerSentEvent serverSentEvent) {
122129
log.info("EventSource onClosed - disconnected");

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

Lines changed: 40 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package io.harness.cf.client.api;
22

3+
import static com.google.common.util.concurrent.Service.State.RUNNING;
34
import static io.harness.cf.client.api.TestUtils.*;
4-
import static io.harness.cf.client.api.TestUtils.makeMockJsonResponse;
5+
import static io.harness.cf.client.api.dispatchers.CannedResponses.makeDummyJwtToken;
6+
import static io.harness.cf.client.api.dispatchers.CannedResponses.makeMockJsonResponse;
57
import static org.junit.jupiter.api.Assertions.*;
68

79
import com.google.common.collect.ImmutableSet;
810
import com.google.gson.JsonObject;
11+
import io.harness.cf.client.api.dispatchers.TestWebServerDispatcher;
12+
import io.harness.cf.client.api.dispatchers.UnimplementedStreamDispatcher;
913
import io.harness.cf.client.common.Cache;
1014
import io.harness.cf.client.connector.Connector;
1115
import io.harness.cf.client.connector.ConnectorException;
@@ -20,20 +24,15 @@
2024
import java.time.Duration;
2125
import java.util.Collections;
2226
import java.util.List;
23-
import java.util.Objects;
2427
import java.util.Set;
2528
import java.util.concurrent.CountDownLatch;
2629
import java.util.concurrent.TimeUnit;
2730
import java.util.concurrent.atomic.AtomicInteger;
2831
import java.util.function.Consumer;
2932
import java.util.stream.Stream;
3033
import lombok.NonNull;
31-
import lombok.SneakyThrows;
32-
import okhttp3.mockwebserver.Dispatcher;
3334
import okhttp3.mockwebserver.MockResponse;
3435
import okhttp3.mockwebserver.MockWebServer;
35-
import okhttp3.mockwebserver.RecordedRequest;
36-
import org.jetbrains.annotations.NotNull;
3736
import org.junit.jupiter.api.AfterEach;
3837
import org.junit.jupiter.api.Test;
3938
import org.junit.jupiter.params.ParameterizedTest;
@@ -248,35 +247,6 @@ private static void testJsonVariantWithNullTarget(CfClient client) {
248247
assertEquals("on", value.get("value").getAsString());
249248
}
250249

251-
static class TestWebServerDispatcher extends Dispatcher {
252-
private final AtomicInteger version = new AtomicInteger(2);
253-
254-
@Override
255-
@SneakyThrows
256-
@NotNull
257-
public MockResponse dispatch(@NotNull RecordedRequest recordedRequest)
258-
throws InterruptedException {
259-
System.out.println("DISPATCH GOT ------> " + recordedRequest.getPath());
260-
261-
switch (Objects.requireNonNull(recordedRequest.getPath())) {
262-
case "/api/1.0/client/auth":
263-
return makeAuthResponse();
264-
case "/api/1.0/client/env/00000000-0000-0000-0000-000000000000/feature-configs?cluster=1":
265-
return makeMockJsonResponse(200, makeBasicFeatureJson());
266-
case "/api/1.0/client/env/00000000-0000-0000-0000-000000000000/target-segments?cluster=1":
267-
return makeMockJsonResponse(200, makeSegmentsJson());
268-
case "/api/1.0/stream?cluster=1":
269-
return makeMockStreamResponse(
270-
200, makeFlagPatchEvent("simplebool", version.getAndIncrement()));
271-
case "/api/1.0/client/env/00000000-0000-0000-0000-000000000000/feature-configs/simplebool?cluster=1":
272-
return makeMockSingleBoolFlagResponse(200, "simplebool", "off", version.get());
273-
default:
274-
throw new UnsupportedOperationException(
275-
"ERROR: url not mapped " + recordedRequest.getPath());
276-
}
277-
}
278-
}
279-
280250
@Test
281251
void streamPatchTest() throws Exception {
282252
BaseConfig config =
@@ -310,6 +280,41 @@ void streamPatchTest() throws Exception {
310280
}
311281
}
312282

283+
@Test
284+
void shouldNotReconnectToStreamEndpointIfEndpointReturns501Unimplemented() throws Exception {
285+
BaseConfig config =
286+
BaseConfig.builder()
287+
.pollIntervalInSeconds(1)
288+
.analyticsEnabled(false)
289+
.streamEnabled(true)
290+
.build();
291+
292+
// This will return 501 when /stream endpoint is connected to
293+
UnimplementedStreamDispatcher webserverDispatcher = new UnimplementedStreamDispatcher(1);
294+
295+
try (MockWebServer mockSvr = new MockWebServer()) {
296+
mockSvr.setDispatcher(webserverDispatcher);
297+
mockSvr.start();
298+
299+
try (CfClient client =
300+
new CfClient(makeConnector(mockSvr.getHostName(), mockSvr.getPort()), config)) {
301+
302+
client.waitForInitialization();
303+
304+
// we should only get one connection to /stream within this window of time
305+
webserverDispatcher.waitForAllConnections(15);
306+
assertEquals(
307+
1,
308+
webserverDispatcher.getStreamEndpointCount().get(),
309+
"there should only be 1 connection to /stream endpoint");
310+
assertEquals(
311+
RUNNING,
312+
client.getInnerClient().getPollProcessor().state(),
313+
"poller not in RUNNING state");
314+
}
315+
}
316+
}
317+
313318
private static void onEvent(String e) {
314319
if ("simplebool".equals(e)) {
315320
onEventCounter.incrementAndGet();
Lines changed: 6 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,35 @@
11
package io.harness.cf.client.api;
22

3-
import com.google.gson.Gson;
43
import io.harness.cf.client.connector.HarnessConfig;
54
import io.harness.cf.client.connector.HarnessConnector;
6-
import io.harness.cf.model.*;
75
import java.io.IOException;
86
import java.net.URISyntaxException;
9-
import java.nio.charset.StandardCharsets;
107
import java.nio.file.Files;
118
import java.nio.file.Path;
129
import java.nio.file.Paths;
13-
import java.util.Arrays;
14-
import java.util.Base64;
15-
import java.util.Collections;
16-
import lombok.AllArgsConstructor;
17-
import lombok.Data;
18-
import okhttp3.mockwebserver.MockResponse;
1910

20-
class TestUtils {
11+
public class TestUtils {
2112

22-
@Data
23-
@AllArgsConstructor
24-
static class Event {
25-
String event, domain, identifier;
26-
int version;
27-
}
28-
29-
static MockResponse makeMockJsonResponse(int httpCode, String body) {
30-
return new MockResponse()
31-
.setResponseCode(httpCode)
32-
.setBody(body)
33-
.addHeader("Content-Type", "application/json; charset=UTF-8");
34-
}
35-
36-
static String makeSegmentsJson() throws IOException, URISyntaxException {
13+
public static String makeSegmentsJson() throws IOException, URISyntaxException {
3714
return getJsonResource("local-test-cases/segments.json");
3815
}
3916

40-
static String makeFeatureJson() throws IOException, URISyntaxException {
17+
public static String makeFeatureJson() throws IOException, URISyntaxException {
4118
return getJsonResource("local-test-cases/percentage-rollout-with-zero-weights.json");
4219
}
4320

44-
static String makeBasicFeatureJson() throws IOException, URISyntaxException {
21+
public static String makeBasicFeatureJson() throws IOException, URISyntaxException {
4522
return getJsonResource("local-test-cases/basic_bool_string_number_json_variations.json");
4623
}
4724

48-
static MockResponse makeAuthResponse() {
49-
return makeMockJsonResponse(200, "{\"authToken\": \"" + makeDummyJwtToken() + "\"}");
50-
}
51-
52-
static MockResponse makeMockStreamResponse(int httpCode, Event... events) {
53-
54-
final StringBuilder builder = new StringBuilder();
55-
Arrays.stream(events)
56-
.forEach(
57-
e -> builder.append("event: *\ndata: ").append(new Gson().toJson(e)).append("\n\n"));
58-
59-
return new MockResponse()
60-
.setResponseCode(httpCode)
61-
.setBody(builder.toString())
62-
.addHeader("Content-Type", "text/event-stream; charset=UTF-8")
63-
.addHeader("Accept-Encoding", "identity");
64-
}
65-
66-
static MockResponse makeMockSingleBoolFlagResponse(
67-
int httpCode, String flagName, String state, int version) {
68-
final FeatureConfig flag = makeFlag(flagName, state, version);
69-
return makeMockJsonResponse(httpCode, new Gson().toJson(flag));
70-
}
71-
72-
static HarnessConnector makeConnector(String host, int port) {
25+
public static HarnessConnector makeConnector(String host, int port) {
7326
final String url = String.format("http://%s:%s/api/1.0", host, port);
7427
return new HarnessConnector(
7528
"dummykey", HarnessConfig.builder().readTimeout(1000).configUrl(url).eventUrl(url).build());
7629
}
7730

78-
static String makeDummyJwtToken() {
79-
final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
80-
final String payload =
81-
"{\"environment\":\"00000000-0000-0000-0000-000000000000\","
82-
+ "\"environmentIdentifier\":\"Production\","
83-
+ "\"project\":\"00000000-0000-0000-0000-000000000000\","
84-
+ "\"projectIdentifier\":\"dev\","
85-
+ "\"accountID\":\"aaaaa_BBBBB-cccccccccc\","
86-
+ "\"organization\":\"00000000-0000-0000-0000-000000000000\","
87-
+ "\"organizationIdentifier\":\"default\","
88-
+ "\"clusterIdentifier\":\"1\","
89-
+ "\"key_type\":\"Server\"}";
90-
final byte[] hmac256 = new byte[32];
91-
return Base64.getEncoder().encodeToString(header.getBytes(StandardCharsets.UTF_8))
92-
+ "."
93-
+ Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8))
94-
+ "."
95-
+ Base64.getEncoder().encodeToString(hmac256);
96-
}
97-
98-
static String getJsonResource(String location) throws IOException, URISyntaxException {
31+
public static String getJsonResource(String location) throws IOException, URISyntaxException {
9932
final Path path = Paths.get(EvaluatorTest.class.getClassLoader().getResource(location).toURI());
10033
return new String(Files.readAllBytes(path));
10134
}
102-
103-
static Event makeFlagPatchEvent(String identifier, int version) {
104-
return new Event("patch", "flag", identifier, version);
105-
}
106-
107-
static FeatureConfig makeFlag(String flagName, String state, int version) {
108-
final FeatureConfig flag = new FeatureConfig();
109-
flag.setDefaultServe(new Serve().variation("true"));
110-
flag.setEnvironment("DUMMY");
111-
flag.setFeature(flagName);
112-
flag.setKind(FeatureConfig.KindEnum.BOOLEAN);
113-
flag.setOffVariation("false");
114-
flag.setPrerequisites(Collections.emptyList());
115-
flag.setProject("DUMMYPROJ");
116-
flag.setRules(Collections.emptyList());
117-
flag.setState(FeatureState.fromValue(state));
118-
flag.setVariationToTargetMap(Collections.emptyList());
119-
flag.setVariations(
120-
Arrays.asList(
121-
new Variation("true", "true", "True", "desc"),
122-
new Variation("false", "false", "False", "desc")));
123-
flag.setVersion((long) version);
124-
return flag;
125-
}
12635
}

0 commit comments

Comments
 (0)