-
Notifications
You must be signed in to change notification settings - Fork 53
Expand file tree
/
Copy pathtest_http_retry.py
More file actions
648 lines (522 loc) · 24.7 KB
/
Copy pathtest_http_retry.py
File metadata and controls
648 lines (522 loc) · 24.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
import typing
from unittest import mock
from unittest.mock import Mock, patch
import pytest
import requests
from requests.exceptions import (
ChunkedEncodingError,
ConnectionError,
ConnectTimeout,
ReadTimeout,
RetryError,
Timeout,
)
from urllib3.exceptions import IncompleteRead, ProtocolError
from fromager import http_retry
class TestRetryHTTPAdapter:
"""Test cases for RetryHTTPAdapter class."""
def test_init_with_default_config(self) -> None:
"""Test adapter initialization with default configuration."""
adapter = http_retry.RetryHTTPAdapter()
assert adapter.timeout == 60.0
assert adapter.backoff_factor == 1.0
assert adapter.max_backoff == 60.0
def test_init_with_custom_config(self) -> None:
"""Test adapter initialization with custom configuration."""
custom_config = {
"total": 3,
"backoff_factor": 2.0,
"status_forcelist": [502, 503],
"allowed_methods": ["GET", "POST"],
"raise_on_status": True,
}
adapter = http_retry.RetryHTTPAdapter(retry_config=custom_config, timeout=30.0)
assert adapter.timeout == 30.0
assert adapter.backoff_factor == 2.0
def test_init_with_invalid_config_types(self) -> None:
"""Test adapter handles invalid configuration types gracefully."""
invalid_config = {
"total": "invalid",
"backoff_factor": "invalid",
"status_forcelist": "invalid",
"allowed_methods": "invalid",
"raise_on_status": "invalid",
}
adapter = http_retry.RetryHTTPAdapter(retry_config=invalid_config)
# Should use defaults when invalid types are provided
assert adapter.backoff_factor == 1.0
@patch("fromager.http_retry.RetryHTTPAdapter._handle_github_rate_limit")
@patch("requests.adapters.HTTPAdapter.send")
def test_send_successful_response(
self, mock_super_send: typing.Any, mock_github_handler: typing.Any
) -> None:
"""Test successful HTTP request without retries."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
response = Mock(spec=requests.Response)
response.status_code = 200
mock_super_send.return_value = response
result = adapter.send(request)
assert result == response
mock_super_send.assert_called_once()
mock_github_handler.assert_not_called()
@patch("time.sleep")
@patch("requests.adapters.HTTPAdapter.send")
def test_send_retries_on_server_errors(
self, mock_super_send: typing.Any, mock_sleep: typing.Any
) -> None:
"""Test retry behavior on server error status codes."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
error_response = Mock(spec=requests.Response)
error_response.status_code = 502
success_response = Mock(spec=requests.Response)
success_response.status_code = 200
mock_super_send.side_effect = [error_response, success_response]
result = adapter.send(request)
assert result == success_response
assert mock_super_send.call_count == 2
mock_sleep.assert_called_once()
@patch("fromager.http_retry.time.time", return_value=100.0)
@patch("time.sleep")
@patch("requests.adapters.HTTPAdapter.send")
def test_send_github_rate_limit_handling(
self,
mock_super_send: typing.Any,
mock_sleep: typing.Any,
mock_time: typing.Any,
) -> None:
"""Test GitHub API rate limit handling."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://api.github.com/repos/test"
rate_limit_response = Mock(spec=requests.Response)
rate_limit_response.status_code = 403
rate_limit_response.text = "API rate limit exceeded"
rate_limit_response.headers = {"X-RateLimit-Reset": str(160)}
rate_limit_response.request = request
success_response = Mock(spec=requests.Response)
success_response.status_code = 200
mock_super_send.side_effect = [rate_limit_response, success_response]
result = adapter.send(request)
assert result == success_response
assert mock_super_send.call_count == 2
mock_sleep.assert_called_once()
@patch("time.sleep")
@patch("requests.adapters.HTTPAdapter.send")
def test_send_retries_on_connection_error(
self, mock_super_send: typing.Any, mock_sleep: typing.Any
) -> None:
"""Test retry behavior on connection errors."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
success_response = Mock(spec=requests.Response)
success_response.status_code = 200
# First call raises ConnectionError, second succeeds
mock_super_send.side_effect = [
ConnectionError("Connection failed"),
success_response,
]
result = adapter.send(request)
assert result == success_response
assert mock_super_send.call_count == 2
mock_sleep.assert_called_once()
@patch("requests.adapters.HTTPAdapter.send")
def test_send_exhausts_retries_and_raises(
self, mock_super_send: typing.Any
) -> None:
"""Test that retries are exhausted and exception is raised."""
adapter = http_retry.RetryHTTPAdapter(retry_config={"total": 1})
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
mock_super_send.side_effect = ConnectionError("Persistent connection error")
with pytest.raises(ConnectionError):
adapter.send(request)
@patch("fromager.http_retry.time.time", return_value=100.0)
def test_handle_github_rate_limit_with_short_reset(
self, mock_time: typing.Any
) -> None:
"""Test GitHub rate limit sleeps and retries when reset is soon."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
response.headers = {"X-RateLimit-Reset": str(110)}
response.request = Mock()
response.request.url = "https://api.github.com"
with patch("time.sleep") as mock_sleep:
adapter._handle_github_rate_limit(response, 0, 3)
mock_sleep.assert_called_once_with(15) # 110 - 100 + 5
@patch("fromager.http_retry.time.time", return_value=100.0)
def test_handle_github_rate_limit_raises_on_long_wait(
self, mock_time: typing.Any
) -> None:
"""Test GitHub rate limit raises immediately when reset is far away."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
reset_in = http_retry.GITHUB_RATE_LIMIT_MAX_WAIT + 100
response.headers = {"X-RateLimit-Reset": str(100 + reset_in)}
response.request = Mock()
response.request.url = "https://api.github.com/repos/test"
with pytest.raises(http_retry.GitHubRateLimitError, match="GITHUB_TOKEN"):
adapter._handle_github_rate_limit(response, 0, 3)
@patch("fromager.http_retry.time.time", return_value=100.0)
def test_handle_github_rate_limit_at_threshold_boundary(
self, mock_time: typing.Any
) -> None:
"""Test wait_time exactly at threshold sleeps instead of raising."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
# wait_time = reset - current + 5 = threshold exactly
reset_ts = 100 + http_retry.GITHUB_RATE_LIMIT_MAX_WAIT - 5
response.headers = {"X-RateLimit-Reset": str(reset_ts)}
response.request = Mock()
response.request.url = "https://api.github.com/repos/test"
with patch("time.sleep") as mock_sleep:
adapter._handle_github_rate_limit(response, 0, 3)
mock_sleep.assert_called_once_with(http_retry.GITHUB_RATE_LIMIT_MAX_WAIT)
@patch("fromager.http_retry.time.time", return_value=100.0)
def test_handle_github_rate_limit_raises_on_max_attempts(
self, mock_time: typing.Any
) -> None:
"""Test GitHub rate limit raises when retries are exhausted."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
response.headers = {"X-RateLimit-Reset": str(110)}
response.request = Mock()
response.request.url = "https://api.github.com/repos/test"
with pytest.raises(http_retry.GitHubRateLimitError, match="GITHUB_TOKEN"):
adapter._handle_github_rate_limit(response, 2, 3)
def test_handle_github_rate_limit_raises_on_invalid_header_at_max_attempts(
self,
) -> None:
"""Test raises with 'Reset time unknown' when header is unparseable and retries exhausted."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
response.headers = {"X-RateLimit-Reset": "garbage"}
response.request = Mock()
response.request.url = "https://api.github.com/repos/test"
with pytest.raises(http_retry.GitHubRateLimitError, match="Reset time unknown"):
adapter._handle_github_rate_limit(response, 2, 3)
def test_handle_github_rate_limit_without_reset_header(self) -> None:
"""Test GitHub rate limit uses exponential backoff when reset header is missing."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
response.headers = {}
response.request = Mock()
response.request.url = "https://api.github.com"
with patch("time.sleep") as mock_sleep:
adapter._handle_github_rate_limit(response, 0, 3)
mock_sleep.assert_called_once()
def test_handle_retryable_exception(self) -> None:
"""Test handling of retryable exceptions."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
exception = ConnectionError("Test error")
with patch("time.sleep") as mock_sleep:
adapter._handle_retryable_exception(exception, request, 0, 3)
mock_sleep.assert_called_once()
def test_handle_retryable_exception_max_attempts(self) -> None:
"""Test handling retryable exception at max attempts."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
exception = ConnectionError("Test error")
with patch("time.sleep") as mock_sleep:
adapter._handle_retryable_exception(exception, request, 2, 3)
mock_sleep.assert_not_called()
class TestCreateRetrySession:
"""Test cases for create_retry_session function."""
def test_create_retry_session_default(self) -> None:
"""Test creating a retry session with default configuration."""
session = http_retry.create_retry_session()
assert isinstance(session, requests.Session)
assert "http://" in session.adapters
assert "https://" in session.adapters
def test_create_retry_session_custom_config(self) -> None:
"""Test creating a retry session with custom configuration."""
custom_config = {"total": 3, "backoff_factor": 2.0}
session = http_retry.create_retry_session(
retry_config=custom_config, timeout=30.0
)
assert isinstance(session, requests.Session)
def test_create_retry_session_basic(self) -> None:
"""Test basic session creation."""
session = http_retry.create_retry_session()
assert isinstance(session.get_adapter("http://"), http_retry.RetryHTTPAdapter)
assert isinstance(session.get_adapter("https://"), http_retry.RetryHTTPAdapter)
assert "Authorization" not in session.headers
class TestRetryOnExceptionDecorator:
"""Test cases for retry_on_exception decorator."""
def test_retry_decorator_success_on_first_attempt(self) -> None:
"""Test decorator when function succeeds on first attempt."""
@http_retry.retry_on_exception(max_attempts=3)
def successful_function() -> str:
return "success"
result = successful_function()
assert result == "success"
def test_retry_decorator_success_after_retries(self) -> None:
"""Test decorator when function succeeds after retries."""
call_count = 0
@http_retry.retry_on_exception(max_attempts=3, backoff_factor=0.01)
def failing_then_succeeding_function() -> str:
nonlocal call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Temporary failure")
return "success"
with patch("time.sleep"):
result = failing_then_succeeding_function()
assert result == "success"
assert call_count == 3
def test_retry_decorator_exhausts_attempts(self) -> None:
"""Test decorator when all retry attempts are exhausted."""
@http_retry.retry_on_exception(max_attempts=2, backoff_factor=0.01)
def always_failing_function() -> None:
raise ConnectionError("Persistent failure")
with patch("time.sleep"):
with pytest.raises(ConnectionError):
always_failing_function()
def test_retry_decorator_non_retryable_exception(self) -> None:
"""Test decorator with non-retryable exception."""
@http_retry.retry_on_exception(exceptions=(ConnectionError,), max_attempts=3)
def function_with_non_retryable_exception() -> None:
raise ValueError("Non-retryable error")
with pytest.raises(ValueError):
function_with_non_retryable_exception()
def test_retry_decorator_custom_exceptions(self) -> None:
"""Test decorator with custom exception types."""
@http_retry.retry_on_exception(
exceptions=(ValueError, TypeError), max_attempts=2, backoff_factor=0.01
)
def function_with_custom_exceptions() -> None:
raise ValueError("Custom retryable error")
with patch("time.sleep"):
with pytest.raises(ValueError):
function_with_custom_exceptions()
def test_retry_decorator_with_function_arguments(self) -> None:
"""Test decorator preserves function arguments."""
@http_retry.retry_on_exception(max_attempts=2)
def function_with_args(
arg1: typing.Any, arg2: typing.Any, kwarg1: typing.Any = None
) -> str:
return f"{arg1}-{arg2}-{kwarg1}"
result = function_with_args("a", "b", kwarg1="c")
assert result == "a-b-c"
class TestGetRetrySession:
"""Test cases for get_retry_session function."""
def test_get_retry_session(self) -> None:
"""Test getting a pre-configured retry session."""
session = http_retry.get_retry_session()
assert isinstance(session, requests.Session)
assert "http://" in session.adapters
assert "https://" in session.adapters
class TestDefaultRetryConfig:
"""Test cases for default retry configuration."""
def test_default_retry_config_values(self) -> None:
"""Test that default retry configuration has expected values."""
config = http_retry.DEFAULT_RETRY_CONFIG
assert config["total"] == 5
assert config["backoff_factor"] == 1.0
status_forcelist = config["status_forcelist"]
assert isinstance(status_forcelist, list)
assert 429 in status_forcelist
assert 502 in status_forcelist
allowed_methods = config["allowed_methods"]
assert isinstance(allowed_methods, list)
assert "GET" in allowed_methods
assert config["raise_on_status"] is False
class TestRetryableExceptions:
"""Test cases for retryable exceptions tuple."""
def test_retryable_exceptions_contains_expected_types(self) -> None:
"""Test that RETRYABLE_EXCEPTIONS contains expected exception types."""
exceptions = http_retry.RETRYABLE_EXCEPTIONS
assert ConnectionError in exceptions
assert Timeout in exceptions
assert ChunkedEncodingError in exceptions
assert IncompleteRead in exceptions
assert ProtocolError in exceptions
assert RetryError in exceptions
assert ConnectTimeout in exceptions
assert ReadTimeout in exceptions
class TestIntegration:
"""Integration test cases."""
def test_end_to_end_retry_session(self) -> None:
"""Test end-to-end usage of retry session."""
# Test that we can create a session and access its adapters
session = http_retry.create_retry_session()
# Verify session has retry adapters mounted
assert isinstance(session.adapters["http://"], http_retry.RetryHTTPAdapter)
assert isinstance(session.adapters["https://"], http_retry.RetryHTTPAdapter)
# Verify session has proper timeout configuration
http_adapter = session.adapters["https://"]
assert http_adapter.timeout == 60.0
def test_adapter_with_various_retryable_exceptions(self) -> None:
"""Test adapter handles various retryable exceptions."""
adapter = http_retry.RetryHTTPAdapter(retry_config={"total": 1})
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
# Test each retryable exception type
retryable_exceptions = [
ConnectionError("Connection failed"),
Timeout("Request timed out"),
ChunkedEncodingError("Chunked encoding error"),
IncompleteRead(partial=10, expected=20),
ProtocolError("Protocol error"),
]
for exception in retryable_exceptions:
with patch("requests.adapters.HTTPAdapter.send") as mock_send:
mock_send.side_effect = exception
with patch("time.sleep"):
with pytest.raises(type(exception)):
adapter.send(request)
@patch("time.sleep")
@patch("random.uniform", return_value=0.5)
def test_backoff_calculation(
self, mock_random: typing.Any, mock_sleep: typing.Any
) -> None:
"""Test backoff time calculation with jitter."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
exception = ConnectionError("Test error")
adapter._handle_retryable_exception(exception, request, 1, 5)
# Expected: min(2^1 + 0.5, 60) = min(2.5, 60) = 2.5
mock_sleep.assert_called_once_with(2.5)
# Test standalone functions
def test_default_retry_config_structure() -> None:
"""Test that DEFAULT_RETRY_CONFIG has the correct structure."""
config = http_retry.DEFAULT_RETRY_CONFIG
expected_keys = {
"total",
"backoff_factor",
"status_forcelist",
"allowed_methods",
"raise_on_status",
}
assert set(config.keys()) == expected_keys
def test_retryable_exceptions_tuple_is_not_empty() -> None:
"""Test that RETRYABLE_EXCEPTIONS is not empty."""
assert len(http_retry.RETRYABLE_EXCEPTIONS) > 0
@patch("time.sleep")
@patch("random.uniform", return_value=0.1)
def test_retry_decorator_backoff_timing(
mock_random: typing.Any, mock_sleep: typing.Any
) -> None:
"""Test retry decorator backoff timing calculation."""
call_count = 0
@http_retry.retry_on_exception(max_attempts=3, backoff_factor=2.0, max_backoff=10.0)
def failing_function() -> str:
nonlocal call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Temporary failure")
return "success"
result = failing_function()
assert result == "success"
assert call_count == 3
# Check that sleep was called with expected backoff times
expected_calls = [
mock.call(2.1), # 2.0 * (2^0) + 0.1 = 2.1
mock.call(4.1), # 2.0 * (2^1) + 0.1 = 4.1
]
mock_sleep.assert_has_calls(expected_calls)
@patch("fromager.http_retry.logger")
def test_adapter_logging_on_retry(mock_logger: typing.Any) -> None:
"""Test that appropriate logging occurs during retries."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://example.com"
exception = ConnectionError("Test error")
with patch("time.sleep"):
adapter._handle_retryable_exception(exception, request, 0, 3)
mock_logger.warning.assert_called_once()
call_args = mock_logger.warning.call_args
assert len(call_args[0]) > 1
assert "Request failed for %s" in call_args[0][0]
assert "https://example.com" in call_args[0]
@patch("fromager.http_retry.logger")
def test_adapter_logging_on_github_rate_limit(mock_logger: typing.Any) -> None:
"""Test logging during GitHub rate limit handling."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
response.headers = {}
response.request = Mock()
response.request.url = "https://api.github.com"
with patch("time.sleep"):
adapter._handle_github_rate_limit(response, 0, 3)
mock_logger.warning.assert_called_once()
args = mock_logger.warning.call_args[0]
assert "GitHub API rate limit hit" in args[0]
class TestGitHubRateLimitError:
"""Test cases for GitHubRateLimitError behavior."""
def test_is_request_exception(self) -> None:
"""Test that GitHubRateLimitError is a RequestException subclass."""
err = http_retry.GitHubRateLimitError("test")
assert isinstance(err, requests.exceptions.RequestException)
def test_not_in_retryable_exceptions(self) -> None:
"""Test that GitHubRateLimitError is not caught by RETRYABLE_EXCEPTIONS."""
err = http_retry.GitHubRateLimitError("test")
assert not isinstance(err, http_retry.RETRYABLE_EXCEPTIONS)
@patch("fromager.http_retry.time.time", return_value=100.0)
@patch("time.sleep")
@patch("requests.adapters.HTTPAdapter.send")
def test_send_raises_on_long_rate_limit(
self,
mock_super_send: typing.Any,
mock_sleep: typing.Any,
mock_time: typing.Any,
) -> None:
"""Test that send() raises GitHubRateLimitError for long rate limit waits."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://api.github.com/repos/test"
rate_limit_response = Mock(spec=requests.Response)
rate_limit_response.status_code = 403
rate_limit_response.text = "API rate limit exceeded"
rate_limit_response.headers = {"X-RateLimit-Reset": str(3700)}
rate_limit_response.request = request
mock_super_send.return_value = rate_limit_response
with pytest.raises(http_retry.GitHubRateLimitError, match="GITHUB_TOKEN"):
adapter.send(request)
mock_sleep.assert_not_called()
@patch("fromager.http_retry.time.time", return_value=100.0)
@patch("time.sleep")
@patch("requests.adapters.HTTPAdapter.send")
def test_send_retries_on_short_rate_limit(
self,
mock_super_send: typing.Any,
mock_sleep: typing.Any,
mock_time: typing.Any,
) -> None:
"""Test that send() sleeps and retries for short rate limit waits."""
adapter = http_retry.RetryHTTPAdapter()
request = Mock(spec=requests.PreparedRequest)
request.url = "https://api.github.com/repos/test"
rate_limit_response = Mock(spec=requests.Response)
rate_limit_response.status_code = 403
rate_limit_response.text = "API rate limit exceeded"
rate_limit_response.headers = {"X-RateLimit-Reset": str(130)}
rate_limit_response.request = request
success_response = Mock(spec=requests.Response)
success_response.status_code = 200
mock_super_send.side_effect = [rate_limit_response, success_response]
result = adapter.send(request)
assert result == success_response
mock_sleep.assert_called_once()
@patch("fromager.http_retry.time.time", return_value=100.0)
def test_error_message_includes_reset_time(self, mock_time: typing.Any) -> None:
"""Test that the error message includes the reset wait time."""
adapter = http_retry.RetryHTTPAdapter()
response = Mock(spec=requests.Response)
response.headers = {"X-RateLimit-Reset": str(3700)}
response.request = Mock()
response.request.url = "https://api.github.com/repos/test"
with pytest.raises(http_retry.GitHubRateLimitError) as exc_info:
adapter._handle_github_rate_limit(response, 0, 3)
msg = str(exc_info.value)
assert "Reset in 3605s" in msg
assert "GITHUB_TOKEN" in msg
assert "5000 requests/hour" in msg