Skip to content

Commit

Permalink
Client should handle rate limiting errors gracefully #76 (#96)
Browse files Browse the repository at this point in the history
Co-authored-by: 20C <[email protected]>
  • Loading branch information
vegu and 20c-ed authored Aug 21, 2024
1 parent c67d383 commit 6ec8b65
Show file tree
Hide file tree
Showing 6 changed files with 375 additions and 265 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@


## Unreleased
### Fixed
- Client should handle rate limiting errors gracefully


## 2.1.1
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
Unreleased:
added: []
fixed: []
fixed:
- Client should handle rate limiting errors gracefully #76
changed: []
deprecated: []
removed: []
Expand Down
537 changes: 285 additions & 252 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/peeringdb/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ class LogSchema(_schema.Schema):
)
level = _schema.Str("log_level", default=os.environ.get("LOG_LEVEL", "INFO"))

class LogSchema(_schema.Schema):
allow_other_loggers = _schema.Int(
"allow_other_loggers", default=os.environ.get("ALLOW_OTHER_LOGGERS", 0)
)
level = _schema.Str("log_level", default=os.environ.get("LOG_LEVEL", "INFO"))

sync = SyncSchema()
orm = OrmSchema()
log = LogSchema()
Expand Down
41 changes: 29 additions & 12 deletions src/peeringdb/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def __init__(
:param api_key: API key
:param cache_url: PeeringDB cache URL
:param cache_dir: Local cache directory
:param retry: The maximum number of retry attempts when rate limited (default is 5)
:param kwargs:
"""
self._log = logging.getLogger(__name__)
Expand All @@ -45,6 +46,9 @@ def __init__(
self.remote_cache_used = False
self.local_cache_used = False

# used for sync 429 status code (pause and resume)
self.attempt = 0

def _get(self, endpoint: str, **params):
url = f"{self.url}/{endpoint}"
url_params = urllib.parse.urlencode(params)
Expand All @@ -60,25 +64,36 @@ def _get(self, endpoint: str, **params):
+ base64.b64encode(f"{self.user}:{self.password}".encode()).decode()
}

resp = requests.get(url, timeout=self.timeout, headers=headers)

if resp.status_code == 429:
raise ValueError(f"Rate limited: {resp.text}")
elif resp.status_code == 400:
error = resp.json()["meta"]["error"]
if re.search("client version is incompatible", error):
raise CompatibilityError(error)
raise
elif resp.status_code != 200:
raise ValueError(f"Error fetching {url}: {resp.status_code}")
return resp.json()["data"]
while True:
try:
resp = requests.get(url, timeout=self.timeout, headers=headers)
resp.raise_for_status()
return resp.json()["data"]
except requests.exceptions.HTTPError as e:
if resp.status_code == 429:
retry_after = min(2**self.attempt, 60)
self._log.info(
f"Rate limited. Retrying in {retry_after} seconds..."
)
time.sleep(retry_after)
self.attempt += 1
elif resp.status_code == 400:
error = resp.json().get("meta", {}).get("error", "")
if re.search("client version is incompatible", error):
raise CompatibilityError(error)
raise ValueError(f"Bad request error: {error}")
else:
raise ValueError(f"Error fetching {url}: {resp.status_code}")
except requests.exceptions.RequestException as err:
raise ValueError(f"Request error: {err}")

def load(
self,
resource: str,
since: int = 0,
fetch_private: bool = False,
initial_private: bool = False,
delay: float = 0.5,
):
"""
Load a resource from mock data.
Expand Down Expand Up @@ -142,6 +157,8 @@ def load(
else:
self.resources[resource] = self._get(resource, since=since)

time.sleep(delay)

def entries(self, tag: str):
"""
Get all entries by tag ro load it if we don't already have the resource
Expand Down
51 changes: 51 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import io
import json
import re
import time
from unittest.mock import MagicMock, patch

import helper
import pytest
Expand Down Expand Up @@ -160,3 +162,52 @@ def test_verbosity(runcli, client, capsys):

# Verbose output should be longer
assert len(outq) < len(outv)


@patch("time.sleep", return_value=None)
def test_rate_limit_handling(mock_sleep):
attempt = 0
log_mock = MagicMock()

# Mock response with status code 429
mock_resp = MagicMock()
mock_resp.status_code = 429

# Test rate limit handling
for _ in range(10):
if mock_resp.status_code == 429:
retry_after = min(2**attempt, 60)
log_mock.info(f"Rate limited. Retrying in {retry_after} seconds...")
time.sleep(retry_after)
attempt += 1

# Assert log calls and sleep durations
expected_calls = [
(("Rate limited. Retrying in 1 seconds...",),),
(("Rate limited. Retrying in 2 seconds...",),),
(("Rate limited. Retrying in 4 seconds...",),),
(("Rate limited. Retrying in 8 seconds...",),),
(("Rate limited. Retrying in 16 seconds...",),),
(("Rate limited. Retrying in 32 seconds...",),),
(("Rate limited. Retrying in 60 seconds...",),),
(("Rate limited. Retrying in 60 seconds...",),),
(("Rate limited. Retrying in 60 seconds...",),),
(("Rate limited. Retrying in 60 seconds...",),),
]

assert log_mock.info.call_args_list == expected_calls

expected_sleep_calls = [
((1,),),
((2,),),
((4,),),
((8,),),
((16,),),
((32,),),
((60,),),
((60,),),
((60,),),
((60,),),
]

assert mock_sleep.call_args_list == expected_sleep_calls

0 comments on commit 6ec8b65

Please sign in to comment.