Skip to content

Commit 09aee6c

Browse files
authored
Merge pull request #61 from launchdarkly/eb/ch18901/4xx-errors
fail permanently on most 4xx errors
2 parents c0aa058 + a347d9f commit 09aee6c

File tree

6 files changed

+84
-25
lines changed

6 files changed

+84
-25
lines changed

ldclient/event_processor.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ldclient.util import _headers
2727
from ldclient.util import create_http_pool_manager
2828
from ldclient.util import log
29-
from ldclient.util import throw_if_unsuccessful_response
29+
from ldclient.util import http_error_message, is_http_error_recoverable, throw_if_unsuccessful_response
3030

3131

3232
__MAX_FLUSH_THREADS__ = 5
@@ -174,7 +174,6 @@ def _do_send(self, output_events):
174174
body=json_body,
175175
retries=1)
176176
self._response_fn(r)
177-
throw_if_unsuccessful_response(r)
178177
return r
179178
except Exception as e:
180179
log.warning(
@@ -323,10 +322,11 @@ def _handle_response(self, r):
323322
if server_date is not None:
324323
timestamp = int(time.mktime(server_date) * 1000)
325324
self._last_known_past_time = timestamp
326-
if r.status == 401:
327-
log.error('Received 401 error, no further events will be posted since SDK key is invalid')
328-
self._disabled = True
329-
return
325+
if r.status > 299:
326+
log.error(http_error_message(r.status, "event delivery", "some events were dropped"))
327+
if not is_http_error_recoverable(r.status):
328+
self._disabled = True
329+
return
330330

331331
def _do_shutdown(self):
332332
self._flush_workers.stop()

ldclient/polling.py

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

33
from ldclient.interfaces import UpdateProcessor
44
from ldclient.util import log
5-
from ldclient.util import UnsuccessfulResponseException
5+
from ldclient.util import UnsuccessfulResponseException, http_error_message, is_http_error_recoverable
66

77
import time
88

@@ -30,15 +30,14 @@ def run(self):
3030
log.info("PollingUpdateProcessor initialized ok")
3131
self._ready.set()
3232
except UnsuccessfulResponseException as e:
33-
log.error('Received unexpected status code %d from polling request' % e.status)
34-
if e.status == 401:
35-
log.error('Received 401 error, no further polling requests will be made since SDK key is invalid')
33+
log.error(http_error_message(e.status, "polling request"))
34+
if not is_http_error_recoverable(e.status):
3635
self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited
3736
self.stop()
3837
break
39-
except Exception:
38+
except Exception as e:
4039
log.exception(
41-
'Error: Exception encountered when updating flags.')
40+
'Error: Exception encountered when updating flags. %s' % e)
4241

4342
elapsed = time.time() - start_time
4443
if elapsed < self._config.poll_interval:

ldclient/streaming.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ldclient.interfaces import UpdateProcessor
1111
from ldclient.sse_client import SSEClient
12-
from ldclient.util import _stream_headers, log, UnsuccessfulResponseException
12+
from ldclient.util import _stream_headers, log, UnsuccessfulResponseException, http_error_message, is_http_error_recoverable
1313
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
1414

1515
# allows for up to 5 minutes to elapse without any data sent across the stream. The heartbeats sent as comments on the
@@ -49,14 +49,11 @@ def run(self):
4949
log.info("StreamingUpdateProcessor initialized ok.")
5050
self._ready.set()
5151
except UnsuccessfulResponseException as e:
52-
log.error("Received unexpected status code %d for stream connection" % e.status)
53-
if e.status == 401:
54-
log.error("Received 401 error, no further streaming connection will be made since SDK key is invalid")
52+
log.error(http_error_message(e.status, "stream connection"))
53+
if not is_http_error_recoverable(e.status):
5554
self._ready.set() # if client is initializing, make it stop waiting; has no effect if already inited
5655
self.stop()
5756
break
58-
else:
59-
log.warning("Restarting stream connection after one second.")
6057
except Exception as e:
6158
log.warning("Caught exception. Restarting stream connection after one second. %s" % e)
6259
# no stacktrace here because, for a typical connection error, it'll just be a lengthy tour of urllib3 internals
@@ -66,7 +63,7 @@ def _backoff_expo():
6663
return backoff.expo(max_value=30)
6764

6865
def should_not_retry(e):
69-
return isinstance(e, UnsuccessfulResponseException) and (e.response.status_code == 401)
66+
return isinstance(e, UnsuccessfulResponseException) and (not is_http_error_recoverable(e.status))
7067

7168
@backoff.on_exception(_backoff_expo, BaseException, max_tries=None, jitter=backoff.full_jitter,
7269
giveup=should_not_retry)

ldclient/util.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,18 @@ def create_http_pool_manager(num_pools=1, verify_ssl=False):
9393
def throw_if_unsuccessful_response(resp):
9494
if resp.status >= 400:
9595
raise UnsuccessfulResponseException(resp.status)
96+
97+
98+
def is_http_error_recoverable(status):
99+
if status >= 400 and status < 500:
100+
return (status == 400) or (status == 408) or (status == 429) # all other 4xx besides these are unrecoverable
101+
return True # all other errors are recoverable
102+
103+
104+
def http_error_message(status, context, retryable_message = "will retry"):
105+
return "Received HTTP error %d%s for %s - %s" % (
106+
status,
107+
" (invalid SDK key)" if (status == 401 or status == 403) else "",
108+
context,
109+
retryable_message if is_http_error_recoverable(status) else "giving up permanently"
110+
)

testing/test_event_processor.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,24 @@ def test_sdk_key_is_sent():
362362
assert mock_http.request_headers.get('Authorization') is 'SDK_KEY'
363363

364364
def test_no_more_payloads_are_sent_after_401_error():
365+
verify_unrecoverable_http_error(401)
366+
367+
def test_no_more_payloads_are_sent_after_403_error():
368+
verify_unrecoverable_http_error(403)
369+
370+
def test_will_still_send_after_408_error():
371+
verify_recoverable_http_error(408)
372+
373+
def test_will_still_send_after_429_error():
374+
verify_recoverable_http_error(429)
375+
376+
def test_will_still_send_after_500_error():
377+
verify_recoverable_http_error(500)
378+
379+
def verify_unrecoverable_http_error(status):
365380
setup_processor(Config(sdk_key = 'SDK_KEY'))
366381

367-
mock_http.set_response_status(401)
382+
mock_http.set_response_status(status)
368383
ep.send_event({ 'kind': 'identify', 'user': user })
369384
ep.flush()
370385
ep._wait_until_inactive()
@@ -375,6 +390,19 @@ def test_no_more_payloads_are_sent_after_401_error():
375390
ep._wait_until_inactive()
376391
assert mock_http.request_data is None
377392

393+
def verify_recoverable_http_error(status):
394+
setup_processor(Config(sdk_key = 'SDK_KEY'))
395+
396+
mock_http.set_response_status(status)
397+
ep.send_event({ 'kind': 'identify', 'user': user })
398+
ep.flush()
399+
ep._wait_until_inactive()
400+
mock_http.reset()
401+
402+
ep.send_event({ 'kind': 'identify', 'user': user })
403+
ep.flush()
404+
ep._wait_until_inactive()
405+
assert mock_http.request_data is not None
378406

379407
def flush_and_get_events():
380408
ep.flush()

testing/test_polling_processor.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,30 @@ def test_general_connection_error_does_not_cause_immediate_failure():
6464
assert not pp.initialized()
6565

6666
def test_http_401_error_causes_immediate_failure():
67-
mock_requester.exception = UnsuccessfulResponseException(401)
68-
start_time = time.time()
67+
verify_unrecoverable_http_error(401)
68+
69+
def test_http_403_error_causes_immediate_failure():
70+
verify_unrecoverable_http_error(401)
71+
72+
def test_http_408_error_does_not_cause_immediate_failure():
73+
verify_recoverable_http_error(408)
74+
75+
def test_http_429_error_does_not_cause_immediate_failure():
76+
verify_recoverable_http_error(429)
77+
78+
def test_http_500_error_does_not_cause_immediate_failure():
79+
verify_recoverable_http_error(500)
80+
81+
def verify_unrecoverable_http_error(status):
82+
mock_requester.exception = UnsuccessfulResponseException(status)
6983
setup_processor(config)
70-
ready.wait(5.0)
71-
elapsed_time = time.time() - start_time
72-
assert elapsed_time < 5.0
84+
finished = ready.wait(5.0)
85+
assert finished
86+
assert not pp.initialized()
87+
88+
def verify_recoverable_http_error(status):
89+
mock_requester.exception = UnsuccessfulResponseException(status)
90+
setup_processor(config)
91+
finished = ready.wait(0.2)
92+
assert not finished
7393
assert not pp.initialized()

0 commit comments

Comments
 (0)