1- import time
21import typing
32from unittest import mock
43from 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