Skip to content

Commit 43e0f9a

Browse files
committed
fix: make redirect and retry handling strict
1 parent bc2dd7e commit 43e0f9a

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

tests/test_check_links.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,61 @@ def test_head_failure_falls_back_to_streaming_get_and_confirms_404() -> None:
122122
assert session.calls[1][2]["stream"] is True
123123

124124

125+
def test_head_redirect_without_location_falls_back_to_get() -> None:
126+
head = FakeResponse(301)
127+
response = FakeResponse(200)
128+
session = FakeSession(head, response)
129+
checker = LinkChecker(
130+
guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0
131+
)
132+
133+
result = checker.check_one(link())
134+
135+
assert result.status == "working"
136+
assert result.method == "GET"
137+
assert head.closed is True
138+
assert response.closed is True
139+
140+
141+
def test_get_redirect_without_location_is_fatal() -> None:
142+
head = FakeResponse(301)
143+
response = FakeResponse(301)
144+
session = FakeSession(head, response)
145+
checker = LinkChecker(
146+
guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0
147+
)
148+
149+
result = checker.check_one(link())
150+
151+
assert result.status == "error"
152+
assert "no Location" in (result.error or "")
153+
assert head.closed is True
154+
assert response.closed is True
155+
156+
157+
def test_unsupported_redirect_status_is_fatal() -> None:
158+
response = FakeResponse(304)
159+
session = FakeSession(response)
160+
checker = LinkChecker(
161+
guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0
162+
)
163+
164+
result = checker.check_one(link())
165+
166+
assert result.status == "error"
167+
assert "unsupported redirect status 304" in (result.error or "")
168+
assert response.closed is True
169+
170+
125171
@pytest.mark.parametrize("status_code", [403, 408, 425, 429, 500, 503])
126172
def test_transient_and_access_denied_statuses_need_review(status_code) -> None:
127173
session = FakeSession(FakeResponse(status_code), FakeResponse(status_code))
128174
checker = LinkChecker(
129-
guard=guard_for(), session_factory=lambda: session, workers=1, min_interval=0
175+
guard=guard_for(),
176+
session_factory=lambda: session,
177+
workers=1,
178+
retries=0,
179+
min_interval=0,
130180
)
131181
assert checker.check_one(link()).status == "review"
132182

@@ -347,10 +397,43 @@ def test_retry_configuration_ignores_unbounded_retry_after() -> None:
347397
retry = session.get_adapter("https://").max_retries
348398
assert retry.respect_retry_after_header is False
349399
assert retry.backoff_max == 5.0
400+
assert retry.status == 0
401+
assert not retry.status_forcelist
350402
finally:
351403
session.close()
352404

353405

406+
def test_status_retry_is_manual_and_closes_each_response() -> None:
407+
first = FakeResponse(503)
408+
second = FakeResponse(200)
409+
410+
class RetrySession(FakeSession):
411+
def __init__(self):
412+
super().__init__(first)
413+
self.responses = iter([first, second])
414+
415+
def head(self, url, **kwargs):
416+
self.calls.append(("HEAD", url, kwargs))
417+
return next(self.responses)
418+
419+
session = RetrySession()
420+
checker = LinkChecker(
421+
guard=guard_for(),
422+
session_factory=lambda: session,
423+
workers=1,
424+
retries=1,
425+
backoff_factor=0,
426+
min_interval=0,
427+
)
428+
429+
result = checker.check_one(link())
430+
431+
assert result.status == "working"
432+
assert len(session.calls) == 2
433+
assert first.closed is True
434+
assert second.closed is True
435+
436+
354437
def test_responses_close_when_result_processing_fails(monkeypatch) -> None:
355438
head = FakeResponse(404)
356439
response = FakeResponse(200)

tools/check_links.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
RETRY_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}
3737
REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
3838
MAX_REDIRECTS = 5
39+
MAX_BACKOFF_SECONDS = 5.0
3940
KNOWN_METADATA_HOSTS = {
4041
"instance-data",
4142
"metadata",
@@ -58,6 +59,10 @@ class UnsafeTarget(ValueError):
5859
"""Raised before a request can reach a non-public network target."""
5960

6061

62+
class RedirectProtocolError(requests.RequestException):
63+
"""Raised when a redirect response cannot be followed safely."""
64+
65+
6166
@dataclass(frozen=True)
6267
class ResolvedTarget:
6368
host: str
@@ -198,11 +203,11 @@ def build_session(
198203
total=retries,
199204
connect=retries,
200205
read=retries,
201-
status=retries,
202206
allowed_methods=frozenset({"HEAD", "GET"}),
203-
status_forcelist=RETRY_STATUS_CODES,
207+
status=0,
208+
status_forcelist=frozenset(),
204209
backoff_factor=backoff_factor,
205-
backoff_max=5.0,
210+
backoff_max=MAX_BACKOFF_SECONDS,
206211
respect_retry_after_header=False,
207212
raise_on_status=False,
208213
)
@@ -319,6 +324,8 @@ def __init__(
319324
) -> None:
320325
self.timeout = timeout
321326
self.workers = workers
327+
self.status_retries = retries
328+
self.backoff_factor = backoff_factor
322329
self.guard = guard or SafeTargetGuard()
323330
factory = session_factory or (
324331
lambda: build_session(
@@ -333,6 +340,7 @@ def _request(
333340
) -> tuple[Any, list[dict[str, Any]]]:
334341
current_url = url
335342
history: list[dict[str, Any]] = []
343+
status_attempt = 0
336344
while True:
337345
self.guard.resolve_url(current_url)
338346
self.rate_limiter.wait(current_url)
@@ -346,9 +354,34 @@ def _request(
346354
if method == "GET"
347355
else session.head(current_url, **kwargs)
348356
)
357+
if (
358+
response.status_code in RETRY_STATUS_CODES
359+
and status_attempt < self.status_retries
360+
):
361+
response.close()
362+
delay = min(
363+
self.backoff_factor * (2**status_attempt), MAX_BACKOFF_SECONDS
364+
)
365+
status_attempt += 1
366+
if delay > 0:
367+
time.sleep(delay)
368+
continue
369+
349370
location = response.headers.get("Location")
350-
if response.status_code not in REDIRECT_STATUS_CODES or not location:
371+
if not 300 <= response.status_code < 400:
351372
return response, history
373+
if response.status_code not in REDIRECT_STATUS_CODES:
374+
response.close()
375+
raise RedirectProtocolError(
376+
f"unsupported redirect status {response.status_code}"
377+
)
378+
if not location:
379+
if method == "HEAD":
380+
return response, history
381+
response.close()
382+
raise RedirectProtocolError(
383+
f"redirect status {response.status_code} has no Location header"
384+
)
352385

353386
try:
354387
if len(history) >= MAX_REDIRECTS:
@@ -371,13 +404,14 @@ def _request(
371404
finally:
372405
response.close()
373406
current_url = next_url
407+
status_attempt = 0
374408

375409
def check_one(self, link: CatalogLink) -> LinkResult:
376410
try:
377411
session = self.sessions.get()
378412
head, head_history = self._request(session, "HEAD", link.url)
379413
try:
380-
if head.status_code < 400:
414+
if head.status_code < 300:
381415
return LinkResult(
382416
link.id,
383417
link.path,

0 commit comments

Comments
 (0)