返回 last30days-skill
test_http_v3.py
根目录 / tests / test_http_v3.py
1 import urllib.error
2 import unittest
3 import time
4 from unittest.mock import patch, MagicMock
5
6 from lib import http
7
8
9 class Test429RetryLimit(unittest.TestCase):
10 """429 retries must be capped at max_429_retries to avoid wasting latency."""
11
12 @patch("lib.http.urllib.request.urlopen")
13 @patch("lib.http.time.sleep") # Don't actually sleep in tests
14 def test_429_retries_limited_to_2_by_default(self, mock_sleep, mock_urlopen):
15 """With default max_429_retries=2, should attempt 2 times then raise."""
16 error = urllib.error.HTTPError(
17 "http://example.com", 429, "Too Many Requests", {}, None
18 )
19 mock_urlopen.side_effect = error
20
21 with self.assertRaises(http.HTTPError) as ctx:
22 http.request("GET", "http://example.com", retries=5)
23
24 self.assertEqual(ctx.exception.status_code, 429)
25 # Should be called exactly 2 times (initial + 1 retry), not 5
26 self.assertEqual(mock_urlopen.call_count, 2)
27
28 @patch("lib.http.urllib.request.urlopen")
29 @patch("lib.http.time.sleep")
30 def test_non_429_errors_still_use_full_retries(self, mock_sleep, mock_urlopen):
31 """500 errors should still retry up to the full retries count."""
32 error = urllib.error.HTTPError(
33 "http://example.com", 500, "Internal Server Error", {}, None
34 )
35 mock_urlopen.side_effect = error
36
37 with self.assertRaises(http.HTTPError):
38 http.request("GET", "http://example.com", retries=3)
39
40 self.assertEqual(mock_urlopen.call_count, 3)
41
42 @patch("lib.http.urllib.request.urlopen")
43 @patch("lib.http.time.sleep")
44 @patch("lib.http.time.monotonic", side_effect=[0.0, 0.5, 0.5])
45 def test_shared_deadline_stops_retry_before_backoff_crosses_it(
46 self,
47 _mock_monotonic,
48 mock_sleep,
49 mock_urlopen,
50 ):
51 mock_urlopen.side_effect = urllib.error.HTTPError(
52 "http://example.com", 500, "Internal Server Error", {}, None
53 )
54
55 with self.assertRaises(http.HTTPError) as caught:
56 http.request(
57 "GET",
58 "http://example.com",
59 retries=3,
60 deadline_monotonic=1.0,
61 )
62
63 self.assertEqual(http.health.TIMEOUT, caught.exception.outcome_state)
64 self.assertEqual(1, mock_urlopen.call_count)
65 mock_sleep.assert_not_called()
66
67 @patch("lib.http.urllib.request.urlopen")
68 @patch("lib.http.time.monotonic", side_effect=[0.0, 0.5, 1.5])
69 def test_shared_deadline_rejects_response_that_finishes_late(
70 self,
71 _mock_monotonic,
72 mock_urlopen,
73 ):
74 mock_urlopen.return_value = _mock_response()
75
76 with self.assertRaises(http.DeadlineExceeded):
77 http.request(
78 "GET",
79 "http://example.com",
80 retries=1,
81 deadline_monotonic=1.0,
82 )
83
84 @patch("lib.http.urllib.request.urlopen")
85 def test_shared_deadline_stops_waiting_during_slow_body_read(
86 self,
87 mock_urlopen,
88 ):
89 response = _mock_response()
90
91 def slow_read():
92 time.sleep(0.2)
93 return b'{"ok": true}'
94
95 response.read.side_effect = slow_read
96 mock_urlopen.return_value = response
97 started = time.monotonic()
98
99 with self.assertRaises(http.DeadlineExceeded):
100 http.request(
101 "GET",
102 "http://example.com",
103 retries=1,
104 deadline_monotonic=started + 0.02,
105 )
106
107 self.assertLess(time.monotonic() - started, 0.12)
108
109 @patch("lib.http.urllib.request.urlopen")
110 def test_worker_socket_timeout_is_not_wall_deadline_expiration(
111 self,
112 mock_urlopen,
113 ):
114 mock_urlopen.side_effect = TimeoutError("early socket timeout")
115
116 with self.assertRaises(http.HTTPError) as caught:
117 http.request(
118 "GET",
119 "http://example.com",
120 retries=1,
121 deadline_monotonic=time.monotonic() + 600,
122 )
123
124 self.assertNotIsInstance(caught.exception, http.DeadlineExceeded)
125 self.assertEqual(http.health.TIMEOUT, caught.exception.outcome_state)
126
127
128 def _mock_response(body: str = '{"ok": true}', status: int = 200):
129 resp = MagicMock()
130 resp.__enter__ = MagicMock(return_value=resp)
131 resp.__exit__ = MagicMock(return_value=False)
132 resp.read.return_value = body.encode("utf-8")
133 resp.status = status
134 return resp
135
136
137 class TestParamsEncoding(unittest.TestCase):
138 """request() should urlencode the params dict into the URL."""
139
140 def _sent_url(self, mock_urlopen) -> str:
141 request_arg = mock_urlopen.call_args[0][0]
142 return request_arg.full_url
143
144 @patch("lib.http.urllib.request.urlopen")
145 def test_params_appended_to_url(self, mock_urlopen):
146 mock_urlopen.return_value = _mock_response()
147 http.get("https://api.example.com/search", params={"q": "test", "limit": 10})
148 sent_url = self._sent_url(mock_urlopen)
149 self.assertIn("q=test", sent_url)
150 self.assertIn("limit=10", sent_url)
151
152 @patch("lib.http.urllib.request.urlopen")
153 def test_params_appended_with_existing_query_string(self, mock_urlopen):
154 mock_urlopen.return_value = _mock_response()
155 http.get("https://api.example.com/search?api_key=secret", params={"q": "test"})
156 sent_url = self._sent_url(mock_urlopen)
157 self.assertTrue(sent_url.startswith("https://api.example.com/search?api_key=secret&"))
158 self.assertIn("q=test", sent_url)
159
160 @patch("lib.http.urllib.request.urlopen")
161 def test_none_values_dropped(self, mock_urlopen):
162 mock_urlopen.return_value = _mock_response()
163 http.get("https://api.example.com/search", params={"q": "test", "filter": None})
164 sent_url = self._sent_url(mock_urlopen)
165 self.assertIn("q=test", sent_url)
166 self.assertNotIn("filter", sent_url)
167
168 @patch("lib.http.urllib.request.urlopen")
169 def test_empty_params_leaves_url_unchanged(self, mock_urlopen):
170 mock_urlopen.return_value = _mock_response()
171 http.get("https://api.example.com/search", params={})
172 sent_url = self._sent_url(mock_urlopen)
173 self.assertEqual(sent_url, "https://api.example.com/search")
174
175 @patch("lib.http.urllib.request.urlopen")
176 def test_no_params_kwarg_leaves_url_unchanged(self, mock_urlopen):
177 mock_urlopen.return_value = _mock_response()
178 http.get("https://api.example.com/search")
179 sent_url = self._sent_url(mock_urlopen)
180 self.assertEqual(sent_url, "https://api.example.com/search")
181
182 @patch("lib.http.urllib.request.urlopen")
183 def test_int_and_bool_params_stringified(self, mock_urlopen):
184 mock_urlopen.return_value = _mock_response()
185 http.get("https://api.example.com/search", params={"count": 25, "raw": True})
186 sent_url = self._sent_url(mock_urlopen)
187 self.assertIn("count=25", sent_url)
188 self.assertIn("raw=True", sent_url)
189
190
191 class TestDNSResolutionRetry(unittest.TestCase):
192 """DNS resolution failures (gaierror) must retry with exponential backoff.
193
194 Caller-passed `retries` values smaller than MIN_DNS_RETRIES are expanded
195 on the first gaierror so a transient resolution failure doesn't wipe a
196 request just because the caller passed retries=2.
197 """
198
199 @patch("lib.http.urllib.request.urlopen")
200 @patch("lib.http.time.sleep")
201 def test_gaierror_retries_up_to_min_dns_retries_even_when_caller_passes_fewer(
202 self, mock_sleep, mock_urlopen
203 ):
204 """Caller passed retries=2; gaierror should still get MIN_DNS_RETRIES attempts."""
205 import socket
206 err = urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))
207 mock_urlopen.side_effect = err
208
209 with self.assertRaises(http.HTTPError):
210 http.request("GET", "http://nonexistent.example", retries=2)
211
212 # Caller passed retries=2, but the budget expanded to MIN_DNS_RETRIES=3.
213 self.assertEqual(mock_urlopen.call_count, http.MIN_DNS_RETRIES)
214
215 @patch("lib.http.urllib.request.urlopen")
216 @patch("lib.http.time.sleep")
217 def test_gaierror_succeeds_after_transient_failure(self, mock_sleep, mock_urlopen):
218 """gaierror on attempt 1, then success — should NOT raise."""
219 import socket
220 success_response = MagicMock()
221 success_response.read.return_value = b'{"ok": true}'
222 success_response.status = 200
223 success_response.__enter__ = lambda self: self
224 success_response.__exit__ = lambda *args: None
225
226 err = urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))
227 mock_urlopen.side_effect = [err, success_response]
228
229 result = http.request("GET", "http://flaky.example", retries=2)
230
231 self.assertEqual(result, {"ok": True})
232 self.assertEqual(mock_urlopen.call_count, 2)
233
234 @patch("lib.http.urllib.request.urlopen")
235 @patch("lib.http.time.sleep")
236 def test_gaierror_uses_exponential_backoff(self, mock_sleep, mock_urlopen):
237 """Backoff delays for gaierror should be 1s, 2s, 4s — not the linear default."""
238 import socket
239 err = urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))
240 mock_urlopen.side_effect = err
241
242 with self.assertRaises(http.HTTPError):
243 http.request("GET", "http://nonexistent.example", retries=3)
244
245 # Expected sleep calls: 1s (after attempt 1), 2s (after attempt 2).
246 # No sleep after the final attempt (the loop exits to raise).
247 sleep_delays = [call.args[0] for call in mock_sleep.call_args_list]
248 self.assertEqual(sleep_delays, [1, 2])
249
250 @patch("lib.http.urllib.request.urlopen")
251 @patch("lib.http.time.sleep")
252 def test_non_dns_urlerror_uses_linear_backoff_not_dns_branch(
253 self, mock_sleep, mock_urlopen
254 ):
255 """A URLError that's NOT a gaierror must NOT expand the retry budget."""
256 # ConnectionRefusedError-style URLError reason (not gaierror)
257 err = urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))
258 mock_urlopen.side_effect = err
259
260 with self.assertRaises(http.HTTPError):
261 http.request("GET", "http://refused.example", retries=2)
262
263 # Caller passed retries=2, and non-DNS URLError doesn't expand it.
264 self.assertEqual(mock_urlopen.call_count, 2)
265
266 @patch("lib.http.urllib.request.urlopen")
267 @patch("lib.http.time.sleep")
268 def test_dns_widening_does_not_leak_into_subsequent_non_dns_urlerror(
269 self, mock_sleep, mock_urlopen
270 ):
271 """Mixed sequence: DNS-then-non-DNS must respect caller's original retries.
272
273 Without the fix, the first gaierror widens effective_retries from 2 to
274 MIN_DNS_RETRIES=3, and a subsequent ConnectionRefused on attempt 1
275 slips into a third overall attempt — exceeding what the caller asked
276 for. Each non-DNS error path must gate on the original `retries`.
277 """
278 import socket
279 dns_err = urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))
280 conn_err = urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))
281 mock_urlopen.side_effect = [dns_err, conn_err, conn_err] # 3rd would only fire if budget leaked
282
283 with self.assertRaises(http.HTTPError):
284 http.request("GET", "http://flaky.example", retries=2)
285
286 # Caller asked for at most 2 attempts. DNS widening must not give us a 3rd.
287 self.assertEqual(mock_urlopen.call_count, 2)
288
289 @patch("lib.http.urllib.request.urlopen")
290 @patch("lib.http.time.sleep")
291 def test_dns_widening_does_not_leak_into_subsequent_oserror(
292 self, mock_sleep, mock_urlopen
293 ):
294 """Mixed sequence: DNS-then-OSError must respect caller's original retries."""
295 import socket
296 dns_err = urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))
297 mock_urlopen.side_effect = [dns_err, TimeoutError("timed out"), TimeoutError("timed out")]
298
299 with self.assertRaises(http.HTTPError):
300 http.request("GET", "http://flaky.example", retries=2)
301
302 self.assertEqual(mock_urlopen.call_count, 2)
303
303 lines PYTHON