From 54a6f346366e0e70aa8dd66c04c8a5b587eb5ef6 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 13 Sep 2026 09:34:35 +0000 Subject: [PATCH 1/3] fix(core): close HTTPError response file in HttpWaitStrategy HTTPError wraps the temporary file holding the response body. When a container's HTTP endpoint answered with an error status while the HttpWaitStrategy was still polling, the exception was discarded without closing it, leaking a file descriptor (and a ResourceWarning under -Werror) on every failed attempt. Use the caught HTTPError as a context manager so the response file is released on every path. URLError carries no file object and keeps the existing handling. Closes #1115 --- src/testcontainers/core/wait_strategies.py | 7 +++++- tests/core/test_wait_strategies.py | 28 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/testcontainers/core/wait_strategies.py b/src/testcontainers/core/wait_strategies.py index ac83ffa29..4e9f5a778 100644 --- a/src/testcontainers/core/wait_strategies.py +++ b/src/testcontainers/core/wait_strategies.py @@ -418,7 +418,12 @@ def _try_http_request(self, url: str, headers: dict[str, str], ssl_context: Any) with urlopen(request, timeout=1, context=ssl_context) as response: return self._check_response(response, url) - except (URLError, HTTPError) as e: + except HTTPError as e: + # HTTPError wraps the response file object, so it doubles as a + # context manager to avoid leaking the file descriptor. + with e: + return self._handle_http_error(e) + except URLError as e: return self._handle_http_error(e) except (ConnectionResetError, ConnectionRefusedError, BrokenPipeError, OSError) as e: # Handle connection-level errors that can occur during HTTP requests diff --git a/tests/core/test_wait_strategies.py b/tests/core/test_wait_strategies.py index 2f5f3d408..60c1be572 100644 --- a/tests/core/test_wait_strategies.py +++ b/tests/core/test_wait_strategies.py @@ -1,9 +1,12 @@ +import io import itertools import logging import re import time from datetime import timedelta +from email.message import Message from unittest.mock import Mock, patch +from urllib.error import HTTPError import pytest @@ -361,6 +364,31 @@ def test_from_url(self, url, expected_port, expected_path, expected_tls): assert strategy._path == expected_path assert strategy._tls is expected_tls + @pytest.mark.parametrize( + "status_codes,expected_result", + [ + ({503}, True), + (set(), False), + ], + ids=[ + "accepted_error_status_code", + "unaccepted_error_status_code", + ], + ) + @patch("testcontainers.core.wait_strategies.urlopen") + def test_try_http_request_closes_http_error(self, mock_urlopen, status_codes, expected_result): + """HTTPError holds the response's file object and must be closed (issue #1115).""" + fp = io.BytesIO(b"error body") + mock_urlopen.side_effect = HTTPError("http://localhost:8080/", 503, "Service Unavailable", Message(), fp) + strategy = HttpWaitStrategy(8080) + for code in status_codes: + strategy.for_status_code(code) + + result = strategy._try_http_request("http://localhost:8080/", {}, None) + + assert result is expected_result + assert fp.closed + class TestHealthcheckWaitStrategy: """Test the HealthcheckWaitStrategy class.""" From f746f2012b287833c9f9cda4af4d61592290d333 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 13 Sep 2026 09:38:05 +0000 Subject: [PATCH 2/3] test(core): cover URLError path in HttpWaitStrategy The split except clause for HTTPError/URLError in _try_http_request was missing coverage for a bare URLError. --- tests/core/test_wait_strategies.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/core/test_wait_strategies.py b/tests/core/test_wait_strategies.py index 60c1be572..7a0ecddf6 100644 --- a/tests/core/test_wait_strategies.py +++ b/tests/core/test_wait_strategies.py @@ -6,7 +6,7 @@ from datetime import timedelta from email.message import Message from unittest.mock import Mock, patch -from urllib.error import HTTPError +from urllib.error import HTTPError, URLError import pytest @@ -389,6 +389,14 @@ def test_try_http_request_closes_http_error(self, mock_urlopen, status_codes, ex assert result is expected_result assert fp.closed + @patch("testcontainers.core.wait_strategies.urlopen") + def test_try_http_request_url_error(self, mock_urlopen): + """A URLError without a wrapped response is not an acceptable status code.""" + mock_urlopen.side_effect = URLError("connection refused") + strategy = HttpWaitStrategy(8080) + + assert strategy._try_http_request("http://localhost:8080/", {}, None) is False + class TestHealthcheckWaitStrategy: """Test the HealthcheckWaitStrategy class.""" From f703e06e4daeb80cc2844901e3214cf9a9cb8d17 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Mon, 14 Sep 2026 01:38:11 +0000 Subject: [PATCH 3/3] test(core): drop vacuous URLError wait-strategy case The assertion returned False both with and without the HTTPError close fix, so it did not lock in the leak change. --- tests/core/test_wait_strategies.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/core/test_wait_strategies.py b/tests/core/test_wait_strategies.py index 7a0ecddf6..60c1be572 100644 --- a/tests/core/test_wait_strategies.py +++ b/tests/core/test_wait_strategies.py @@ -6,7 +6,7 @@ from datetime import timedelta from email.message import Message from unittest.mock import Mock, patch -from urllib.error import HTTPError, URLError +from urllib.error import HTTPError import pytest @@ -389,14 +389,6 @@ def test_try_http_request_closes_http_error(self, mock_urlopen, status_codes, ex assert result is expected_result assert fp.closed - @patch("testcontainers.core.wait_strategies.urlopen") - def test_try_http_request_url_error(self, mock_urlopen): - """A URLError without a wrapped response is not an acceptable status code.""" - mock_urlopen.side_effect = URLError("connection refused") - strategy = HttpWaitStrategy(8080) - - assert strategy._try_http_request("http://localhost:8080/", {}, None) is False - class TestHealthcheckWaitStrategy: """Test the HealthcheckWaitStrategy class."""