Repository: encode/httpx

Issue:
Ensure that `iter_bytes` does not ever yield any zero-length chunks.
Prompted by https://github.com/encode/httpx/discussions/1733

It appears that `iter_bytes` can currently sometimes return a zero-length chunk. This isn't strictly a broken behaviour, since we don't make any assertions about if that might occur or not, and it's still *correct*, but it'd be preferable from a UX perspective if it *didn't* even do this.

<div type='discussions-op-text'>

<sup>Originally posted by **michalc** July  4, 2021</sup>
The last chunk in the below is of length zero:

```python
with httpx.stream('GET', 'https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-4.4-essentials_build.zip') as r:
    for chunk in r.iter_bytes():
        print(len(chunk))
```
is this expected?

It also looks like `iter_raw` _doesn't_ have a zero length chunk at the end:
```python
with httpx.stream('GET', 'https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-4.4-essentials_build.zip') as r:
    for chunk in r.iter_raw():
        print(len(chunk))
```
since in this case there is no content-encoding, I guess I expect that `iter_bytes == iter_raw`

----

Having a bit of a nose around the code, suspecting it's from https://github.com/encode/httpx/blob/47d712c01cdde6574b2a94e43a94e5ae6e4f2faf/httpx/_models.py#L1483 where `decoder.flush()` returns an empty `bytes` instance, this is passed to `chunker.decode`, and since `chunk_size` is `None`, a list containing this empty `bytes` instance is returned https://github.com/encode/httpx/blob/47d712c01cdde6574b2a94e43a94e5ae6e4f2faf/httpx/_decoders.py#L172

and then each element of this list is yielded to the calling code

----

This is related to https://github.com/uktrade/stream-unzip/issues/13, where the code in stream-unzip does not expect any zero length chunks. Potentially stream-unzip should be robust against this case... but wasn't sure if this was desirable in httpx, so mentioning it here in case it is an issue to be addressed.</div>


Discussion:

Resolving patch:
diff --git a/httpx/_decoders.py b/httpx/_decoders.py
--- a/httpx/_decoders.py
+++ b/httpx/_decoders.py
@@ -172,7 +172,7 @@ def __init__(self, chunk_size: int = None) -> None:
 
     def decode(self, content: bytes) -> typing.List[bytes]:
         if self._chunk_size is None:
-            return [content]
+            return [content] if content else []
 
         self._buffer.write(content)
         if self._buffer.tell() >= self._chunk_size:
diff --git a/httpx/_models.py b/httpx/_models.py
--- a/httpx/_models.py
+++ b/httpx/_models.py
@@ -1585,7 +1585,7 @@ def iter_bytes(self, chunk_size: int = None) -> typing.Iterator[bytes]:
                         yield chunk
                 decoded = decoder.flush()
                 for chunk in chunker.decode(decoded):
-                    yield chunk
+                    yield chunk  # pragma: nocover
                 for chunk in chunker.flush():
                     yield chunk
 
@@ -1683,7 +1683,7 @@ async def aiter_bytes(self, chunk_size: int = None) -> typing.AsyncIterator[byte
                         yield chunk
                 decoded = decoder.flush()
                 for chunk in chunker.decode(decoded):
-                    yield chunk
+                    yield chunk  # pragma: nocover
                 for chunk in chunker.flush():
                     yield chunk
 


Test patch:
diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py
--- a/tests/models/test_responses.py
+++ b/tests/models/test_responses.py
@@ -396,6 +396,19 @@ def test_iter_raw_with_chunksize():
     assert parts == [b"Hello, world!"]
 
 
+def test_iter_raw_doesnt_return_empty_chunks():
+    def streaming_body_with_empty_chunks():
+        yield b"Hello, "
+        yield b""
+        yield b"world!"
+        yield b""
+
+    response = httpx.Response(200, content=streaming_body_with_empty_chunks())
+
+    parts = [part for part in response.iter_raw()]
+    assert parts == [b"Hello, ", b"world!"]
+
+
 def test_iter_raw_on_iterable():
     response = httpx.Response(
         200,
@@ -526,6 +539,19 @@ def test_iter_bytes_with_empty_response():
     assert parts == []
 
 
+def test_iter_bytes_doesnt_return_empty_chunks():
+    def streaming_body_with_empty_chunks():
+        yield b"Hello, "
+        yield b""
+        yield b"world!"
+        yield b""
+
+    response = httpx.Response(200, content=streaming_body_with_empty_chunks())
+
+    parts = [part for part in response.iter_bytes()]
+    assert parts == [b"Hello, ", b"world!"]
+
+
 @pytest.mark.asyncio
 async def test_aiter_bytes():
     response = httpx.Response(