Skip to content

Commit d9ad52b

Browse files
fix(http): fail fast on GitHub API rate limit instead of hanging
Raise GitHubRateLimitError immediately when the rate limit reset is more than 120 seconds away, instead of sleeping 300s per retry for up to ~40 minutes. The error message suggests setting GITHUB_TOKEN. Closes: #1118 Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent abfdf97 commit d9ad52b

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)