Repository: encode/httpx

Issue:
Zstandard data is incomplete on HEAD requests
Tried to do a HEAD request with a server answering with `zstd` encoding by default but since the content is empty `ZStandardDecoder` is raising `DecodingError("Zstandard data is incomplete")` when calling `flush()`.

Tried to fix the problem by adding a check if `ret` is empty:
```python

    def flush(self) -> bytes:
        ret = self.decompressor.flush()  # note: this is a no-op
        # If content is empty, no need to wait for EOF
        if not self.decompressor.eof and len(ret) > 0:
            raise DecodingError("Zstandard data is incomplete")  # pragma: no cover
        return bytes(ret)
```

It is working for me but I don't know if there is other implications with this modification.

URLs to test: https://password-hashing.net/ or https://3proxy.org/

---

- [X] Initially raised as discussion https://github.com/encode/httpx/discussions/3408

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c16d66..4e2afe2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
-## [Unreleased]
+## 0.28.0 (...)
 
 The 0.28 release includes a limited set of backwards incompatible changes.
 
diff --git a/README.md b/README.md
index d5d2148..23992d9 100644
--- a/README.md
+++ b/README.md
@@ -13,9 +13,7 @@
 </a>
 </p>
 
-HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated
-command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync
-and async APIs**.
+HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**.
 
 ---
 
diff --git a/httpx/__version__.py b/httpx/__version__.py
index 5eaaddb..0a684ac 100644
--- a/httpx/__version__.py
+++ b/httpx/__version__.py
@@ -1,3 +1,3 @@
 __title__ = "httpx"
 __description__ = "A next generation HTTP client, for Python 3."
-__version__ = "0.27.2"
+__version__ = "0.28.0"
diff --git a/httpx/_decoders.py b/httpx/_decoders.py
index 180898c..899dfad 100644
--- a/httpx/_decoders.py
+++ b/httpx/_decoders.py
@@ -175,9 +175,11 @@ class ZStandardDecoder(ContentDecoder):
             ) from None
 
         self.decompressor = zstandard.ZstdDecompressor().decompressobj()
+        self.seen_data = False
 
     def decode(self, data: bytes) -> bytes:
         assert zstandard is not None
+        self.seen_data = True
         output = io.BytesIO()
         try:
             output.write(self.decompressor.decompress(data))
@@ -190,6 +192,8 @@ class ZStandardDecoder(ContentDecoder):
         return output.getvalue()
 
     def flush(self) -> bytes:
+        if not self.seen_data:
+            return b""
         ret = self.decompressor.flush()  # note: this is a no-op
         if not self.decompressor.eof:
             raise DecodingError("Zstandard data is incomplete")  # pragma: no cover


Test patch:
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
index bcbb18b..9ffaba1 100644
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -100,6 +100,25 @@ def test_zstd_decoding_error():
         )
 
 
+def test_zstd_empty():
+    headers = [(b"Content-Encoding", b"zstd")]
+    response = httpx.Response(200, headers=headers, content=b"")
+    assert response.content == b""
+
+
+def test_zstd_truncated():
+    body = b"test 123"
+    compressed_body = zstd.compress(body)
+
+    headers = [(b"Content-Encoding", b"zstd")]
+    with pytest.raises(httpx.DecodingError):
+        httpx.Response(
+            200,
+            headers=headers,
+            content=compressed_body[1:3],
+        )
+
+
 def test_zstd_multiframe():
     # test inspired by urllib3 test suite
     data = (