Skip to content

Commit fb25f39

Browse files
Merge pull request #1120 from LalatenduMohanty/fix/github-rate-limit-fail-fast
fix(http): fail fast on GitHub API rate limit instead of hanging
2 parents c97ac64 + d9ad52b commit fb25f39

2 files changed

Lines changed: 209 additions & 32 deletions

File tree

src/fromager/http_retry.py

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@
5656
)
5757

5858

59+
class GitHubRateLimitError(RequestException):
60+
"""Raised when GitHub API rate limit is exceeded and reset is too far away to wait."""
61+
62+
63+
# Maximum wait time (seconds) before raising GitHubRateLimitError instead of sleeping.
64+
GITHUB_RATE_LIMIT_MAX_WAIT = 120
65+
66+
5967
class RetryHTTPAdapter(HTTPAdapter):
6068
"""HTTP adapter with enhanced retry logic and backoff."""
6169

@@ -206,45 +214,61 @@ def send(
206214
def _handle_github_rate_limit(
207215
self, response: requests.Response, attempt: int, max_attempts: int
208216
) -> None:
209-
"""Handle GitHub API rate limiting with appropriate backoff."""
210-
if attempt >= max_attempts - 1:
211-
logger.error(
212-
"GitHub API rate limit exceeded after %d attempts for %s",
213-
max_attempts,
214-
response.request.url or "<unknown>",
215-
)
216-
return
217+
"""Handle GitHub API rate limiting with appropriate backoff.
218+
219+
Raises GitHubRateLimitError immediately when the wait would exceed
220+
GITHUB_RATE_LIMIT_MAX_WAIT, instead of sleeping for minutes and
221+
retrying into the same 403.
222+
"""
223+
url = response.request.url or "<unknown>"
217224

218225
# Check for reset time in headers
219226
reset_time = response.headers.get("X-RateLimit-Reset")
220227
if reset_time:
221228
try:
222229
reset_timestamp = int(reset_time)
223230
current_time = int(time.time())
224-
wait_time = min(
225-
reset_timestamp - current_time + 5, 300
226-
) # Max 5 minutes
227-
if wait_time > 0:
228-
logger.warning(
229-
"GitHub API rate limit hit for %s. Waiting %d seconds until reset.",
230-
response.request.url or "<unknown>",
231-
wait_time,
232-
)
233-
time.sleep(wait_time)
234-
return
231+
wait_time = reset_timestamp - current_time + 5
235232
except (ValueError, TypeError):
236233
logger.debug("Could not parse GitHub rate limit reset time")
234+
wait_time = None
235+
else:
236+
wait_time = None
237+
238+
# Fail fast when the wait is too long or retries are exhausted
239+
if attempt >= max_attempts - 1 or (
240+
wait_time is not None and wait_time > GITHUB_RATE_LIMIT_MAX_WAIT
241+
):
242+
wait_msg = (
243+
f"Reset in {wait_time}s."
244+
if wait_time is not None
245+
else "Reset time unknown."
246+
)
247+
raise GitHubRateLimitError(
248+
f"GitHub API rate limit exceeded for {url}. {wait_msg} "
249+
f"Set GITHUB_TOKEN environment variable to increase the "
250+
f"rate limit from 60 to 5000 requests/hour."
251+
)
237252

238-
# Fallback to exponential backoff
239-
wait_time = min(2**attempt + random.uniform(0, 1), 60)
253+
if wait_time is not None and wait_time > 0:
254+
logger.warning(
255+
"GitHub API rate limit hit for %s. Waiting %d seconds until reset.",
256+
url,
257+
wait_time,
258+
)
259+
time.sleep(wait_time)
260+
return
261+
262+
# Fallback to exponential backoff when reset time is unknown or already passed
263+
wait_time_backoff = min(2**attempt + random.uniform(0, 1), 60)
240264
logger.warning(
241265
"GitHub API rate limit hit for %s. Retrying in %.1f seconds (attempt %d/%d)",
242-
response.request.url or "<unknown>",
243-
wait_time,
266+
url,
267+
wait_time_backoff,
244268
attempt + 1,
245269
max_attempts,
246270
)
247-
time.sleep(wait_time)
271+
time.sleep(wait_time_backoff)
248272

249273
def _handle_retryable_exception(
250274
self,

tests/test_http_retry.py

Lines changed: 161 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import time
21
import typing
32
from unittest import mock
43
from unittest.mock import Mock, patch
@@ -97,10 +96,14 @@ def test_send_retries_on_server_errors(
9796
assert mock_super_send.call_count == 2
9897
mock_sleep.assert_called_once()
9998

99+
@patch("fromager.http_retry.time.time", return_value=100.0)
100100
@patch("time.sleep")
101101
@patch("requests.adapters.HTTPAdapter.send")
102102
def test_send_github_rate_limit_handling(
103-
self, mock_super_send: typing.Any, mock_sleep: typing.Any
103+
self,
104+
mock_super_send: typing.Any,
105+
mock_sleep: typing.Any,
106+
mock_time: typing.Any,
104107
) -> None:
105108
"""Test GitHub API rate limit handling."""
106109
adapter = http_retry.RetryHTTPAdapter()
@@ -110,7 +113,7 @@ def test_send_github_rate_limit_handling(
110113
rate_limit_response = Mock(spec=requests.Response)
111114
rate_limit_response.status_code = 403
112115
rate_limit_response.text = "API rate limit exceeded"
113-
rate_limit_response.headers = {"X-RateLimit-Reset": str(int(time.time()) + 60)}
116+
rate_limit_response.headers = {"X-RateLimit-Reset": str(160)}
114117
rate_limit_response.request = request
115118

116119
success_response = Mock(spec=requests.Response)
@@ -163,20 +166,82 @@ def test_send_exhausts_retries_and_raises(
163166
with pytest.raises(ConnectionError):
164167
adapter.send(request)
165168

166-
def test_handle_github_rate_limit_with_reset_header(self) -> None:
167-
"""Test GitHub rate limit handling with reset header."""
169+
@patch("fromager.http_retry.time.time", return_value=100.0)
170+
def test_handle_github_rate_limit_with_short_reset(
171+
self, mock_time: typing.Any
172+
) -> None:
173+
"""Test GitHub rate limit sleeps and retries when reset is soon."""
168174
adapter = http_retry.RetryHTTPAdapter()
169175
response = Mock(spec=requests.Response)
170-
response.headers = {"X-RateLimit-Reset": str(int(time.time()) + 1)}
176+
response.headers = {"X-RateLimit-Reset": str(110)}
171177
response.request = Mock()
172178
response.request.url = "https://api.github.com"
173179

174180
with patch("time.sleep") as mock_sleep:
175181
adapter._handle_github_rate_limit(response, 0, 3)
176-
mock_sleep.assert_called_once()
182+
mock_sleep.assert_called_once_with(15) # 110 - 100 + 5
183+
184+
@patch("fromager.http_retry.time.time", return_value=100.0)
185+
def test_handle_github_rate_limit_raises_on_long_wait(
186+
self, mock_time: typing.Any
187+
) -> None:
188+
"""Test GitHub rate limit raises immediately when reset is far away."""
189+
adapter = http_retry.RetryHTTPAdapter()
190+
response = Mock(spec=requests.Response)
191+
reset_in = http_retry.GITHUB_RATE_LIMIT_MAX_WAIT + 100
192+
response.headers = {"X-RateLimit-Reset": str(100 + reset_in)}
193+
response.request = Mock()
194+
response.request.url = "https://api.github.com/repos/test"
195+
196+
with pytest.raises(http_retry.GitHubRateLimitError, match="GITHUB_TOKEN"):
197+
adapter._handle_github_rate_limit(response, 0, 3)
198+
199+
@patch("fromager.http_retry.time.time", return_value=100.0)
200+
def test_handle_github_rate_limit_at_threshold_boundary(
201+
self, mock_time: typing.Any
202+
) -> None:
203+
"""Test wait_time exactly at threshold sleeps instead of raising."""
204+
adapter = http_retry.RetryHTTPAdapter()
205+
response = Mock(spec=requests.Response)
206+
# wait_time = reset - current + 5 = threshold exactly
207+
reset_ts = 100 + http_retry.GITHUB_RATE_LIMIT_MAX_WAIT - 5
208+
response.headers = {"X-RateLimit-Reset": str(reset_ts)}
209+
response.request = Mock()
210+
response.request.url = "https://api.github.com/repos/test"
211+
212+
with patch("time.sleep") as mock_sleep:
213+
adapter._handle_github_rate_limit(response, 0, 3)
214+
mock_sleep.assert_called_once_with(http_retry.GITHUB_RATE_LIMIT_MAX_WAIT)
215+
216+
@patch("fromager.http_retry.time.time", return_value=100.0)
217+
def test_handle_github_rate_limit_raises_on_max_attempts(
218+
self, mock_time: typing.Any
219+
) -> None:
220+
"""Test GitHub rate limit raises when retries are exhausted."""
221+
adapter = http_retry.RetryHTTPAdapter()
222+
response = Mock(spec=requests.Response)
223+
response.headers = {"X-RateLimit-Reset": str(110)}
224+
response.request = Mock()
225+
response.request.url = "https://api.github.com/repos/test"
226+
227+
with pytest.raises(http_retry.GitHubRateLimitError, match="GITHUB_TOKEN"):
228+
adapter._handle_github_rate_limit(response, 2, 3)
229+
230+
def test_handle_github_rate_limit_raises_on_invalid_header_at_max_attempts(
231+
self,
232+
) -> None:
233+
"""Test raises with 'Reset time unknown' when header is unparseable and retries exhausted."""
234+
adapter = http_retry.RetryHTTPAdapter()
235+
response = Mock(spec=requests.Response)
236+
response.headers = {"X-RateLimit-Reset": "garbage"}
237+
response.request = Mock()
238+
response.request.url = "https://api.github.com/repos/test"
239+
240+
with pytest.raises(http_retry.GitHubRateLimitError, match="Reset time unknown"):
241+
adapter._handle_github_rate_limit(response, 2, 3)
177242

178243
def test_handle_github_rate_limit_without_reset_header(self) -> None:
179-
"""Test GitHub rate limit handling without reset header."""
244+
"""Test GitHub rate limit uses exponential backoff when reset header is missing."""
180245
adapter = http_retry.RetryHTTPAdapter()
181246
response = Mock(spec=requests.Response)
182247
response.headers = {}
@@ -493,3 +558,91 @@ def test_adapter_logging_on_github_rate_limit(mock_logger: typing.Any) -> None:
493558
mock_logger.warning.assert_called_once()
494559
args = mock_logger.warning.call_args[0]
495560
assert "GitHub API rate limit hit" in args[0]
561+
562+
563+
class TestGitHubRateLimitError:
564+
"""Test cases for GitHubRateLimitError behavior."""
565+
566+
def test_is_request_exception(self) -> None:
567+
"""Test that GitHubRateLimitError is a RequestException subclass."""
568+
err = http_retry.GitHubRateLimitError("test")
569+
assert isinstance(err, requests.exceptions.RequestException)
570+
571+
def test_not_in_retryable_exceptions(self) -> None:
572+
"""Test that GitHubRateLimitError is not caught by RETRYABLE_EXCEPTIONS."""
573+
err = http_retry.GitHubRateLimitError("test")
574+
assert not isinstance(err, http_retry.RETRYABLE_EXCEPTIONS)
575+
576+
@patch("fromager.http_retry.time.time", return_value=100.0)
577+
@patch("time.sleep")
578+
@patch("requests.adapters.HTTPAdapter.send")
579+
def test_send_raises_on_long_rate_limit(
580+
self,
581+
mock_super_send: typing.Any,
582+
mock_sleep: typing.Any,
583+
mock_time: typing.Any,
584+
) -> None:
585+
"""Test that send() raises GitHubRateLimitError for long rate limit waits."""
586+
adapter = http_retry.RetryHTTPAdapter()
587+
request = Mock(spec=requests.PreparedRequest)
588+
request.url = "https://api.github.com/repos/test"
589+
590+
rate_limit_response = Mock(spec=requests.Response)
591+
rate_limit_response.status_code = 403
592+
rate_limit_response.text = "API rate limit exceeded"
593+
rate_limit_response.headers = {"X-RateLimit-Reset": str(3700)}
594+
rate_limit_response.request = request
595+
596+
mock_super_send.return_value = rate_limit_response
597+
598+
with pytest.raises(http_retry.GitHubRateLimitError, match="GITHUB_TOKEN"):
599+
adapter.send(request)
600+
601+
mock_sleep.assert_not_called()
602+
603+
@patch("fromager.http_retry.time.time", return_value=100.0)
604+
@patch("time.sleep")
605+
@patch("requests.adapters.HTTPAdapter.send")
606+
def test_send_retries_on_short_rate_limit(
607+
self,
608+
mock_super_send: typing.Any,
609+
mock_sleep: typing.Any,
610+
mock_time: typing.Any,
611+
) -> None:
612+
"""Test that send() sleeps and retries for short rate limit waits."""
613+
adapter = http_retry.RetryHTTPAdapter()
614+
request = Mock(spec=requests.PreparedRequest)
615+
request.url = "https://api.github.com/repos/test"
616+
617+
rate_limit_response = Mock(spec=requests.Response)
618+
rate_limit_response.status_code = 403
619+
rate_limit_response.text = "API rate limit exceeded"
620+
rate_limit_response.headers = {"X-RateLimit-Reset": str(130)}
621+
rate_limit_response.request = request
622+
623+
success_response = Mock(spec=requests.Response)
624+
success_response.status_code = 200
625+
626+
mock_super_send.side_effect = [rate_limit_response, success_response]
627+
628+
result = adapter.send(request)
629+
630+
assert result == success_response
631+
mock_sleep.assert_called_once()
632+
633+
@patch("fromager.http_retry.time.time", return_value=100.0)
634+
def test_error_message_includes_reset_time(self, mock_time: typing.Any) -> None:
635+
"""Test that the error message includes the reset wait time."""
636+
adapter = http_retry.RetryHTTPAdapter()
637+
response = Mock(spec=requests.Response)
638+
response.headers = {"X-RateLimit-Reset": str(3700)}
639+
response.request = Mock()
640+
response.request.url = "https://api.github.com/repos/test"
641+
642+
with pytest.raises(http_retry.GitHubRateLimitError) as exc_info:
643+
adapter._handle_github_rate_limit(response, 0, 3)
644+
645+
msg = str(exc_info.value)
646+
assert "Reset in 3605s" in msg
647+
assert "GITHUB_TOKEN" in msg
648+
assert "5000 requests/hour" in msg

0 commit comments

Comments
 (0)