Skip to content

Commit 61a1a48

Browse files
author
root
committed
linkcheck: retry HTTP timeouts when using linkcheck_retries
Apply linkcheck_retries to transient timeouts when linkcheck_report_timeouts_as_broken is false (default). The outer retry loop previously only continued on BROKEN, so TIMEOUT was returned immediately and extra retries were never used (fixes #14339). Add regression coverage using a closure counter for request counting (Ruff B010; avoids setattr/dynamic attrs on TCPServer). - sphinx/builders/linkcheck.py - tests/test_builders/test_build_linkcheck.py - CHANGES.rst Made-with: Cursor
1 parent cc7c6f4 commit 61a1a48

File tree

3 files changed

+61
-3
lines changed

3 files changed

+61
-3
lines changed

CHANGES.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
Release in development
2+
======================
3+
4+
Bugs fixed
5+
----------
6+
7+
* #14339: linkcheck: Apply :confval:`linkcheck_retries` to HTTP timeouts when
8+
:confval:`linkcheck_report_timeouts_as_broken` is false (default).
9+
110
Release 9.1.0 (released Dec 31, 2025)
211
=====================================
312

sphinx/builders/linkcheck.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,12 +478,18 @@ def _check(self, docname: str, uri: str, hyperlink: Hyperlink) -> _URIProperties
478478
return _Status.BROKEN, '', 0
479479

480480
# need to actually check the URI
481-
status: _Status
482481
status, info, code = _Status.UNKNOWN, '', 0
483-
for _ in range(self.retries):
482+
for attempt in range(self.retries):
484483
status, info, code = self._check_uri(uri, hyperlink)
485-
if status != _Status.BROKEN:
484+
if attempt + 1 >= self.retries:
486485
break
486+
# ``linkcheck_retries`` applies to broken links; also retry transient
487+
# timeouts when they are reported as such (not when folded into "broken").
488+
if status == _Status.BROKEN:
489+
continue
490+
if status == _Status.TIMEOUT and self._timeout_status == _Status.TIMEOUT:
491+
continue
492+
break
487493

488494
return status, info, code
489495

tests/test_builders/test_build_linkcheck.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,49 @@ def do_GET(self) -> None:
12571257
assert content['status'] == 'timeout'
12581258

12591259

1260+
@pytest.mark.sphinx(
1261+
'linkcheck',
1262+
testroot='linkcheck-localserver',
1263+
freshenv=True,
1264+
confoverrides={
1265+
'linkcheck_report_timeouts_as_broken': False,
1266+
'linkcheck_timeout': 0.05,
1267+
'linkcheck_retries': 3,
1268+
},
1269+
)
1270+
def test_retries_after_transient_timeout(app: SphinxTestApp) -> None:
1271+
"""linkcheck_retries must retry when the server is slow (issue #14339)."""
1272+
get_n = [0]
1273+
1274+
class SlowThenFastHandler(BaseHTTPRequestHandler):
1275+
protocol_version = 'HTTP/1.1'
1276+
1277+
def do_HEAD(self) -> None:
1278+
# Force a GET so each attempt measures response latency from do_GET.
1279+
self.send_response(405, 'Method Not Allowed')
1280+
self.send_header('Content-Length', '0')
1281+
self.end_headers()
1282+
1283+
def do_GET(self) -> None:
1284+
get_n[0] += 1
1285+
count = get_n[0]
1286+
if count <= 2:
1287+
time.sleep(0.15)
1288+
content = b'ok\n'
1289+
self.send_response(200, 'OK')
1290+
self.send_header('Content-Length', str(len(content)))
1291+
self.end_headers()
1292+
self.wfile.write(content)
1293+
1294+
with serve_application(app, SlowThenFastHandler):
1295+
app.build()
1296+
1297+
with open(app.outdir / 'output.json', encoding='utf-8') as fp:
1298+
content = json.load(fp)
1299+
1300+
assert content['status'] == 'working'
1301+
1302+
12601303
@pytest.mark.sphinx(
12611304
'linkcheck',
12621305
testroot='linkcheck-localserver',

0 commit comments

Comments
 (0)