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
| Scenario | Before (broken) | After (fixed) |
|---|---|---|
| First read fails, second access | Returns b"" silently | Raises RuntimeError |
| Normal read, single access | Works correctly | Works correctly |
| Normal read, second access | Returns cached bytes | Returns cached bytes |
| Empty response body | Returns 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
-
Error state must be persistent. If a property fails, it must store the failure so subsequent calls don’t silently succeed with garbage data.
-
Code is a state machine.
_contentand_content_consumedform 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)
-
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.