diff --git a/CHANGES.rst b/CHANGES.rst index 73cb7409d..a19a28f03 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -57,6 +57,8 @@ Bugs fixed Set this option to ``False`` to report HTTP 401 (unauthorized) server responses as broken. Patch by James Addison. +* #11868: linkcheck: added a distinct ``timeout`` reporting status code. + Patch by James Addison. Testing ------- diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 6828e99e4..b8ee94cee 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -17,6 +17,7 @@ from urllib.parse import unquote, urlparse, urlsplit, urlunparse from docutils import nodes from requests.exceptions import ConnectionError, HTTPError, SSLError, TooManyRedirects +from requests.exceptions import Timeout as RequestTimeout from sphinx.builders.dummy import DummyBuilder from sphinx.deprecation import RemovedInSphinx80Warning @@ -64,6 +65,7 @@ class CheckExternalLinksBuilder(DummyBuilder): def init(self) -> None: self.broken_hyperlinks = 0 + self.timed_out_hyperlinks = 0 self.hyperlinks: dict[str, Hyperlink] = {} # set a timeout for non-responding servers socket.setdefaulttimeout(5.0) @@ -88,7 +90,7 @@ class CheckExternalLinksBuilder(DummyBuilder): for result in checker.check(self.hyperlinks): self.process_result(result) - if self.broken_hyperlinks: + if self.broken_hyperlinks or self.timed_out_hyperlinks: self.app.statuscode = 1 def process_result(self, result: CheckResult) -> None: @@ -115,6 +117,15 @@ class CheckExternalLinksBuilder(DummyBuilder): self.write_entry('local', result.docname, filename, result.lineno, result.uri) elif result.status == 'working': logger.info(darkgreen('ok ') + result.uri + result.message) + elif result.status == 'timeout': + if self.app.quiet or self.app.warningiserror: + logger.warning('timeout ' + result.uri + result.message, + location=(result.docname, result.lineno)) + else: + logger.info(red('timeout ') + result.uri + red(' - ' + result.message)) + self.write_entry('timeout', result.docname, filename, result.lineno, + result.uri + ': ' + result.message) + self.timed_out_hyperlinks += 1 elif result.status == 'broken': if self.app.quiet or self.app.warningiserror: logger.warning(__('broken link: %s (%s)'), result.uri, result.message, @@ -436,6 +447,9 @@ class HyperlinkAvailabilityCheckWorker(Thread): del response break + except RequestTimeout as err: + return 'timeout', str(err), 0 + except SSLError as err: # SSL failure; report that the link is broken. return 'broken', str(err), 0 diff --git a/tests/test_build_linkcheck.py b/tests/test_build_linkcheck.py index 28529d9b0..de6d5f02a 100644 --- a/tests/test_build_linkcheck.py +++ b/tests/test_build_linkcheck.py @@ -854,6 +854,27 @@ def test_too_many_requests_retry_after_without_header(app, capsys): ) +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) +def test_requests_timeout(app): + class DelayedResponseHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + time.sleep(0.2) # wait before sending any response data + self.send_response(200, "OK") + self.send_header("Content-Length", "0") + self.end_headers() + + app.config.linkcheck_timeout = 0.01 + with http_server(DelayedResponseHandler): + app.build() + + with open(app.outdir / "output.json", encoding="utf-8") as fp: + content = json.load(fp) + + assert content["status"] == "timeout" + + @pytest.mark.sphinx('linkcheck', testroot='linkcheck-localserver', freshenv=True) def test_too_many_requests_user_timeout(app): app.config.linkcheck_rate_limit_timeout = 0.0