From 4809c8f97fd44c8f42fa64c4dbd14e2e2c0cdd65 Mon Sep 17 00:00:00 2001 From: James Addison Date: Fri, 12 Jan 2024 13:42:23 +0000 Subject: [PATCH] linkcheck: add a distinct 'timeout' reporting status --- CHANGES.rst | 2 ++ sphinx/builders/linkcheck.py | 16 +++++++++++++++- tests/test_build_linkcheck.py | 21 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 73cb7409de0..a19a28f03d5 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 6828e99e420..b8ee94cee39 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -17,6 +17,7 @@ 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 @@ def finish(self) -> None: 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 @@ def process_result(self, result: CheckResult) -> None: 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 @@ def _check_uri(self, uri: str, hyperlink: Hyperlink) -> tuple[str, str, int]: 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 28529d9b0cb..de6d5f02a8c 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