Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/requests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/test_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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