From 1a0a555f242903680e0ad2c07046f5ee741ced5e Mon Sep 17 00:00:00 2001 From: tomasgesino Date: Fri, 17 Oct 2025 20:06:36 -0300 Subject: [PATCH] feat: warn when brotli extra missing --- httpx/_decoders.py | 7 +++++-- httpx/_models.py | 22 ++++++++++++++++++---- tests/models/test_responses.py | 16 ++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/httpx/_decoders.py b/httpx/_decoders.py index 899dfada87..4171b37a3d 100644 --- a/httpx/_decoders.py +++ b/httpx/_decoders.py @@ -26,12 +26,15 @@ brotli = None +BROTLI_INSTALLED = brotli is not None + # Zstandard support is optional try: import zstandard except ImportError: # pragma: no cover zstandard = None # type: ignore +ZSTANDARD_INSTALLED = zstandard is not None class ContentDecoder: def decode(self, data: bytes) -> bytes: @@ -387,7 +390,7 @@ def flush(self) -> list[str]: } -if brotli is None: +if not BROTLI_INSTALLED: SUPPORTED_DECODERS.pop("br") # pragma: no cover -if zstandard is None: +if not ZSTANDARD_INSTALLED: SUPPORTED_DECODERS.pop("zstd") # pragma: no cover diff --git a/httpx/_models.py b/httpx/_models.py index 2cc86321a4..4a3a05a051 100644 --- a/httpx/_models.py +++ b/httpx/_models.py @@ -7,11 +7,13 @@ import re import typing import urllib.request +import warnings from collections.abc import Mapping from http.cookiejar import Cookie, CookieJar from ._content import ByteStream, UnattachedStream, encode_request, encode_response from ._decoders import ( + BROTLI_INSTALLED, SUPPORTED_DECODERS, ByteChunker, ContentDecoder, @@ -704,13 +706,25 @@ def _get_content_decoder(self) -> ContentDecoder: if not hasattr(self, "_decoder"): decoders: list[ContentDecoder] = [] values = self.headers.get_list("content-encoding", split_commas=True) + warned_missing_brotli = False for value in values: value = value.strip().lower() - try: - decoder_cls = SUPPORTED_DECODERS[value] - decoders.append(decoder_cls()) - except KeyError: + decoder_cls = SUPPORTED_DECODERS.get(value) + if decoder_cls is None: + if ( + value == "br" + and not BROTLI_INSTALLED + and not warned_missing_brotli + ): + warned_missing_brotli = True + warnings.warn( + "Received 'Content-Encoding: br' but Brotli support is disabled. " + "Install httpx[brotli] to decode this response.", + UserWarning, + stacklevel=3, + ) continue + decoders.append(decoder_cls()) if len(decoders) == 1: self._decoder = decoders[0] diff --git a/tests/models/test_responses.py b/tests/models/test_responses.py index 06c28e1e30..ed32e1d30f 100644 --- a/tests/models/test_responses.py +++ b/tests/models/test_responses.py @@ -913,6 +913,22 @@ def test_value_error_without_request(header_value): httpx.Response(200, headers=headers, content=broken_compressed_body) +def test_warns_when_brotli_support_missing(monkeypatch): + monkeypatch.setattr(httpx._decoders, "BROTLI_INSTALLED", False, raising=False) + monkeypatch.setattr(httpx._models, "BROTLI_INSTALLED", False, raising=False) + if "br" in httpx._decoders.SUPPORTED_DECODERS: + monkeypatch.delitem(httpx._decoders.SUPPORTED_DECODERS, "br", raising=False) + + with pytest.warns(UserWarning, match="Content-Encoding: br"): + response = httpx.Response( + 200, + headers={"Content-Encoding": "br"}, + content=b"brotli-payload", + ) + + assert response.content == b"brotli-payload" + + def test_response_with_unset_request(): response = httpx.Response(200, content=b"Hello, world!")