Skip to content
This repository was archived by the owner on May 30, 2024. It is now read-only.

Commit b39e37e

Browse files
authored
Merge pull request #75 from launchdarkly/eb/ch18901/4xx-errors
treat most 4xx errors as unrecoverable
2 parents e451ba7 + c7b0c75 commit b39e37e

File tree

10 files changed

+234
-71
lines changed

10 files changed

+234
-71
lines changed

src/main/java/com/launchdarkly/client/DefaultEventProcessor.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
import java.util.concurrent.atomic.AtomicLong;
2626

2727
import static com.launchdarkly.client.Util.getRequestBuilder;
28+
import static com.launchdarkly.client.Util.httpErrorMessage;
29+
import static com.launchdarkly.client.Util.isHttpErrorRecoverable;
2830

2931
import okhttp3.MediaType;
3032
import okhttp3.Request;
@@ -386,9 +388,12 @@ private void handleResponse(Response response) {
386388
} catch (ParseException e) {
387389
}
388390
}
389-
if (response.code() == 401) {
391+
if (!isHttpErrorRecoverable(response.code())) {
390392
disabled.set(true);
391-
logger.error("Received 401 error, no further events will be posted since SDK key is invalid");
393+
logger.error(httpErrorMessage(response.code(), "posting events", "some events were dropped"));
394+
// It's "some events were dropped" because we're not going to retry *this* request any more times -
395+
// we only get to this point if we have used up our retry attempts. So the last batch of events was
396+
// lost, even though we will still try to post *other* events in the future.
392397
}
393398
}
394399
}
@@ -530,7 +535,7 @@ private void postEvents(List<EventOutput> eventsOut) {
530535
logger.debug("Event delivery took {} ms, response status {}", endTime - startTime, response.code());
531536
if (!response.isSuccessful()) {
532537
logger.warn("Unexpected response status when posting events: {}", response.code());
533-
if (response.code() >= 500) {
538+
if (isHttpErrorRecoverable(response.code())) {
534539
continue;
535540
}
536541
}

src/main/java/com/launchdarkly/client/FeatureRequestor.java

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,27 @@ static class AllData {
3737
this.config = config;
3838
}
3939

40-
Map<String, FeatureFlag> getAllFlags() throws IOException, InvalidSDKKeyException {
40+
Map<String, FeatureFlag> getAllFlags() throws IOException, HttpErrorException {
4141
String body = get(GET_LATEST_FLAGS_PATH);
4242
return FeatureFlag.fromJsonMap(config, body);
4343
}
4444

45-
FeatureFlag getFlag(String featureKey) throws IOException, InvalidSDKKeyException {
45+
FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException {
4646
String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey);
4747
return FeatureFlag.fromJson(config, body);
4848
}
4949

50-
Map<String, Segment> getAllSegments() throws IOException, InvalidSDKKeyException {
50+
Map<String, Segment> getAllSegments() throws IOException, HttpErrorException {
5151
String body = get(GET_LATEST_SEGMENTS_PATH);
5252
return Segment.fromJsonMap(config, body);
5353
}
5454

55-
Segment getSegment(String segmentKey) throws IOException, InvalidSDKKeyException {
55+
Segment getSegment(String segmentKey) throws IOException, HttpErrorException {
5656
String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey);
5757
return Segment.fromJson(config, body);
5858
}
5959

60-
AllData getAllData() throws IOException, InvalidSDKKeyException {
60+
AllData getAllData() throws IOException, HttpErrorException {
6161
String body = get(GET_LATEST_ALL_PATH);
6262
return config.gson.fromJson(body, AllData.class);
6363
}
@@ -69,7 +69,7 @@ AllData getAllData() throws IOException, InvalidSDKKeyException {
6969
return ret;
7070
}
7171

72-
private String get(String path) throws IOException, InvalidSDKKeyException {
72+
private String get(String path) throws IOException, HttpErrorException {
7373
Request request = getRequestBuilder(sdkKey)
7474
.url(config.baseURI.toString() + path)
7575
.get()
@@ -81,12 +81,7 @@ private String get(String path) throws IOException, InvalidSDKKeyException {
8181
String body = response.body().string();
8282

8383
if (!response.isSuccessful()) {
84-
if (response.code() == 401) {
85-
logger.error("[401] Invalid SDK key when accessing URI: " + request.url());
86-
throw new InvalidSDKKeyException();
87-
}
88-
throw new IOException("Unexpected response when retrieving Feature Flag(s): " + response + " using url: "
89-
+ request.url() + " with body: " + body);
84+
throw new HttpErrorException(response.code());
9085
}
9186
logger.debug("Get flag(s) response: " + response.toString() + " with body: " + body);
9287
logger.debug("Network response: " + response.networkResponse());
@@ -98,10 +93,4 @@ private String get(String path) throws IOException, InvalidSDKKeyException {
9893
return body;
9994
}
10095
}
101-
102-
@SuppressWarnings("serial")
103-
public static class InvalidSDKKeyException extends Exception {
104-
public InvalidSDKKeyException() {
105-
}
106-
}
10796
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.launchdarkly.client;
2+
3+
@SuppressWarnings("serial")
4+
class HttpErrorException extends Exception {
5+
private final int status;
6+
7+
public HttpErrorException(int status) {
8+
super("HTTP error " + status);
9+
this.status = status;
10+
}
11+
12+
public int getStatus() {
13+
return status;
14+
}
15+
}

src/main/java/com/launchdarkly/client/LDConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ protected LDConfig(Builder builder) {
113113
.connectTimeout(connectTimeoutMillis, TimeUnit.MILLISECONDS)
114114
.readTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS)
115115
.writeTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS)
116-
.retryOnConnectionFailure(true);
116+
.retryOnConnectionFailure(false); // we will implement our own retry logic
117117

118118
// When streaming is enabled, http GETs made by FeatureRequester will
119119
// always guarantee a new flag state. So, disable http response caching

src/main/java/com/launchdarkly/client/PollingProcessor.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22

33
import com.google.common.util.concurrent.SettableFuture;
44
import com.google.common.util.concurrent.ThreadFactoryBuilder;
5+
56
import org.slf4j.Logger;
67
import org.slf4j.LoggerFactory;
78

89
import java.io.IOException;
9-
import java.util.concurrent.*;
10+
import java.util.concurrent.Executors;
11+
import java.util.concurrent.Future;
12+
import java.util.concurrent.ScheduledExecutorService;
13+
import java.util.concurrent.ThreadFactory;
14+
import java.util.concurrent.TimeUnit;
1015
import java.util.concurrent.atomic.AtomicBoolean;
1116

17+
import static com.launchdarkly.client.Util.httpErrorMessage;
18+
import static com.launchdarkly.client.Util.isHttpErrorRecoverable;
19+
1220
class PollingProcessor implements UpdateProcessor {
1321
private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class);
1422

@@ -55,10 +63,12 @@ public void run() {
5563
logger.info("Initialized LaunchDarkly client.");
5664
initFuture.set(null);
5765
}
58-
} catch (FeatureRequestor.InvalidSDKKeyException e) {
59-
logger.error("Received 401 error, no further polling requests will be made since SDK key is invalid");
60-
scheduler.shutdown();
61-
initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited
66+
} catch (HttpErrorException e) {
67+
logger.error(httpErrorMessage(e.getStatus(), "polling request", "will retry"));
68+
if (!isHttpErrorRecoverable(e.getStatus())) {
69+
scheduler.shutdown();
70+
initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited
71+
}
6272
} catch (IOException e) {
6373
logger.error("Encountered exception in LaunchDarkly client when retrieving update", e);
6474
}

src/main/java/com/launchdarkly/client/StreamProcessor.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import java.util.concurrent.Future;
1818
import java.util.concurrent.atomic.AtomicBoolean;
1919

20+
import static com.launchdarkly.client.Util.httpErrorMessage;
21+
import static com.launchdarkly.client.Util.isHttpErrorRecoverable;
2022
import static com.launchdarkly.client.VersionedDataKind.FEATURES;
2123
import static com.launchdarkly.client.VersionedDataKind.SEGMENTS;
2224

@@ -65,11 +67,13 @@ public Future<Void> start() {
6567
ConnectionErrorHandler connectionErrorHandler = new ConnectionErrorHandler() {
6668
@Override
6769
public Action onConnectionError(Throwable t) {
68-
if ((t instanceof UnsuccessfulResponseException) &&
69-
((UnsuccessfulResponseException) t).getCode() == 401) {
70-
logger.error("Received 401 error, no further streaming connection will be made since SDK key is invalid");
71-
initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited
72-
return Action.SHUTDOWN;
70+
if (t instanceof UnsuccessfulResponseException) {
71+
int status = ((UnsuccessfulResponseException)t).getCode();
72+
logger.error(httpErrorMessage(status, "streaming connection", "will retry"));
73+
if (!isHttpErrorRecoverable(status)) {
74+
initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited
75+
return Action.SHUTDOWN;
76+
}
7377
}
7478
return Action.PROCEED;
7579
}

src/main/java/com/launchdarkly/client/Util.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,42 @@ static Request.Builder getRequestBuilder(String sdkKey) {
3232
.addHeader("Authorization", sdkKey)
3333
.addHeader("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION);
3434
}
35+
36+
/**
37+
* Tests whether an HTTP error status represents a condition that might resolve on its own if we retry.
38+
* @param statusCode the HTTP status
39+
* @return true if retrying makes sense; false if it should be considered a permanent failure
40+
*/
41+
static boolean isHttpErrorRecoverable(int statusCode) {
42+
if (statusCode >= 400 && statusCode < 500) {
43+
switch (statusCode) {
44+
case 408: // request timeout
45+
case 429: // too many requests
46+
return true;
47+
default:
48+
return false; // all other 4xx errors are unrecoverable
49+
}
50+
}
51+
return true;
52+
}
53+
54+
/**
55+
* Builds an appropriate log message for an HTTP error status.
56+
* @param statusCode the HTTP status
57+
* @param context description of what we were trying to do
58+
* @param recoverableMessage description of our behavior if the error is recoverable; typically "will retry"
59+
* @return a message string
60+
*/
61+
static String httpErrorMessage(int statusCode, String context, String recoverableMessage) {
62+
StringBuilder sb = new StringBuilder();
63+
sb.append("Received HTTP error ").append(statusCode);
64+
switch (statusCode) {
65+
case 401:
66+
case 403:
67+
sb.append(" (invalid SDK key)");
68+
}
69+
sb.append(" for ").append(context).append(" - ");
70+
sb.append(isHttpErrorRecoverable(statusCode) ? recoverableMessage : "giving up permanently");
71+
return sb.toString();
72+
}
3573
}

src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import com.launchdarkly.client.DefaultEventProcessor.EventDispatcher;
99

1010
import org.hamcrest.Matcher;
11-
import org.hamcrest.Matchers;
1211
import org.junit.After;
1312
import org.junit.Before;
1413
import org.junit.Test;
@@ -391,29 +390,60 @@ public void sdkKeyIsSent() throws Exception {
391390

392391
assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY));
393392
}
393+
394+
@Test
395+
public void http401ErrorIsUnrecoverable() throws Exception {
396+
testUnrecoverableHttpError(401);
397+
}
398+
399+
@Test
400+
public void http403ErrorIsUnrecoverable() throws Exception {
401+
testUnrecoverableHttpError(403);
402+
}
403+
404+
// Cannot test our retry logic for 408, because OkHttp insists on doing its own retry on 408 so that
405+
// we never actually see that response status.
406+
// @Test
407+
// public void http408ErrorIsRecoverable() throws Exception {
408+
// testRecoverableHttpError(408);
409+
// }
410+
411+
@Test
412+
public void http429ErrorIsRecoverable() throws Exception {
413+
testRecoverableHttpError(429);
414+
}
415+
416+
@Test
417+
public void http500ErrorIsRecoverable() throws Exception {
418+
testRecoverableHttpError(500);
419+
}
394420

395421
@Test
396-
public void noMorePayloadsAreSentAfter401Error() throws Exception {
422+
public void flushIsRetriedOnceAfter5xxError() throws Exception {
423+
}
424+
425+
private void testUnrecoverableHttpError(int status) throws Exception {
397426
ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build());
398427
Event e = EventFactory.DEFAULT.newIdentifyEvent(user);
399428
ep.sendEvent(e);
400-
flushAndGetEvents(new MockResponse().setResponseCode(401));
429+
flushAndGetEvents(new MockResponse().setResponseCode(status));
401430

402431
ep.sendEvent(e);
403432
ep.flush();
404433
ep.waitUntilInactive();
405434
RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS);
406435
assertThat(req, nullValue(RecordedRequest.class));
407436
}
408-
409-
@Test
410-
public void flushIsRetriedOnceAfter5xxError() throws Exception {
437+
438+
private void testRecoverableHttpError(int status) throws Exception {
411439
ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build());
412440
Event e = EventFactory.DEFAULT.newIdentifyEvent(user);
413441
ep.sendEvent(e);
414442

415-
server.enqueue(new MockResponse().setResponseCode(503));
416-
server.enqueue(new MockResponse().setResponseCode(503));
443+
server.enqueue(new MockResponse().setResponseCode(status));
444+
server.enqueue(new MockResponse().setResponseCode(status));
445+
server.enqueue(new MockResponse());
446+
// need two responses because flush will be retried one time
417447

418448
ep.flush();
419449
ep.waitUntilInactive();

0 commit comments

Comments
 (0)