Repository: urllib3/urllib3

Issue:
request(..., preload_content=False).read(-1) raises an unexpected Exception
### Subject

With `preload_content=False`, calling `read(-1)` on the body raises `RuntimeError("buffer is empty")` while `read()` or `read(None)` return the whole request body as expected.

### Environment

```python
import platform
import ssl
import urllib3

print("OS", platform.platform())
print("Python", platform.python_version())
print(ssl.OPENSSL_VERSION)
print("urllib3", urllib3.__version__)
```

prints:

```
OS macOS-13.5.1-arm64-arm-64bit
Python 3.11.4
OpenSSL 3.1.1 30 May 2023
urllib3 2.0.4
```

### Steps to Reproduce

A simple and isolated way to reproduce the issue. A code snippet would be great.

Bad case:

```
import urllib3
http = urllib3.PoolManager()
http.request("GET", "https://httpbin.org/robots.txt", preload_content=False).read(-1)
```

raises: `RuntimeError: buffer is empty`.

while these work:

```
http.request("GET", "https://httpbin.org/robots.txt", preload_content=False).read()
```

or 

```
http.request("GET", "https://httpbin.org/robots.txt", preload_content=False).read(None)
```

### Expected Behavior

The whole buffer should be returned, as `-1` is the expected default argument for fileobj [according to the python stdlib](https://docs.python.org/3/library/io.html?highlight=io#io.RawIOBase).

### Actual Behavior

it raises

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/kosta/Library/Caches/pypoetry/virtualenvs/urllib3-read-IiCHxn1z-py3.11/lib/python3.11/site-packages/urllib3/response.py", line 877, in read
    return self._decoded_buffer.get(amt)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kosta/Library/Caches/pypoetry/virtualenvs/urllib3-read-IiCHxn1z-py3.11/lib/python3.11/site-packages/urllib3/response.py", line 255, in get
    raise RuntimeError("buffer is empty")
```

Note: urllib3 1.x raises:

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/kosta/Library/Caches/pypoetry/virtualenvs/uai-snaky-path-TevYsdGm-py3.9/lib/python3.9/site-packages/urllib3/response.py", line 567, in read
    data = self._fp_read(amt) if not fp_closed else b""
  File "/Users/kosta/Library/Caches/pypoetry/virtualenvs/uai-snaky-path-TevYsdGm-py3.9/lib/python3.9/site-packages/urllib3/response.py", line 533, in _fp_read
    return self._fp.read(amt) if amt is not None else self._fp.read()
  File "/opt/homebrew/Cellar/python@3.9/3.9.17_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/http/client.py", line 462, in read
    b = bytearray(amt)
ValueError: negative count
```


Discussion:
Our `HTTPResponse` object actually behaves a lot more like an `io.BufferedIOBase` instead of an `io.RawIOBase` object due to how `.read()` is implemented to always return either `amt` bytes or between 0 and `amt-1` bytes when EOF is reached. Perhaps our parent class `io.IOBase` isn't correct on `HTTPResponse`?

We actually get our behavior with `-1` from the standard library `http.client.HTTPResponse` which is what our implementation is based on. It doesn't look like `-1` would work there either?

Resolving patch:
diff --git a/src/urllib3/contrib/emscripten/response.py b/src/urllib3/contrib/emscripten/response.py
--- a/src/urllib3/contrib/emscripten/response.py
+++ b/src/urllib3/contrib/emscripten/response.py
@@ -155,7 +155,7 @@ def read(
                 self.length_is_certain = True
                 # wrap body in IOStream
                 self._response.body = BytesIO(self._response.body)
-            if amt is not None:
+            if amt is not None and amt >= 0:
                 # don't cache partial content
                 cache_content = False
                 data = self._response.body.read(amt)
diff --git a/src/urllib3/response.py b/src/urllib3/response.py
--- a/src/urllib3/response.py
+++ b/src/urllib3/response.py
@@ -935,7 +935,10 @@ def read(
         if decode_content is None:
             decode_content = self.decode_content
 
-        if amt is not None:
+        if amt and amt < 0:
+            # Negative numbers and `None` should be treated the same.
+            amt = None
+        elif amt is not None:
             cache_content = False
 
             if len(self._decoded_buffer) >= amt:
@@ -995,6 +998,9 @@ def read1(
         """
         if decode_content is None:
             decode_content = self.decode_content
+        if amt and amt < 0:
+            # Negative numbers and `None` should be treated the same.
+            amt = None
         # try and respond without going to the network
         if self._has_decoded_content:
             if not decode_content:
@@ -1189,6 +1195,11 @@ def read_chunked(
             if self._fp.fp is None:  # type: ignore[union-attr]
                 return None
 
+            if amt and amt < 0:
+                # Negative numbers and `None` should be treated the same,
+                # but httplib handles only `None` correctly.
+                amt = None
+
             while True:
                 self._update_chunk_length()
                 if self.chunk_left == 0:


Test patch:
diff --git a/test/test_response.py b/test/test_response.py
--- a/test/test_response.py
+++ b/test/test_response.py
@@ -217,6 +217,12 @@ def test_reference_read(self) -> None:
         assert r.read() == b""
         assert r.read() == b""
 
+    @pytest.mark.parametrize("read_args", ((), (None,), (-1,)))
+    def test_reference_read_until_eof(self, read_args: tuple[typing.Any, ...]) -> None:
+        fp = BytesIO(b"foo")
+        r = HTTPResponse(fp, preload_content=False)
+        assert r.read(*read_args) == b"foo"
+
     def test_reference_read1(self) -> None:
         fp = BytesIO(b"foobar")
         r = HTTPResponse(fp, preload_content=False)
@@ -227,6 +233,14 @@ def test_reference_read1(self) -> None:
         assert r.read1() == b"bar"
         assert r.read1() == b""
 
+    @pytest.mark.parametrize("read1_args", ((), (None,), (-1,)))
+    def test_reference_read1_without_limit(
+        self, read1_args: tuple[typing.Any, ...]
+    ) -> None:
+        fp = BytesIO(b"foo")
+        r = HTTPResponse(fp, preload_content=False)
+        assert r.read1(*read1_args) == b"foo"
+
     def test_reference_read1_nodecode(self) -> None:
         fp = BytesIO(b"foobar")
         r = HTTPResponse(fp, preload_content=False, decode_content=False)
@@ -1262,7 +1276,10 @@ def test_mock_transfer_encoding_chunked_custom_read(self) -> None:
         response = list(resp.read_chunked(2))
         assert expected_response == response
 
-    def test_mock_transfer_encoding_chunked_unlmtd_read(self) -> None:
+    @pytest.mark.parametrize("read_chunked_args", ((), (None,), (-1,)))
+    def test_mock_transfer_encoding_chunked_unlmtd_read(
+        self, read_chunked_args: tuple[typing.Any, ...]
+    ) -> None:
         stream = [b"foooo", b"bbbbaaaaar"]
         fp = MockChunkedEncodingResponse(stream)
         r = httplib.HTTPResponse(MockSock)  # type: ignore[arg-type]
@@ -1272,7 +1289,7 @@ def test_mock_transfer_encoding_chunked_unlmtd_read(self) -> None:
         resp = HTTPResponse(
             r, preload_content=False, headers={"transfer-encoding": "chunked"}
         )
-        assert stream == list(resp.read_chunked())
+        assert stream == list(resp.read_chunked(*read_chunked_args))
 
     def test_read_not_chunked_response_as_chunks(self) -> None:
         fp = BytesIO(b"foo")