Repository: sphinx-doc/sphinx

Issue:
linkcheck builder: add a distinct reporting status code for timeouts
**Is your feature request related to a problem? Please describe.**
Currently when the `linkcheck` builder encounters a timeout while checking a link, the corresponding link will be reported as `broken`.

This makes it difficult to distinguish between webservers that are slow to respond, as compared to received responses that indicate that a given hyperlink no longer returns useful information.  That distinction could be valuable, for example to help determine whether hyperlink unavailability is due to transient or network-related conditions.

**Describe the solution you'd like**
An additional reporting status for the `linkcheck` builder.  The simplest name for this status would be `timeout`.

**Describe alternatives you've considered**
TODO: are there alternatives approaches that would be an improvement on this?

**Additional context**
Technically the linkcheck builder has (at least) two kinds of timeouts - there's a low-level network socket timeout, which we set to 5 seconds, and there's the per-hyperlink request timeout, relating to the time that the (connected) client will wait for a response from the webserver.

- Opened following discussion in #11853.


Discussion:
I think this makes sense.

A



Patch:
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


Test patch:
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