Skip to content

Commit 2e712de

Browse files
authored
Merge pull request #16 from deftinc/test-rate-limiting
2 parents 4bda87a + 3cd9f20 commit 2e712de

File tree

5 files changed

+68
-14
lines changed

5 files changed

+68
-14
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ except FeatureNotFound as err:
6767
# Do what we want to do when the feature doesn't exist
6868
```
6969

70-
### PostApiClientError
70+
### PosthogApiClientError
7171
For the `PosthogAdapter` in particular it will raise error if it was unable to reach the Posthog API. These get bubbled up as `PosthogAPIClientError`.
7272

7373
```python
@@ -79,6 +79,18 @@ except PosthogAPIClientError as err:
7979
# Handle the error -- define default behavior in outage
8080
```
8181

82+
### RateLimitError
83+
Again, specific to the `PosthogAdapter` it will raise an error if the account is rate limited by the Posthog API. These get bubbled up as `RateLimitError`.
84+
85+
```python
86+
from feature_gate.clients.posthog_api_client import RateLimitError
87+
88+
try:
89+
client.features() # receives response indicating a rate limit and retry time in seconds
90+
except RateLimitError as err:
91+
# Handle the error -- define default behavior during rate limiting
92+
```
93+
8294
## Testing
8395

8496
The Memory Adapter can be used for writing tests. This creates an ephemeral memory only implementation of the feature_gate client API. This is non-suitable for production only for tests.

feature_gate/clients/posthog_api_client.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
class PosthogAPIClientError(Exception):
1616
pass
1717

18+
class RateLimitError(Exception):
19+
pass
20+
1821
class PosthogAPIClient:
1922
def __init__(self, api_base=None, api_key=None, project_id=None):
2023
if api_base is None:
@@ -185,28 +188,39 @@ def _get_headers(self):
185188
def _check_status_ok(self, code):
186189
return code == 200 or code == 201
187190

191+
def _check_status_too_many_requests(self, code):
192+
return code == 429
193+
188194
def _map_single_response(self, method, path, response):
189195
ret = None
190196
if self._check_status_ok(response.status_code):
191-
data = response.json()
192-
self.logger.info("request successful", method=method, path=path, status_code=response.status_code, response=data)
193-
ret = self._map_single_response_success(data)
197+
data = response.json()
198+
self.logger.info("request successful", method=method, path=path, status_code=response.status_code, response=data)
199+
ret = self._map_single_response_success(data)
200+
elif self._check_status_too_many_requests(response.status_code):
201+
data = response.json()
202+
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
203+
raise RateLimitError(f"{data['detail']}")
194204
else:
195-
data = response.json()
196-
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
197-
ret = self._map_error_response(response.status_code, data)
205+
data = response.json()
206+
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
207+
ret = self._map_error_response(response.status_code, data)
198208
return ret
199209

200210
def _map_list_response(self, method, path, response):
201211
ret = None
202212
if self._check_status_ok(response.status_code):
203-
data = response.json()
204-
self.logger.info("request successful", method=method, path=path, status_code=response.status_code, response=data)
205-
ret = self._map_list_response_success(data)
213+
data = response.json()
214+
self.logger.info("request successful", method=method, path=path, status_code=response.status_code, response=data)
215+
ret = self._map_list_response_success(data)
216+
elif self._check_status_too_many_requests(response.status_code):
217+
data = response.json()
218+
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
219+
raise RateLimitError(f"{data['detail']}")
206220
else:
207-
data = response.json()
208-
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
209-
ret = self._map_error_response(response.status_code, data)
221+
data = response.json()
222+
self.logger.info("request failed", method=method, path=path, status_code=response.status_code, response=data)
223+
ret = self._map_error_response(response.status_code, data)
210224
return ret
211225

212226
def _map_error_response(self, code, data):
@@ -238,3 +252,7 @@ def _map_list_response_success(self, data):
238252
def _log_posthog_connection_error(self, error):
239253
self.logger.error(f"Posthog connection error - {error}")
240254
raise PosthogAPIClientError(f"Posthog connection error - {error}")
255+
256+
def _log_posthog_rate_limit_error(self, error):
257+
self.logger.error(f"Posthog rate limit error - {error}")
258+
raise RateLimitError(f"Posthog rate limit error error - {error}")

tests/feature_gate/adapters/posthog_test.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import requests
33

44
from feature_gate.adapters.posthog import PosthogAdapter
5+
from feature_gate.clients.posthog_api_client import RateLimitError
56
from feature_gate.client import Client, FeatureNotFound
67
from feature_gate.feature import Feature
7-
from tests.fixtures.posthog_api_client.mocks import build_feature_from_mocks, mock_add_feature_funnel, mock_disable_feature_funnel, mock_enable_feature_funnel, mock_features_when_empty, mock_features_when_error_returned, mock_features_when_funnel, mock_funnel_is_disabled, mock_funnel_is_enabled, mock_remove_feature_funnel
8+
from tests.fixtures.posthog_api_client.mocks import build_feature_from_mocks, mock_add_feature_funnel, mock_disable_feature_funnel, mock_enable_feature_funnel, mock_features_when_empty, mock_features_when_error_returned, mock_features_when_funnel, mock_funnel_is_disabled, mock_funnel_is_enabled, mock_remove_feature_funnel, mock_rate_limiting_error
89
from unittest.mock import patch
910

1011
def configured_client():
@@ -96,6 +97,15 @@ def test_is_enabled_raises_an_error_when_the_api_response_returns_an_error_statu
9697
except FeatureNotFound as e:
9798
assert str(e) == "Feature funnel_test not found"
9899

100+
def test_is_enabled_raises_an_error_when_rate_limited():
101+
client = configured_client()
102+
feature = build_feature_from_mocks()
103+
with patch.object(requests, 'get', return_value=mock_rate_limiting_error()):
104+
try:
105+
resp = client.is_enabled(feature.key)
106+
except RateLimitError as e:
107+
assert str(e) == "Request was throttled. Expected available in 5 seconds."
108+
99109
def test_enable_returns_true_when_feature_exists():
100110
client = configured_client()
101111
feature = build_feature_from_mocks()

tests/fixtures/posthog_api_client/mocks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,11 @@ def mock_features_when_error_returned():
8989
return_value=load_response('get_features_when_empty')
9090
)
9191
)
92+
93+
def mock_rate_limiting_error():
94+
return Mock(
95+
status_code=429,
96+
json=Mock(
97+
return_value=load_response('rate_limiting_error')
98+
)
99+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "throttled_error",
3+
"code": "throttled",
4+
"detail": "Request was throttled. Expected available in 5 seconds.",
5+
"attr": null
6+
}

0 commit comments

Comments
 (0)