Skip to content

Commit fea1045

Browse files
author
Sam Park
authored
Merge pull request #8 from GoodRx/simpler-retries
Simplify API retries while obeying rate limits
2 parents 748814d + 130f410 commit fea1045

File tree

4 files changed

+112
-41
lines changed

4 files changed

+112
-41
lines changed

braze/client.py

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,59 @@
1-
import json
21
import time
32

43
import requests
54
from requests.exceptions import RequestException
65
from tenacity import retry
7-
from tenacity import retry_if_exception_type
86
from tenacity import stop_after_attempt
97
from tenacity import wait_random_exponential
108

9+
DEFAULT_API_URL = "https://rest.iad-02.braze.com"
10+
USER_TRACK_ENDPOINT = "/users/track"
11+
USER_DELETE_ENDPOINT = "/users/delete"
12+
MAX_RETRIES = 3
13+
# Max time to wait between API call retries
14+
MAX_WAIT_SECONDS = 1.25
15+
1116

1217
class BrazeRateLimitError(Exception):
13-
pass
18+
def __init__(self, reset_epoch_s):
19+
"""
20+
A rate limit error was encountered.
21+
22+
:param float reset_epoch_s: Unix timestamp for when the API may be called again.
23+
"""
24+
self.reset_epoch_s = reset_epoch_s
25+
super(BrazeRateLimitError, self).__init__("BrazeRateLimitError")
1426

1527

1628
class BrazeInternalServerError(Exception):
1729
pass
1830

1931

32+
def _wait_random_exp_or_rate_limit():
33+
"""Creates a tenacity wait callback that accounts for explicit rate limits."""
34+
random_exp = wait_random_exponential(multiplier=1, max=MAX_WAIT_SECONDS)
35+
36+
def check(retry_state):
37+
"""
38+
Waits with either a random exponential backoff or attempts to obey rate limits
39+
that Braze returns.
40+
41+
:param tenacity.RetryCallState retry_state: Info about current retry invocation
42+
:raises BrazeRateLimitError: If the rate limit reset time is too long
43+
:returns: Time to wait, in seconds.
44+
:rtype: float
45+
"""
46+
exc = retry_state.outcome.exception()
47+
if isinstance(exc, BrazeRateLimitError):
48+
sec_to_reset = exc.reset_epoch_s - float(time.time())
49+
if sec_to_reset >= MAX_WAIT_SECONDS:
50+
raise exc
51+
return max(0.0, sec_to_reset)
52+
return random_exp(retry_state=retry_state)
53+
54+
return check
55+
56+
2057
class BrazeClient(object):
2158
"""
2259
Client for Appboy public API. Support user_track.
@@ -42,15 +79,9 @@ class BrazeClient(object):
4279
print r['errors']
4380
"""
4481

45-
DEFAULT_API_URL = "https://rest.iad-02.braze.com"
46-
USER_TRACK_ENDPOINT = "/users/track"
47-
USER_DELETE_ENDPOINT = "/users/delete"
48-
MAX_RETRIES = 3
49-
MAX_WAIT_SECONDS = 1.25
50-
5182
def __init__(self, api_key, api_url=None):
5283
self.api_key = api_key
53-
self.api_url = api_url or self.DEFAULT_API_URL
84+
self.api_url = api_url or DEFAULT_API_URL
5485
self.request_url = ""
5586

5687
def user_track(self, attributes, events, purchases):
@@ -61,7 +92,7 @@ def user_track(self, attributes, events, purchases):
6192
:param purchases: dict or list of user purchases dict (external_id, app_id, product_id, currency, price)
6293
:return: json dict response, for example: {"message": "success", "errors": [], "client_error": ""}
6394
"""
64-
self.request_url = self.api_url + self.USER_TRACK_ENDPOINT
95+
self.request_url = self.api_url + USER_TRACK_ENDPOINT
6596

6697
payload = {}
6798

@@ -83,7 +114,7 @@ def user_delete(self, external_ids, appboy_ids):
83114
:param appboy_ids: dict or list of user braze ids
84115
:return: json dict response, for example: {"message": "success", "errors": [], "client_error": ""}
85116
"""
86-
self.request_url = self.api_url + self.USER_DELETE_ENDPOINT
117+
self.request_url = self.api_url + USER_DELETE_ENDPOINT
87118

88119
payload = {}
89120

@@ -129,35 +160,19 @@ def __create_request(self, payload):
129160

130161
@retry(
131162
reraise=True,
132-
wait=wait_random_exponential(multiplier=1, max=MAX_WAIT_SECONDS),
163+
wait=_wait_random_exp_or_rate_limit(),
133164
stop=stop_after_attempt(MAX_RETRIES),
134-
retry=(
135-
retry_if_exception_type(BrazeInternalServerError)
136-
| retry_if_exception_type(RequestException)
137-
),
138165
)
139-
def _post_request_with_retries(self, payload, retry_attempt=0):
166+
def _post_request_with_retries(self, payload):
140167
"""
141168
:param dict payload:
142-
:param int retry_attempt: current retry attempt number
143-
:rtype: dict
169+
:rtype: requests.Response
144170
"""
145-
headers = {"Content-Type": "application/json"}
146-
r = requests.post(
147-
self.request_url, data=json.dumps(payload), headers=headers, timeout=2
148-
)
149-
if retry_attempt >= self.MAX_RETRIES:
150-
raise BrazeRateLimitError("BrazeRateLimitError")
151-
171+
r = requests.post(self.request_url, json=payload, timeout=2)
152172
# https://www.braze.com/docs/developer_guide/rest_api/messaging/#fatal-errors
153173
if r.status_code == 429:
154-
reset_epoch_seconds = float(r.headers.get("X-RateLimit-Reset"))
155-
sec_to_reset = reset_epoch_seconds - float(time.time())
156-
if sec_to_reset < self.MAX_WAIT_SECONDS:
157-
time.sleep(sec_to_reset)
158-
return self._post_request_with_retries(payload, retry_attempt + 1)
159-
else:
160-
raise BrazeRateLimitError("BrazeRateLimitError")
174+
reset_epoch_s = float(r.headers.get("X-RateLimit-Reset", 0))
175+
raise BrazeRateLimitError(reset_epoch_s)
161176
elif str(r.status_code).startswith("5"):
162177
raise BrazeInternalServerError("BrazeInternalServerError")
163178
return r

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from setuptools import setup
33

44
NAME = "braze-client"
5-
VERSION = "2.0.0"
5+
VERSION = "2.1.0"
66

7-
REQUIRES = ["requests >=2.21.0, <3.0.0", "tenacity >=4.8.0, <6.0.0"]
7+
REQUIRES = ["requests >=2.21.0, <3.0.0", "tenacity >=5.0.0, <6.0.0"]
88

99
EXTRAS = {"dev": ["tox"]}
1010

tests/braze/test_client.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import time
22

3+
from braze.client import _wait_random_exp_or_rate_limit
34
from braze.client import BrazeClient
5+
from braze.client import BrazeRateLimitError
6+
from braze.client import MAX_RETRIES
7+
from braze.client import MAX_WAIT_SECONDS
48
from freezegun import freeze_time
59
import pytest
10+
from pytest import approx
611
from requests import RequestException
712
from requests_mock import ANY
13+
from tenacity import Future
14+
from tenacity import RetryCallState
815

916

1017
@pytest.fixture
@@ -27,6 +34,42 @@ def purchases():
2734
return {"external_id": "123", "name": "some_name"}
2835

2936

37+
class TestWaitRandomExpOrRateLimit(object):
38+
@pytest.fixture
39+
def retry_state(self):
40+
retry_state = RetryCallState(object(), lambda x: x, (), {})
41+
retry_state.outcome = Future(attempt_number=1)
42+
return retry_state
43+
44+
@freeze_time()
45+
def test_raises_if_too_long(self, retry_state):
46+
callback = _wait_random_exp_or_rate_limit()
47+
exc = BrazeRateLimitError(time.time() + MAX_WAIT_SECONDS + 1)
48+
retry_state.outcome.set_exception(exc)
49+
50+
with pytest.raises(BrazeRateLimitError) as e:
51+
callback(retry_state)
52+
53+
assert e.value.reset_epoch_s == exc.reset_epoch_s
54+
55+
@freeze_time()
56+
def test_doesnt_allow_negative_waits(self, retry_state):
57+
callback = _wait_random_exp_or_rate_limit()
58+
exc = BrazeRateLimitError(time.time() - 1)
59+
retry_state.outcome.set_exception(exc)
60+
61+
assert callback(retry_state) == 0.0
62+
63+
def test_uses_random_exp_for_other_exceptions(self, retry_state):
64+
callback = _wait_random_exp_or_rate_limit()
65+
retry_state.outcome.set_exception(Exception())
66+
67+
for attempt in range(10):
68+
retry_state.attempt_number = attempt
69+
for _ in range(100):
70+
assert 0 <= callback(retry_state) <= 1.5
71+
72+
3073
class TestBrazeClient(object):
3174
def test_init(self, braze_client):
3275
assert braze_client.api_key == "API_KEY"
@@ -65,7 +108,7 @@ def test_user_track_request_exception(
65108
assert error_msg in response["errors"]
66109

67110
@pytest.mark.parametrize(
68-
"status_code, retry_attempts", [(500, BrazeClient.MAX_RETRIES), (401, 1)]
111+
"status_code, retry_attempts", [(500, MAX_RETRIES), (401, 1)]
69112
)
70113
def test_retries_for_errors(
71114
self,
@@ -95,18 +138,18 @@ def test_retries_for_errors(
95138
@freeze_time()
96139
@pytest.mark.parametrize(
97140
"reset_delta_seconds, expected_attempts",
98-
[(0.05, 1 + BrazeClient.MAX_RETRIES), (BrazeClient.MAX_WAIT_SECONDS + 1, 1)],
141+
[(0.05, MAX_RETRIES), (MAX_WAIT_SECONDS + 1, 1)],
99142
)
100143
def test_retries_for_rate_limit_errors(
101144
self,
102145
braze_client,
103-
mocker,
104146
requests_mock,
105147
attributes,
106148
events,
107149
purchases,
108150
reset_delta_seconds,
109151
expected_attempts,
152+
no_sleep,
110153
):
111154
headers = {
112155
"Content-Type": "application/json",
@@ -116,10 +159,15 @@ def test_retries_for_rate_limit_errors(
116159
mock_json = {"message": error_msg, "errors": error_msg}
117160
requests_mock.post(ANY, json=mock_json, status_code=429, headers=headers)
118161

119-
spy = mocker.spy(braze_client, "_post_request_with_retries")
120162
response = braze_client.user_track(
121163
attributes=attributes, events=events, purchases=purchases
122164
)
123-
assert spy.call_count == expected_attempts
165+
166+
stats = braze_client._post_request_with_retries.retry.statistics
167+
assert stats["attempt_number"] == expected_attempts
124168
assert response["success"] is False
125169
assert "BrazeRateLimitError" in response["errors"]
170+
171+
# Ensure the correct wait time is used when rate limited
172+
for i in range(expected_attempts - 1):
173+
assert approx(no_sleep.call_args_list[i][0], reset_delta_seconds)

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,11 @@
77
@pytest.fixture
88
def braze_client():
99
return BrazeClient(api_key="API_KEY")
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def no_sleep(mocker, braze_client):
14+
"""Disables actual sleeps, but keeps retry wait logic. Zippy tests!"""
15+
return mocker.patch.object(
16+
braze_client._post_request_with_retries.retry, "sleep", return_value=None
17+
)

0 commit comments

Comments
 (0)