Fixing response.content Error Amnesia in requests

The second call to response.content after a read error silently returned empty string. A 4-line fix makes it raise an exception instead.

The Bug

Repo: psf/requests Issue: #4965 Status: Fix + test verified locally Filed by: tlandschoff-scale

Description: Accessing response.content twice after a read error — the first call raises an exception, the second call silently returns an empty string.

This is a classic error amnesia bug: the content property doesn’t persist the fact that reading failed, so a second access tries again on a dead stream and gets nothing.

Fix scope: 4 lines added in src/requests/models.py — wrapped the content-reading block in try/except.

Root Cause

The content property in Response (models.py line 1031) reads the response body lazily:

@property
def content(self) -> bytes:
    if self._content is False:
        self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""

    self._content_consumed = True
    return self._content

When iter_content() raises (connection reset, protocol error, timeout), _content stays False. On the second call, the guard if self._content is False is still True, so it tries to read again. But the raw stream has already been consumed by the failed attempt — urllib3 returns empty bytes. The second call completes “successfully” with b"".

This is especially dangerous in debugging or error-handling code:

response = requests.post("http://example.com", stream=True)
try:
    content = response.content
except ConnectionError:
    # Hmm, something went wrong. Let me check...
    pass

# Second access — silently returns '' instead of re-raising
second_try = response.content  # 💥 empty bytes, no error

The Fix

Wrap the content-reading block in a try/except. On failure, mark the response as consumed and re-raise. The second call then hits the RuntimeError("content already consumed") path — an explicit failure instead of silent data loss.

# Before:
else:
    self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""

# After:
else:
    try:
        self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
    except BaseException:
        self._content_consumed = True
        raise

The except BaseException catches everything (ConnectionError, ChunkedEncodingError, ContentDecodingError, etc.) and marks the state so subsequent access doesn’t silently retry.

What Changes

ScenarioBefore (broken)After (fixed)
First read fails, second accessReturns b"" silentlyRaises RuntimeError
Normal read, single accessWorks correctlyWorks correctly
Normal read, second accessReturns cached bytesReturns cached bytes
Empty response bodyReturns b""Returns b""

Testing

I wrote a unit test that simulates a failed stream read:

def test_content_raises_on_second_access_after_error():
    resp = Response()
    resp.status_code = 200
    resp.raw.stream = lambda *a, **kw: (_ for _ in ()).throw(
        ProtocolError("Connection broken")
    )

    # First access raises
    with pytest.raises(Exception):
        _ = resp.content

    # Second access must also raise
    with pytest.raises(RuntimeError, match="already consumed"):
        _ = resp.content

The fix passes all 337 existing tests — zero regressions.

Lessons Learned

  1. Error state must be persistent. If a property fails, it must store the failure so subsequent calls don’t silently succeed with garbage data.

  2. Code is a state machine. _content and _content_consumed form a tiny state machine with 3 states:

    • _content is False, _content_consumed is False → unread
    • _content is bytes, _content_consumed is True → read successfully
    • _content is False, _content_consumed is True → read failed (our new state)
  3. Always set consumed=True in the error path. Without it, the object enters an infinite retry loop — and in this case, the retry silently returned garbage.

How to Detect This in Your Code

Search for patterns where you call response.content inside a try/except and then use it again later:

try:
    data = resp.content
except:
    log.error("failed")
# Later...
data = resp.content  # ← latent bug

If you’re using the requests library and handling errors around response.content, upgrade to a version with this fix or add explicit _content_consumed checks to avoid silent data loss during debugging sessions.