Skip to content

Commit

Permalink
Client should handle rate limiting errors gracefully #76
Browse files Browse the repository at this point in the history
  • Loading branch information
20c-ed authored and vegu committed Aug 13, 2024
1 parent c67d383 commit 8bb084d
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(

Check warning on line 75 in src/peeringdb/fetch.py

View check run for this annotation

Codecov / codecov/patch

src/peeringdb/fetch.py#L74-L75

Added lines #L74 - L75 were not covered by tests
f"Rate limited. Retrying in {retry_after} seconds..."
)
time.sleep(retry_after)
self.attempt += 1

Check warning on line 79 in src/peeringdb/fetch.py

View check run for this annotation

Codecov / codecov/patch

src/peeringdb/fetch.py#L78-L79

Added lines #L78 - L79 were not covered by tests
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}")

Check warning on line 84 in src/peeringdb/fetch.py

View check run for this annotation

Codecov / codecov/patch

src/peeringdb/fetch.py#L81-L84

Added lines #L81 - L84 were not covered by tests
else:
raise ValueError(f"Error fetching {url}: {resp.status_code}")
except requests.exceptions.RequestException as err:
raise ValueError(f"Request error: {err}")

Check warning on line 88 in src/peeringdb/fetch.py

View check run for this annotation

Codecov / codecov/patch

src/peeringdb/fetch.py#L87-L88

Added lines #L87 - L88 were not covered by tests

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 8bb084d

Please sign in to comment.