tomchristie: On reviewing this, I'm not sure my "More generally: we should make sure not to call any decoder if the body is empty." comment is the best - approach.

Your PR here fixes one case, but we'd also need to introduce the same for the streaming case.

It's probably more pragmatic for us to make sure that the brotli decoder implementation (and any others) all correctly deal with `""` inputs, and all correctly return `""` for the output in that case.
sethmlarson: Totally agree with Tom here. Decoders shouldn't explode on empty input. 
cansarigol: I added a [commit](https://github.com/encode/httpx/pull/237/commits/8f73ce73802e916526e8fc90d213636d17b07f12) Am I continuing with seen_data?
tomchristie: > Am I continuing with seen_data?

Please, yup.

The `httpx/models.py` file should not change as part of this PR, and the signature of `def decode(self, data: bytes)` should stay the same too.

diff --git a/docs/advanced.md b/docs/advanced.md
index 5ecf5a6..424d397 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -42,6 +42,21 @@ assert r.status_code == 200
 assert r.text == "Hello World!"
 ```
 
+For some more complex cases you might need to customize the WSGI or ASGI
+dispatch. This allows you to:
+
+* Inspect 500 error responses, rather than raise exceptions, by setting `raise_app_exceptions=False`.
+* Mount the WSGI or ASGI application at a subpath, by setting `script_name` (WSGI) or `root_path` (ASGI).
+* Use a given the client address for requests, by setting `remote_addr` (WSGI) or `client` (ASGI).
+
+For example:
+
+```python
+# Instantiate a client that makes WSGI requests with a client IP of "1.2.3.4".
+dispatch = httpx.WSGIDispatch(app=app, remote_addr="1.2.3.4")
+client = httpx.Client(dispatch=dispatch)
+```
+
 ## .netrc Support
 
 HTTPX supports .netrc file. In `trust_env=True` cases, if auth parameter is
diff --git a/httpx/client.py b/httpx/client.py
index 40bf0f1..fd86fb0 100644
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -63,7 +63,6 @@ class BaseClient:
         base_url: URLTypes = None,
         dispatch: typing.Union[AsyncDispatcher, Dispatcher] = None,
         app: typing.Callable = None,
-        raise_app_exceptions: bool = True,
         backend: ConcurrencyBackend = None,
         trust_env: bool = None,
     ):
@@ -76,13 +75,9 @@ class BaseClient:
             param_count = len(inspect.signature(app).parameters)
             assert param_count in (2, 3)
             if param_count == 2:
-                dispatch = WSGIDispatch(
-                    app=app, raise_app_exceptions=raise_app_exceptions
-                )
+                dispatch = WSGIDispatch(app=app)
             else:
-                dispatch = ASGIDispatch(
-                    app=app, raise_app_exceptions=raise_app_exceptions
-                )
+                dispatch = ASGIDispatch(app=app)
 
         if dispatch is None:
             async_dispatch: AsyncDispatcher = ConnectionPool(
diff --git a/httpx/decoders.py b/httpx/decoders.py
index bfa9654..3ae822b 100644
--- a/httpx/decoders.py
+++ b/httpx/decoders.py
@@ -98,8 +98,12 @@ class BrotliDecoder(Decoder):
             brotli is not None
         ), "The 'brotlipy' or 'brotli' library must be installed to use 'BrotliDecoder'"
         self.decompressor = brotli.Decompressor()
+        self.seen_data = False
 
     def decode(self, data: bytes) -> bytes:
+        if not data:
+            return b""
+        self.seen_data = True
         try:
             if hasattr(self.decompressor, "decompress"):
                 return self.decompressor.decompress(data)
@@ -108,6 +112,8 @@ class BrotliDecoder(Decoder):
             raise DecodingError from exc
 
     def flush(self) -> bytes:
+        if not self.seen_data:
+            return b""
         try:
             if hasattr(self.decompressor, "finish"):
                 self.decompressor.finish()
diff --git a/httpx/dispatch/asgi.py b/httpx/dispatch/asgi.py
index 8164b18..23eebb0 100644
--- a/httpx/dispatch/asgi.py
+++ b/httpx/dispatch/asgi.py
@@ -29,6 +29,15 @@ class ASGIDispatch(AsyncDispatcher):
         client=("1.2.3.4", 123)
     )
     client = httpx.Client(dispatch=dispatch)
+
+    Arguments:
+
+    * `app` - The ASGI application.
+    * `raise_app_exceptions` - Boolean indicating if exceptions in the application
+       should be raised. Default to `True`. Can be set to `False` for use cases
+       such as testing the content of a client 500 response.
+    * `root_path` - The root path on which the ASGI application should be mounted.
+    * `client` - A two-tuple indicating the client IP and port of incoming requests.
     ```
     """
 
diff --git a/httpx/dispatch/wsgi.py b/httpx/dispatch/wsgi.py
index 017eae1..60e0a18 100644
--- a/httpx/dispatch/wsgi.py
+++ b/httpx/dispatch/wsgi.py
@@ -29,6 +29,16 @@ class WSGIDispatch(Dispatcher):
         remote_addr="1.2.3.4"
     )
     client = httpx.Client(dispatch=dispatch)
+
+
+    Arguments:
+
+    * `app` - The ASGI application.
+    * `raise_app_exceptions` - Boolean indicating if exceptions in the application
+       should be raised. Default to `True`. Can be set to `False` for use cases
+       such as testing the content of a client 500 response.
+    * `script_name` - The root path on which the ASGI application should be mounted.
+    * `remote_addr` - A string indicating the client IP of incoming requests.
     ```
     """
 

Handle HEAD responses with Brotli decoder
Currently if you receive a response with `Content-Encoding: br` set and no body we get an error because Brotli doesn't like being called on an empty stream.
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
index 036a416..1b5d6e1 100644
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -4,7 +4,13 @@ import brotli
 import pytest
 
 import httpx
-from httpx.decoders import TextDecoder
+from httpx.decoders import (
+    BrotliDecoder,
+    DeflateDecoder,
+    GZipDecoder,
+    IdentityDecoder,
+    TextDecoder,
+)
 
 
 def test_deflate():
@@ -79,6 +85,22 @@ def test_streaming():
     assert response.read() == body
 
 
+@pytest.mark.parametrize("header_value", (b"deflate", b"gzip", b"br", b"identity"))
+def test_empty_content(header_value):
+    headers = [(b"Content-Encoding", header_value)]
+    response = httpx.Response(200, headers=headers, content=b"")
+    assert response.content == b""
+
+
+@pytest.mark.parametrize(
+    "decoder", (BrotliDecoder, DeflateDecoder, GZipDecoder, IdentityDecoder)
+)
+def test_decoders_empty_cases(decoder):
+    instance = decoder()
+    assert instance.decode(b"") == b""
+    assert instance.flush() == b""
+
+
 @pytest.mark.parametrize("header_value", (b"deflate", b"gzip", b"br"))
 def test_decoding_errors(header_value):
     headers = [(b"Content-Encoding", header_value)]

pip install -e .
parse_log_pytest
requirements.txt
pytest
3.9
test-requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==25.3.0
brotlipy==0.7.0
certifi==2025.1.31
cffi==1.17.1
chardet==3.0.4
click==8.1.8
coverage==7.8.0
cryptography==44.0.2
exceptiongroup==1.2.2
flake8==7.2.0
flake8-bugbear==24.12.12
flake8-comprehensions==3.16.0
h11==0.8.1
h2==3.2.0
hpack==3.0.0
hstspreload==2025.1.1
-e git+https://github.com/encode/httpx.git@a960a2a8fd273960993551f88617f22bfeb85035#egg=httpx
hyperframe==5.2.0
idna==2.10
iniconfig==2.1.0
isort==6.0.1
mccabe==0.7.0
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
pluggy==1.5.0
pycodestyle==2.13.0
pycparser==2.22
pyflakes==3.3.2
pytest==8.3.5
pytest-asyncio==0.26.0
pytest-cov==6.0.0
rfc3986==1.5.0
tomli==2.2.1
trustme==1.2.1
typing_extensions==4.13.0
uvicorn==0.34.0

name: httpx
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==25.3.0
      - brotlipy==0.7.0
      - certifi==2025.1.31
      - cffi==1.17.1
      - chardet==3.0.4
      - click==8.1.8
      - coverage==7.8.0
      - cryptography==44.0.2
      - exceptiongroup==1.2.2
      - flake8==7.2.0
      - flake8-bugbear==24.12.12
      - flake8-comprehensions==3.16.0
      - h11==0.8.1
      - h2==3.2.0
      - hpack==3.0.0
      - hstspreload==2025.1.1
      - hyperframe==5.2.0
      - idna==2.10
      - iniconfig==2.1.0
      - isort==6.0.1
      - mccabe==0.7.0
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pycparser==2.22
      - pyflakes==3.3.2
      - pytest==8.3.5
      - pytest-asyncio==0.26.0
      - pytest-cov==6.0.0
      - rfc3986==1.5.0
      - tomli==2.2.1
      - trustme==1.2.1
      - typing-extensions==4.13.0
      - uvicorn==0.34.0
prefix: /opt/conda/envs/httpx

tests/test_decoders.py::test_empty_content[br] tests/test_decoders.py::test_decoders_empty_cases[BrotliDecoder]
tests/test_decoders.py::test_deflate tests/test_decoders.py::test_gzip tests/test_decoders.py::test_brotli tests/test_decoders.py::test_multi tests/test_decoders.py::test_multi_with_identity tests/test_decoders.py::test_streaming tests/test_decoders.py::test_empty_content[deflate] tests/test_decoders.py::test_empty_content[gzip] tests/test_decoders.py::test_empty_content[identity] tests/test_decoders.py::test_decoders_empty_cases[DeflateDecoder] tests/test_decoders.py::test_decoders_empty_cases[GZipDecoder] tests/test_decoders.py::test_decoders_empty_cases[IdentityDecoder] tests/test_decoders.py::test_decoding_errors[deflate] tests/test_decoders.py::test_decoding_errors[gzip] tests/test_decoders.py::test_decoding_errors[br] tests/test_decoders.py::test_text_decoder[data0-ascii] tests/test_decoders.py::test_text_decoder[data1-utf-8] tests/test_decoders.py::test_text_decoder[data2-shift-jis] tests/test_decoders.py::test_text_decoder[data3-shift-jis] tests/test_decoders.py::test_text_decoder[data4-MacCyrillic] tests/test_decoders.py::test_text_decoder[data5-euc-jp] tests/test_decoders.py::test_text_decoder_known_encoding tests/test_decoders.py::test_text_decoder_empty_cases tests/test_decoders.py::test_invalid_content_encoding_header
httpx/dispatch/wsgi.py httpx/client.py httpx/dispatch/asgi.py docs/advanced.md httpx/decoders.py
httpx/dispatch/wsgi.py httpx/client.py httpx/dispatch/asgi.py docs/advanced.md httpx/decoders.py