From 0a4eb313fefd670b486545a23c1ab4a03825f421 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 22 Oct 2026 20:03:52 -0400 Subject: [PATCH] fix(6255): Test destination names an address that did not answer as S3 A 4xx with no S3 error body came from something else at that address, most often a web server on the default port. The message now says so and to check the scheme and port, beside the raw status. A 5xx without a body keeps its own reason, since a busy S3 service can answer that way. Co-Authored-By: Claude Opus 5.5 --- src/grimoire/services/backup_s3.py | 6 ++++++ tests/test_services_backup_s3.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/grimoire/services/backup_s3.py b/src/grimoire/services/backup_s3.py index 63324f1a..cf54bc32 100644 --- a/src/grimoire/services/backup_s3.py +++ b/src/grimoire/services/backup_s3.py @@ -120,6 +120,12 @@ def _says(response: httpx.Response) -> str: "AccessDenied": "the key isn't allowed to do that", } said = hints.get(code) or message or response.reason_phrase + if not code and 400 <= response.status_code < 500: + # S3 refuses with an body. A 4xx without one came from + # something else at that address — most often a web server on the + # default port, when the S3 service listens on a port of its own. + said = ("the address answered, but not as S3 — check the scheme and " + "the port (an S3 service usually listens on its own port)") return f"HTTP {response.status_code}{f' {code}' if code else ''}: {said}." diff --git a/tests/test_services_backup_s3.py b/tests/test_services_backup_s3.py index be2d7d59..ddca269a 100644 --- a/tests/test_services_backup_s3.py +++ b/tests/test_services_backup_s3.py @@ -78,6 +78,22 @@ def test_a_refusal_says_why(tmp_path): asyncio.run(s3.put_file(TARGET, "b.tar", f, transport=transport)) +def test_a_web_server_at_the_address_is_named_as_not_s3(): + """No port in the address: the request lands on whatever serves port 80, + and its plain 404 says nothing about S3.""" + page = b"

404 Not Found

" + transport = httpx.MockTransport(lambda r: httpx.Response(404, content=page)) + with pytest.raises(s3.S3Error, match=r"HTTP 404: the address answered, but not as S3 — check the scheme and the port"): + asyncio.run(s3.list_objects(TARGET, transport=transport)) + + +def test_a_server_error_without_a_body_is_not_blamed_on_the_port(): + transport = httpx.MockTransport(lambda r: httpx.Response(503)) + with pytest.raises(s3.S3Error, match="HTTP 503: Service Unavailable") as err: + asyncio.run(s3.list_objects(TARGET, transport=transport)) + assert "port" not in str(err.value) + + def test_unreachable_says_so(tmp_path): f = tmp_path / "b.tar" f.write_bytes(b"x")