Skip to content

Commit 718c085

Browse files
authored
fix: prevent endless loop on 403 GitHub response (#866)
Signed-off-by: Ben Selwyn-Smith <benselwynsmith@googlemail.com>
1 parent 5c490f9 commit 718c085

File tree

3 files changed

+73
-3
lines changed

3 files changed

+73
-3
lines changed

src/macaron/config/defaults.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
[requests]
55
# The default timeout in seconds for 'requests' API calls.
66
timeout = 10
7+
# The number of times to re-attempt to retrieve a resource when an error is encountered.
8+
error_retries = 5
79

810
[downloads]
911
# The default timeout in seconds for downloading assets.

src/macaron/util.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,23 @@ def send_get_http(url: str, headers: dict) -> dict:
3737
"""
3838
logger.debug("GET - %s", url)
3939
timeout = defaults.getint("requests", "timeout", fallback=10)
40+
error_retries = defaults.getint("requests", "error_retries", fallback=5)
41+
retry_counter = error_retries
4042
response = requests.get(url=url, headers=headers, timeout=timeout)
4143
while response.status_code != 200:
4244
logger.error(
4345
"Receiving error code %s from server. Message: %s.",
4446
response.status_code,
4547
response.text,
4648
)
49+
if retry_counter <= 0:
50+
logger.debug("Maximum retries reached: %s", error_retries)
51+
return {}
4752
if response.status_code == 403:
4853
check_rate_limit(response)
4954
else:
5055
return {}
56+
retry_counter = retry_counter - 1
5157
response = requests.get(url=url, headers=headers, timeout=timeout)
5258

5359
return dict(response.json())
@@ -81,6 +87,8 @@ def send_get_http_raw(
8187
logger.debug("GET - %s", url)
8288
if not timeout:
8389
timeout = defaults.getint("requests", "timeout", fallback=10)
90+
error_retries = defaults.getint("requests", "error_retries", fallback=5)
91+
retry_counter = error_retries
8492
try:
8593
response = requests.get(
8694
url=url,
@@ -99,10 +107,14 @@ def send_get_http_raw(
99107
"Receiving error code %s from server.",
100108
response.status_code,
101109
)
110+
if retry_counter <= 0:
111+
logger.debug("Maximum retries reached: %s", error_retries)
112+
return None
102113
if response.status_code == 403:
103114
check_rate_limit(response)
104115
else:
105116
return None
117+
retry_counter = retry_counter - 1
106118
response = requests.get(
107119
url=url,
108120
headers=headers,
@@ -121,7 +133,6 @@ def check_rate_limit(response: Response) -> None:
121133
response : Response
122134
The latest response from GitHub API.
123135
"""
124-
remains = 0
125136
if "X-RateLimit-Remaining" in response.headers:
126137
remains = int(response.headers["X-RateLimit-Remaining"])
127138
else:

tests/test_util.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
# Copyright (c) 2022 - 2022, Oracle and/or its affiliates. All rights reserved.
1+
# Copyright (c) 2022 - 2024, Oracle and/or its affiliates. All rights reserved.
22
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/.
33

44
"""
55
This module test the Util methods
66
"""
7-
7+
from collections.abc import Callable
88
from unittest import TestCase
99
from unittest.mock import call, patch
1010

11+
from pytest_httpserver import HTTPServer
12+
from werkzeug import Request, Response
13+
1114
from macaron import util
15+
from macaron.config.defaults import defaults
16+
from macaron.util import send_get_http_raw
1217

1318

1419
class TestUtil(TestCase):
@@ -78,3 +83,55 @@ def test_copy_file_bulk(self) -> None:
7883
call("/src/path/foo/file2", "/target/path/foo/file2"),
7984
]
8085
)
86+
87+
88+
def _response_generator(target_value: int) -> Callable[[Request], Response]:
89+
"""Return a generator with closure so a value can be tracked across multiple invocations."""
90+
value = 0
91+
92+
def generator(request: Request) -> Response: # pylint: disable=unused-argument
93+
"""Add the next value as a header and adjust the status code based on the value."""
94+
nonlocal value, target_value
95+
value += 1
96+
response = Response()
97+
response.status_code = 403 if value <= (target_value + 1) else 200
98+
response.headers["X-VALUE"] = str(value)
99+
return response
100+
101+
return generator
102+
103+
104+
def _http_setup(retries: int, httpserver: HTTPServer) -> str:
105+
"""Set up the http server for a GET test."""
106+
# Get a localhost URL.
107+
mocked_url: str = httpserver.url_for("")
108+
109+
# Create and assign the stateful handler.
110+
handler = _response_generator(retries)
111+
httpserver.expect_request("").respond_with_handler(handler)
112+
return mocked_url
113+
114+
115+
def test_get_http_partial_failure(httpserver: HTTPServer) -> None:
116+
"""Test the http GET operation when some errors are received before the request succeeds."""
117+
# Retrieve the allowed number of retries on a failed request and reduce it by 1.
118+
target_value = defaults.getint("requests", "error_retries", fallback=5) - 1
119+
120+
mocked_url = _http_setup(target_value, httpserver)
121+
122+
# Test for a correct response after the expected number of retries.
123+
response = send_get_http_raw(mocked_url)
124+
assert response
125+
assert "X-VALUE" in response.headers
126+
assert response.headers["X-VALUE"] == str(target_value + 2)
127+
128+
129+
def test_get_http_complete_failure(httpserver: HTTPServer) -> None:
130+
"""Test the http GET operation when too many errors are received and the request fails."""
131+
# Retrieve the allowed number of retries on a failed request.
132+
target_value = defaults.getint("requests", "error_retries", fallback=5)
133+
134+
mocked_url = _http_setup(target_value, httpserver)
135+
136+
# Assert the request fails and returns nothing.
137+
assert send_get_http_raw(mocked_url) is None

0 commit comments

Comments
 (0)