Repository: urllib3/urllib3

Issue:
Misleading `IncompleteRead` exception, if a HTTP response contains unexpected content
### Subject

When interacting with poorly implemented servers or when paying little attention during mocking of HTTP requests, one can be faced e.g. with a 204 response, which contains an unexpected body. 

So far so bad. urllib3 only raises an `IncompleteRead` exception, which provides no insight into the reason (e.g. a 204 response with body).

### Environment

* Python 3.10
* urllib3 2.1.0

### Steps to Reproduce

````shell
pip install requests responses
````

````python
# scratch.py
import unittest
import responses
import requests


class MyTest(unittest.TestCase):
    url = "http://example.com"

    def test_requestsmock(self):
        rmock = responses.RequestsMock()
        rmock.start()
        rmock.post(url=self.url,
                   status=204,
                   json={"status": "ok"},
                   auto_calculate_content_length=True)

        response = requests.post(url=self.url, data="Foo Bar!")

    @responses.activate
    def test_decorator(self):
        responses.add(method=responses.POST,
                      url=self.url,
                      status=204,
                      json={"status": "ok"},
                      auto_calculate_content_length=True)
        response = requests.post(url=self.url, data="Foo Bar!")


unittest.main()
````

### Expected Behavior

If an empty response body is expected, the raised exception should complain about the presence of an unexpected body.

Also, the reason for expecting an empty body would be helpful and speed up debugging.

### Actual Behavior

Instead, `IncompleteRead(16 bytes read, -16 more expected)` is raised:

```
python3.10 scratch.py
EE
======================================================================
ERROR: test_decorator (__main__.MyTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 712, in _error_catcher
    yield
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 833, in _raw_read
    raise IncompleteRead(self._fp_bytes_read, self.length_remaining)
urllib3.exceptions.IncompleteRead: IncompleteRead(16 bytes read, -16 more expected)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/models.py", line 816, in generate
    yield from self.raw.stream(chunk_size, decode_content=True)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 934, in stream
    data = self.read(amt=amt, decode_content=decode_content)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 905, in read
    data = self._raw_read(amt)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 811, in _raw_read
    with self._error_catcher():
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 729, in _error_catcher
    raise ProtocolError(f"Connection broken: {e!r}", e) from e
urllib3.exceptions.ProtocolError: ('Connection broken: IncompleteRead(16 bytes read, -16 more expected)', IncompleteRead(16 bytes read, -16 more expected))

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/responses/__init__.py", line 232, in wrapper
    return func(*args, **kwargs)
  File "/home/andi/.config/JetBrains/PyCharm2023.2/scratches/scratch.py", line 28, in test_decorator
    response = requests.post(url=self.url, data="Foo Bar!")
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/api.py", line 115, in post
    return request("post", url, data=data, json=json, **kwargs)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/api.py", line 59, in request
    return session.request(method=method, url=url, **kwargs)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/sessions.py", line 747, in send
    r.content
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/models.py", line 899, in content
    self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/models.py", line 818, in generate
    raise ChunkedEncodingError(e)
requests.exceptions.ChunkedEncodingError: ('Connection broken: IncompleteRead(16 bytes read, -16 more expected)', IncompleteRead(16 bytes read, -16 more expected))

======================================================================
ERROR: test_requestsmock (__main__.MyTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 712, in _error_catcher
    yield
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 833, in _raw_read
    raise IncompleteRead(self._fp_bytes_read, self.length_remaining)
urllib3.exceptions.IncompleteRead: IncompleteRead(16 bytes read, -16 more expected)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/models.py", line 816, in generate
    yield from self.raw.stream(chunk_size, decode_content=True)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 934, in stream
    data = self.read(amt=amt, decode_content=decode_content)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 905, in read
    data = self._raw_read(amt)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 811, in _raw_read
    with self._error_catcher():
  File "/usr/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/urllib3/response.py", line 729, in _error_catcher
    raise ProtocolError(f"Connection broken: {e!r}", e) from e
urllib3.exceptions.ProtocolError: ('Connection broken: IncompleteRead(16 bytes read, -16 more expected)', IncompleteRead(16 bytes read, -16 more expected))

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/andi/.config/JetBrains/PyCharm2023.2/scratches/scratch.py", line 18, in test_requestsmock
    response = requests.post(url=self.url, data="Foo Bar!")
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/api.py", line 115, in post
    return request("post", url, data=data, json=json, **kwargs)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/api.py", line 59, in request
    return session.request(method=method, url=url, **kwargs)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/sessions.py", line 747, in send
    r.content
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/models.py", line 899, in content
    self._content = b"".join(self.iter_content(CONTENT_CHUNK_SIZE)) or b""
  File "/home/andi/virtualenvs/checkers/lib/python3.10/site-packages/requests/models.py", line 818, in generate
    raise ChunkedEncodingError(e)
requests.exceptions.ChunkedEncodingError: ('Connection broken: IncompleteRead(16 bytes read, -16 more expected)', IncompleteRead(16 bytes read, -16 more expected))

----------------------------------------------------------------------
Ran 2 tests in 0.005s

FAILED (errors=2)
```




Discussion:
@crazyscientist Thanks for reporting this. I would accept a fix that detects this situation and reraises as a `ProtocolError` with a better error message.



Patch:
diff --git a/changelog/3261.misc.rst b/changelog/3261.misc.rst
new file mode 100644
index 0000000000..5e44888bc9
--- /dev/null
+++ b/changelog/3261.misc.rst
@@ -0,0 +1,1 @@
+Made raised ``urllib3.exceptions.ProtocolError`` more verbose when a response contains content unexpectedly.
diff --git a/src/urllib3/exceptions.py b/src/urllib3/exceptions.py
index 5bb9236961..b0792f00fd 100644
--- a/src/urllib3/exceptions.py
+++ b/src/urllib3/exceptions.py
@@ -252,13 +252,16 @@ class IncompleteRead(HTTPError, httplib_IncompleteRead):
     for ``partial`` to avoid creating large objects on streamed reads.
     """
 
+    partial: int  # type: ignore[assignment]
+    expected: int
+
     def __init__(self, partial: int, expected: int) -> None:
-        self.partial = partial  # type: ignore[assignment]
+        self.partial = partial
         self.expected = expected
 
     def __repr__(self) -> str:
         return "IncompleteRead(%i bytes read, %i more expected)" % (
-            self.partial,  # type: ignore[str-format]
+            self.partial,
             self.expected,
         )
 
diff --git a/src/urllib3/response.py b/src/urllib3/response.py
index 41dc9575b8..d31fac9ba0 100644
--- a/src/urllib3/response.py
+++ b/src/urllib3/response.py
@@ -749,8 +749,18 @@ def _error_catcher(self) -> typing.Generator[None, None, None]:
 
                 raise ReadTimeoutError(self._pool, None, "Read timed out.") from e  # type: ignore[arg-type]
 
+            except IncompleteRead as e:
+                if (
+                    e.expected is not None
+                    and e.partial is not None
+                    and e.expected == -e.partial
+                ):
+                    arg = "Response may not contain content."
+                else:
+                    arg = f"Connection broken: {e!r}"
+                raise ProtocolError(arg, e) from e
+
             except (HTTPException, OSError) as e:
-                # This includes IncompleteRead.
                 raise ProtocolError(f"Connection broken: {e!r}", e) from e
 
             # If no exception is thrown, we should avoid cleaning up


Test patch:
diff --git a/test/test_response.py b/test/test_response.py
index 414cde0e81..6a3c50e147 100644
--- a/test/test_response.py
+++ b/test/test_response.py
@@ -1293,7 +1293,7 @@ def test_buggy_incomplete_read(self) -> None:
 
         orig_ex = ctx.value.args[1]
         assert isinstance(orig_ex, IncompleteRead)
-        assert orig_ex.partial == 0  # type: ignore[comparison-overlap]
+        assert orig_ex.partial == 0
         assert orig_ex.expected == content_length
 
     def test_incomplete_chunk(self) -> None:
@@ -1504,6 +1504,27 @@ def make_bad_mac_fp() -> typing.Generator[BytesIO, None, None]:
                 resp.read()
             assert e.value.args[0] == mac_error
 
+    def test_unexpected_body(self) -> None:
+        with pytest.raises(ProtocolError) as excinfo:
+            fp = BytesIO(b"12345")
+            headers = {"content-length": "5"}
+            resp = HTTPResponse(fp, status=204, headers=headers)
+            resp.read(16)
+        assert "Response may not contain content" in str(excinfo.value)
+
+        with pytest.raises(ProtocolError):
+            fp = BytesIO(b"12345")
+            headers = {"content-length": "0"}
+            resp = HTTPResponse(fp, status=204, headers=headers)
+            resp.read(16)
+        assert "Response may not contain content" in str(excinfo.value)
+
+        with pytest.raises(ProtocolError):
+            fp = BytesIO(b"12345")
+            resp = HTTPResponse(fp, status=204)
+            resp.read(16)
+        assert "Response may not contain content" in str(excinfo.value)
+
 
 class MockChunkedEncodingResponse:
     def __init__(self, content: list[bytes]) -> None: