From 721c25cacb1e4bb6ae7bb5bd01eb67f48ae47d7c Mon Sep 17 00:00:00 2001 From: armorbreak001 Date: Sat, 20 Jun 2026 16:29:53 +0800 Subject: [PATCH] fix: retain stream read error on subsequent response.content accesses (#4965) When accessing response.content raises an exception during stream reading (e.g., ConnectionError, ChunkedEncodingError), subsequent accesses would return empty bytes (b"") or raise a generic RuntimeError instead of re-raising the original error. This made debugging especially difficult in debuggers where properties may be accessed multiple times, as the original error was silently lost. Changes: - Add _content_error attribute to cache the first read exception - Check _content_error early in content property (before re-read attempt) - Re-raise the same exception object on subsequent accesses - Add regression tests for full-error and partial-read failure cases Fixes #4965 --- src/requests/models.py | 14 +++++++++++- tests/test_lowlevel.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/requests/models.py b/src/requests/models.py index 17b39cd1b3..7bbed7cea2 100644 --- a/src/requests/models.py +++ b/src/requests/models.py @@ -736,6 +736,7 @@ class Response: _content: bytes | Literal[False] | None _content_consumed: bool + _content_error: BaseException | None _next: PreparedRequest | None status_code: int headers: CaseInsensitiveDict[str] @@ -765,6 +766,7 @@ class Response: def __init__(self) -> None: self._content = False self._content_consumed = False + self._content_error = None self._next = None #: Integer Code of responded HTTP Status, e.g. 404 or 200. @@ -1035,6 +1037,12 @@ def iter_lines( def content(self) -> bytes: """Content of the response, in bytes.""" + # If a previous read attempt failed, re-raise the same exception. + # This ensures consistent behaviour when accessing .content multiple + # times after a stream read error (e.g. in a debugger). + if self._content_error is not None: + raise self._content_error + if self._content is False: # Read the contents. if self._content_consumed: @@ -1043,7 +1051,11 @@ def content(self) -> bytes: if self.status_code == 0 or self.raw is None: self._content = None else: - self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b"" + try: + self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b"" + except Exception as e: + self._content_error = e + raise self._content_consumed = True # don't need to release the connection; that's been handled by urllib3 diff --git a/tests/test_lowlevel.py b/tests/test_lowlevel.py index 0a722b07a5..9497bb0f9b 100644 --- a/tests/test_lowlevel.py +++ b/tests/test_lowlevel.py @@ -426,3 +426,51 @@ def response_handler(sock): assert isinstance(excinfo.value, requests.exceptions.RequestException) assert isinstance(excinfo.value, JSONDecodeError) assert r.text not in str(excinfo.value) + + +def test_response_content_retains_error(): + """Accessing response.content after a stream read error should re-raise + the same exception, not return empty bytes or raise RuntimeError. + + Regression test for https://github.com/psf/requests/issues/4965 + """ + from unittest.mock import MagicMock + + r = requests.models.Response() + r.status_code = 200 + r.raw = MagicMock() + r.raw.stream = MagicMock(side_effect=requests.exceptions.ConnectionError("connection reset")) + + # First access should raise ConnectionError + with pytest.raises(requests.exceptions.ConnectionError): + _ = r.content + + # Second access should raise the same ConnectionError, not b"" or RuntimeError + with pytest.raises(requests.exceptions.ConnectionError): + _ = r.content + + +def test_response_content_retains_error_after_partial_read(): + """If iter_content fails mid-stream, the error should still be retained.""" + from unittest.mock import MagicMock + + def failing_stream(chunk_size, decode_content=True): + yield b"partial data" + raise requests.exceptions.ChunkedEncodingError("Connection broken") + + r = requests.models.Response() + r.status_code = 200 + r.raw = MagicMock() + r.raw.stream = failing_stream + + first_error = None + try: + _ = r.content + except Exception as e: + first_error = e + assert isinstance(e, requests.exceptions.ChunkedEncodingError) + + # Second access should raise the same error type + assert first_error is not None + with pytest.raises(requests.exceptions.ChunkedEncodingError): + _ = r.content