From e178e7f9bdeac09fee0c3ce56b2d8a3b54e609c2 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 26 Dec 2025 17:31:37 +0000 Subject: [PATCH] fix(linkcheck): handle TooManyRedirects fallback (#54) --- sphinx/builders/linkcheck.py | 4 +- .../test-linkcheck-too-many-redirects/conf.py | 3 + .../index.rst | 4 + tests/test_build_linkcheck.py | 79 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/roots/test-linkcheck-too-many-redirects/conf.py create mode 100644 tests/roots/test-linkcheck-too-many-redirects/index.rst diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 1dc0337c3b1..4104fe6584c 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -20,7 +20,7 @@ from docutils import nodes from docutils.nodes import Node -from requests.exceptions import HTTPError +from requests.exceptions import HTTPError, TooManyRedirects from sphinx.application import Sphinx from sphinx.builders import Builder @@ -172,7 +172,7 @@ def check_uri() -> Tuple[str, str, int]: config=self.app.config, auth=auth_info, **kwargs) response.raise_for_status() - except HTTPError: + except (HTTPError, TooManyRedirects): # retry with GET request if that fails, some servers # don't like HEAD requests. response = requests.get(req_url, stream=True, config=self.app.config, diff --git a/tests/roots/test-linkcheck-too-many-redirects/conf.py b/tests/roots/test-linkcheck-too-many-redirects/conf.py new file mode 100644 index 00000000000..c843c983532 --- /dev/null +++ b/tests/roots/test-linkcheck-too-many-redirects/conf.py @@ -0,0 +1,3 @@ +project = 'linkcheck-too-many-redirects' +extensions = [] +linkcheck_anchors = False diff --git a/tests/roots/test-linkcheck-too-many-redirects/index.rst b/tests/roots/test-linkcheck-too-many-redirects/index.rst new file mode 100644 index 00000000000..a87d0cdf085 --- /dev/null +++ b/tests/roots/test-linkcheck-too-many-redirects/index.rst @@ -0,0 +1,4 @@ +Too Many Redirects linkcheck fixture +==================================== + +* `Target `_ diff --git a/tests/test_build_linkcheck.py b/tests/test_build_linkcheck.py index c09c81fe08c..0b9d174f043 100644 --- a/tests/test_build_linkcheck.py +++ b/tests/test_build_linkcheck.py @@ -14,6 +14,7 @@ import pytest import requests +from requests.exceptions import TooManyRedirects from .utils import CERT_FILE, http_server, https_server, modify_env @@ -382,3 +383,81 @@ def test_connect_to_selfsigned_nonexistent_cert_file(app): "uri": "https://localhost:7777/", "info": "Could not find a suitable TLS CA certificate bundle, invalid path: does/not/exist", } + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-too-many-redirects', freshenv=True) +def test_fallback_get_on_too_many_redirects_head(app, monkeypatch): + target_url = 'https://example.com/too-many-redirects' + calls = {'head': 0, 'get': 0} + + def fake_head(url, **kwargs): + calls['head'] += 1 + assert url == target_url + raise TooManyRedirects('Exceeded 30 redirects.') + + class DummyResponse: + def __init__(self, url): + self.url = url + self.history = [] + + def raise_for_status(self): + return None + + def fake_get(url, **kwargs): + calls['get'] += 1 + assert url == target_url + return DummyResponse(url) + + monkeypatch.setattr('sphinx.builders.linkcheck.requests.head', fake_head) + monkeypatch.setattr('sphinx.builders.linkcheck.requests.get', fake_get) + + app.builder.build_all() + + output_txt = (app.outdir / 'output.txt').read_text() + assert output_txt == '' + + rows = [json.loads(line) for line in (app.outdir / 'output.json').read_text().splitlines()] + assert rows == [{ + 'code': 0, + 'status': 'working', + 'filename': 'index.rst', + 'lineno': 4, + 'uri': target_url, + 'info': '' + }] + assert calls == {'head': 1, 'get': 1} + + +@pytest.mark.sphinx('linkcheck', testroot='linkcheck-too-many-redirects', freshenv=True) +def test_get_failure_after_too_many_redirects_head(app, monkeypatch): + target_url = 'https://example.com/too-many-redirects' + calls = {'head': 0, 'get': 0} + + def fake_head(url, **kwargs): + calls['head'] += 1 + assert url == target_url + raise TooManyRedirects('Exceeded 30 redirects.') + + def fake_get(url, **kwargs): + calls['get'] += 1 + assert url == target_url + raise TooManyRedirects('Exceeded 30 redirects.') + + monkeypatch.setattr('sphinx.builders.linkcheck.requests.head', fake_head) + monkeypatch.setattr('sphinx.builders.linkcheck.requests.get', fake_get) + + app.builder.build_all() + + output_txt = (app.outdir / 'output.txt').read_text() + assert 'https://example.com/too-many-redirects: Exceeded 30 redirects.' in output_txt + + rows = [json.loads(line) for line in (app.outdir / 'output.json').read_text().splitlines()] + assert rows == [{ + 'code': 0, + 'status': 'broken', + 'filename': 'index.rst', + 'lineno': 4, + 'uri': target_url, + 'info': 'Exceeded 30 redirects.' + }] + assert calls == {'head': 1, 'get': 1}