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:
jayaddison: Todo: consider the interaction between these changes and the low-level Python `socket` timeout, because we [configure a value](https://github.com/sphinx-doc/sphinx/blob/bcb1825679aa1aa8d637c4b948a172fa690bec58/sphinx/builders/linkcheck.py#L69) for that.
jayaddison: > Todo: consider the interaction between these changes and the low-level Python `socket` timeout, because we [configure a value](https://github.com/sphinx-doc/sphinx/blob/bcb1825679aa1aa8d637c4b948a172fa690bec58/sphinx/builders/linkcheck.py#L69) for that.

Generally I think that because we provide a `timeout=` argument when making HTTP requests via `requests` (that in turn configures timeouts via `urllib3`), the [`socket.setdefaulttimeout` call](https://github.com/sphinx-doc/sphinx/blob/bcb1825679aa1aa8d637c4b948a172fa690bec58/sphinx/builders/linkcheck.py#L69) should -- in theory -- be redundant.

However: I think we should wait until `urllib3>=2.0.0` is our baseline before considering removing the configuration of a 5s `socket` timeout there.  That's because there were some bugs relating to timeout handling that are addressed in v2.0.0 of `urllib3`.

As a result, I don't think we should remove the `setdefaulttimeout` call before we declare `requests>=2.30.0` as a baseline, at the very earliest.  In practice we should wait longer, because just because [`requests` `2.30.0` _added support for_ `urllib3` v2.0.0](https://github.com/psf/requests/commit/2ad18e0e10e7d7ecd5384c378f25ec8821a10a29) doesn't mean that everyone using it will upgrade immediately.

Patch:
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/_epub_base.py b/sphinx/builders/_epub_base.py
index e47780023..aa1b9ec95 100644
--- a/sphinx/builders/_epub_base.py
+++ b/sphinx/builders/_epub_base.py
@@ -168,7 +168,7 @@ def init(self) -> None:
         self.refnodes: list[dict[str, Any]] = []
 
     def create_build_info(self) -> BuildInfo:
-        return BuildInfo(self.config, self.tags, ['html', 'epub'])
+        return BuildInfo(self.config, self.tags, frozenset({'html', 'epub'}))
 
     def get_theme_config(self) -> tuple[str, dict]:
         return self.config.epub_theme, self.config.epub_theme_options
diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py
index 0f3cccb34..0a6a0a8f0 100644
--- a/sphinx/builders/html/__init__.py
+++ b/sphinx/builders/html/__init__.py
@@ -50,11 +50,12 @@
 from sphinx.writers.html5 import HTML5Translator
 
 if TYPE_CHECKING:
-    from collections.abc import Iterable, Iterator, Sequence
+    from collections.abc import Iterable, Iterator, Set
 
     from docutils.nodes import Node
 
     from sphinx.application import Sphinx
+    from sphinx.config import _ConfigRebuild
     from sphinx.environment import BuildEnvironment
     from sphinx.util.tags import Tags
 
@@ -130,7 +131,7 @@ def __init__(
         self,
         config: Config | None = None,
         tags: Tags | None = None,
-        config_categories: Sequence[str] = (),
+        config_categories: Set[_ConfigRebuild] = frozenset(),
     ) -> None:
         self.config_hash = ''
         self.tags_hash = ''
@@ -239,7 +240,7 @@ def init(self) -> None:
         self.use_index = self.get_builder_config('use_index', 'html')
 
     def create_build_info(self) -> BuildInfo:
-        return BuildInfo(self.config, self.tags, ['html'])
+        return BuildInfo(self.config, self.tags, frozenset({'html'}))
 
     def _get_translations_js(self) -> str:
         candidates = [path.join(dir, self.config.language,
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 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/sphinx/config.py b/sphinx/config.py
index 6a09d6ee8..85524ab06 100644
--- a/sphinx/config.py
+++ b/sphinx/config.py
@@ -24,7 +24,7 @@
 
 if TYPE_CHECKING:
     import os
-    from collections.abc import Collection, Iterator, Sequence
+    from collections.abc import Collection, Iterator, Sequence, Set
 
     from sphinx.application import Sphinx
     from sphinx.environment import BuildEnvironment
@@ -33,7 +33,13 @@
 
 logger = logging.getLogger(__name__)
 
-_ConfigRebuild = Literal['', 'env', 'epub', 'gettext', 'html']
+_ConfigRebuild = Literal[
+    '', 'env', 'epub', 'gettext', 'html',
+    # sphinxcontrib-applehelp
+    'applehelp',
+    # sphinxcontrib-devhelp
+    'devhelp',
+]
 
 CONFIG_FILENAME = 'conf.py'
 UNSERIALIZABLE_TYPES = (type, types.ModuleType, types.FunctionType)
@@ -423,7 +429,7 @@ def add(self, name: str, default: Any, rebuild: _ConfigRebuild,
         valid_types = _validate_valid_types(types)
         self._options[name] = _Opt(default, rebuild, valid_types)
 
-    def filter(self, rebuild: str | Sequence[str]) -> Iterator[ConfigValue]:
+    def filter(self, rebuild: Set[_ConfigRebuild]) -> Iterator[ConfigValue]:
         if isinstance(rebuild, str):
             return (value for value in self if value.rebuild == rebuild)
         return (value for value in self if value.rebuild in rebuild)
diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py
index dfb35bee6..72753e240 100644
--- a/sphinx/environment/__init__.py
+++ b/sphinx/environment/__init__.py
@@ -320,7 +320,7 @@ def _update_config(self, config: Config) -> None:
         else:
             # check if a config value was changed that affects how
             # doctrees are read
-            for item in config.filter('env'):
+            for item in config.filter(frozenset({'env'})):
                 if self.config[item.name] != item.value:
                     self.config_status = CONFIG_CHANGED
                     self.config_status_extra = f' ({item.name!r})'


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