Skip to content

Commit 9d062b0

Browse files
committed
fix(verify): let GET decide when HEAD fails with a non-transient status
Tier 1 only retried with GET when HEAD answered 400/403/405/501. Kaggle dataset pages answer HEAD with 404 and GET with 200, so cited datasets such as msainani/gsmarena-mobile-devices and jakubkhalponiak/phones-2024 were counted as http-404. HEAD is now only a shortcut. A success answers the question, and 429/503 still goes straight to check_one's backoff without an immediate GET. Any other status of 400 or more falls through to GET, and GET's answer is the result, both for a returned status and in the HTTPError branch. The retry-after and challenge flags are reset per request, so a HEAD's flags no longer stick to a GET answer.
1 parent 1c4b4fa commit 9d062b0

2 files changed

Lines changed: 67 additions & 12 deletions

File tree

‎app/verify/http_check.py‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ def _is_automation_challenge(exc: Exception, code: int) -> bool:
155155
def _attempt(url: str, opener: Any) -> tuple[int | None, str | None, float | None, bool]:
156156
"""One HEAD-then-GET pass.
157157
158+
HEAD is only a shortcut: a success answers the question, and a rate-limit
159+
answer (429/503) goes straight back to :func:`check_one`'s backoff. Any other
160+
status of 400 or more, or a network error, falls through to GET and GET's
161+
answer is the result. Some hosts answer HEAD with a status GET does not
162+
give: Kaggle dataset pages answer HEAD with 404 and GET with 200.
163+
158164
Returns ``(status, final_url, retry_after, automation_challenge)``. The last
159165
value is intentionally based on an explicit gateway signal, never on a bare
160166
403, so unavailable pages cannot become promotable merely by returning 403.
@@ -164,28 +170,26 @@ def _attempt(url: str, opener: Any) -> tuple[int | None, str | None, float | Non
164170
retry_after: float | None = None
165171
automation_challenge = False
166172
for method in ("HEAD", "GET"):
173+
retry_after, automation_challenge = None, False
167174
try:
168175
status, final = opener.open(url, method)
169-
if method == "HEAD" and status in (400, 403, 405, 501):
170-
continue # server dislikes HEAD -> retry GET
171-
break
172176
except Exception as exc: # HTTPError carries a code; everything else is dead
173177
code = getattr(exc, "code", None)
174-
if isinstance(code, int):
175-
status, final = code, getattr(exc, "url", None) or url
176-
retry_after = _retry_after_seconds(exc)
177-
automation_challenge = _is_automation_challenge(exc, code)
178-
if method == "HEAD" and code in (400, 403, 405, 501):
179-
continue
180-
break
181-
status, final, automation_challenge = None, None, False
178+
if not isinstance(code, int):
179+
status, final = None, None
180+
continue
181+
status, final = code, getattr(exc, "url", None) or url
182+
retry_after = _retry_after_seconds(exc)
183+
automation_challenge = _is_automation_challenge(exc, code)
184+
if status < 400 or status in TRANSIENT_STATUSES:
185+
break
182186
return status, final, retry_after, automation_challenge
183187

184188

185189
def check_one(
186190
url: str, opener: Any, *, on_rate_limit: Callable[[str], None] | None = None
187191
) -> CheckResult:
188-
"""HEAD first; fall back to GET when HEAD is rejected (405/403) or errors.
192+
"""HEAD first; when HEAD fails with anything but a rate limit, GET decides.
189193
190194
A rate-limit answer (429/503) is retried with backoff — the host is telling us
191195
to wait, not that the page is gone.

‎tests/verify/test_http_check.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,57 @@ def open(self, url, method):
6161
assert res.alive and res.status == 200
6262

6363

64+
class ByMethod:
65+
"""Answers HEAD and GET differently; an Exception value is raised."""
66+
67+
def __init__(self, head, get):
68+
self.answers = {"HEAD": head, "GET": get}
69+
self.calls = []
70+
71+
def open(self, url, method):
72+
self.calls.append(method)
73+
val = self.answers[method]
74+
if isinstance(val, Exception):
75+
raise val
76+
return val
77+
78+
79+
def _http_error(code):
80+
return type("E", (Exception,), {"code": code, "url": None, "headers": {}})()
81+
82+
83+
def test_head_404_then_get_200_is_alive():
84+
# Kaggle dataset pages answer HEAD with 404 and GET with 200.
85+
url = "https://www.kaggle.com/datasets/msainani/gsmarena-mobile-devices"
86+
op = ByMethod(head=_http_error(404), get=(200, url))
87+
res = http_check.check_one(url, op)
88+
assert res.alive and res.status == 200
89+
assert op.calls == ["HEAD", "GET"]
90+
91+
92+
def test_head_404_returned_then_get_200_is_alive():
93+
url = "https://www.kaggle.com/datasets/jakubkhalponiak/phones-2024"
94+
res = http_check.check_one(url, ByMethod(head=(404, url), get=(200, url)))
95+
assert res.alive and res.status == 200
96+
97+
98+
def test_head_404_then_get_404_is_dead():
99+
url = "https://example.com/gone"
100+
op = ByMethod(head=_http_error(404), get=_http_error(404))
101+
res = http_check.check_one(url, op)
102+
assert not res.alive and res.reason == "http-404"
103+
assert op.calls == ["HEAD", "GET"]
104+
105+
106+
def test_head_429_does_not_get_in_the_same_attempt(monkeypatch):
107+
monkeypatch.setattr(http_check.time, "sleep", lambda _s: None)
108+
url = "https://www.gsmarena.com/x-1.php"
109+
op = ByMethod(head=_Http429(url, retry_after="0"), get=(200, url))
110+
res = http_check.check_one(url, op)
111+
assert res.status == 429 and res.transient
112+
assert op.calls == ["HEAD"] * http_check.RETRY_ATTEMPTS
113+
114+
64115
def test_connection_error_is_dead():
65116
table = {"https://nope.invalid/x": ConnectionError("no route")}
66117
[res] = http_check.check_urls(list(table), opener_factory=_factory(table), min_interval=0)

0 commit comments

Comments
 (0)