From 217e97edd3ca6fd21fabf2d3d32fb29dc2fc062f Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:47:24 +0900 Subject: [PATCH 1/5] Reject unknown Connect stream compression with unimplemented A Connect streaming request with an unsupported connect-content-encoding was treated as identity: a compressed message then failed with internal ("sent compressed message without compression support"), and an uncompressed one went through. The spec asks servers to handle this header like content-encoding and answer unimplemented with the supported encodings, as connect-go does and as connect-py already does for Connect unary and gRPC. An empty header still means identity, as in connect-go. The stream path now reuses the unary error message, so gRPC and gRPC-Web errors list the supported encodings too. Co-Authored-By: Claude Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- src/connectrpc/_protocol_connect.py | 11 ++-- src/connectrpc/_server_async.py | 4 +- src/connectrpc/_server_sync.py | 4 +- test/test_compression.py | 96 ++++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/src/connectrpc/_protocol_connect.py b/src/connectrpc/_protocol_connect.py index bc25bc30..f06d3259 100644 --- a/src/connectrpc/_protocol_connect.py +++ b/src/connectrpc/_protocol_connect.py @@ -143,13 +143,12 @@ def codec_name_from_content_type(self, content_type: str, *, stream: bool) -> st def negotiate_stream_compression( self, headers: Headers, compressions: dict[str, Compression] - ) -> tuple[Compression, Compression]: - req_compression_name = headers.get( - CONNECT_STREAMING_HEADER_COMPRESSION, "identity" - ) - req_compression = ( - compressions.get(req_compression_name) or IdentityCompression() + ) -> tuple[Compression | None, Compression]: + # An empty header means identity too, as in connect-go. + req_compression_name = ( + headers.get(CONNECT_STREAMING_HEADER_COMPRESSION) or "identity" ) + req_compression = compressions.get(req_compression_name) accept_compression = headers.get( CONNECT_STREAMING_HEADER_ACCEPT_COMPRESSION, "" ) diff --git a/src/connectrpc/_server_async.py b/src/connectrpc/_server_async.py index 4301ef24..906c86de 100644 --- a/src/connectrpc/_server_async.py +++ b/src/connectrpc/_server_async.py @@ -410,8 +410,10 @@ async def _handle_stream( try: await metadata_run.start() if not req_compression: + compression_name = headers.get(protocol.compression_header_name()) raise ConnectError( - Code.UNIMPLEMENTED, "Unrecognized request compression" + Code.UNIMPLEMENTED, + f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", ) request_stream = _request_stream( receive, diff --git a/src/connectrpc/_server_sync.py b/src/connectrpc/_server_sync.py index c3cd309f..506369a9 100644 --- a/src/connectrpc/_server_sync.py +++ b/src/connectrpc/_server_sync.py @@ -528,8 +528,10 @@ def _handle_stream( try: metadata_run.start() if not req_compression: + compression_name = headers.get(protocol.compression_header_name()) raise ConnectError( - Code.UNIMPLEMENTED, "Unrecognized request compression" + Code.UNIMPLEMENTED, + f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", ) request_stream = _request_stream( request_body, diff --git a/test/test_compression.py b/test/test_compression.py index eab5bccf..8912a3ee 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -7,13 +7,16 @@ from pyqwest import Client, SyncClient from pyqwest.testing import ASGITransport, WSGITransport -from connectrpc._compression import IdentityCompression +from connectrpc._compression import IdentityCompression, resolve_compressions +from connectrpc._protocol_connect import ConnectServerProtocol from connectrpc.client import ResponseMetadata from connectrpc.code import Code from connectrpc.compression.brotli import BrotliCompression from connectrpc.compression.gzip import GzipCompression from connectrpc.compression.zstd import ZstdCompression from connectrpc.errors import ConnectError +from connectrpc.protocol import ProtocolType +from connectrpc.request import Headers from ._util import resolve_compression from .connectrpc.example.haberdasher_connect import ( @@ -99,6 +102,97 @@ def make_hat(self, _request, _ctx): assert meta.headers.get("content-encoding") == encoding +_protocols = [ProtocolType.CONNECT, ProtocolType.GRPC, ProtocolType.GRPC_WEB] +_streams = [pytest.param(False, id="unary"), pytest.param(True, id="stream")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", _protocols) +@pytest.mark.parametrize("stream", _streams) +async def test_unknown_request_compression_async( + protocol: ProtocolType, stream: bool +) -> None: + class SimpleHaberdasher(Haberdasher): + async def make_hat(self, _request, _ctx): + return Hat(size=10, color="blue") + + async def make_similar_hats(self, _request, _ctx): + yield Hat(size=10, color="blue") + + # The server only supports the default gzip. + app = HaberdasherASGIApplication(SimpleHaberdasher()) + client = HaberdasherClient( + "http://localhost", + protocol=protocol, + http_client=Client(ASGITransport(app)), + send_compression=ZstdCompression(), + ) + with pytest.raises(ConnectError) as exc_info: + if stream: + async for _ in client.make_similar_hats(Size(inches=10)): + pass + else: + await client.make_hat(Size(inches=10)) + assert exc_info.value.code == Code.UNIMPLEMENTED + assert ( + exc_info.value.message + == "unknown compression: 'zstd': supported encodings are gzip, identity" + ) + + +@pytest.mark.parametrize("protocol", _protocols) +@pytest.mark.parametrize("stream", _streams) +def test_unknown_request_compression_sync(protocol: ProtocolType, stream: bool) -> None: + class SimpleHaberdasher(HaberdasherSync): + def make_hat(self, _request, _ctx): + return Hat(size=10, color="blue") + + def make_similar_hats(self, _request, _ctx): + yield Hat(size=10, color="blue") + + # The server only supports the default gzip. + app = HaberdasherWSGIApplication(SimpleHaberdasher()) + client = HaberdasherClientSync( + "http://localhost", + protocol=protocol, + http_client=SyncClient(WSGITransport(app)), + send_compression=ZstdCompression(), + ) + with pytest.raises(ConnectError) as exc_info: + if stream: + for _ in client.make_similar_hats(Size(inches=10)): + pass + else: + client.make_hat(Size(inches=10)) + assert exc_info.value.code == Code.UNIMPLEMENTED + assert ( + exc_info.value.message + == "unknown compression: 'zstd': supported encodings are gzip, identity" + ) + + +@pytest.mark.parametrize( + ("header", "expected"), + [ + pytest.param(None, "identity", id="absent"), + pytest.param("", "identity", id="empty"), + pytest.param("identity", "identity", id="identity"), + pytest.param("gzip", "gzip", id="gzip"), + pytest.param("zstd", None, id="unknown"), + ], +) +def test_connect_stream_request_compression( + header: str | None, expected: str | None +) -> None: + headers = Headers() + if header is not None: + headers["connect-content-encoding"] = header + compression, _ = ConnectServerProtocol().negotiate_stream_compression( + headers, resolve_compressions(None) + ) + assert (compression.name() if compression else None) == expected + + class TestIdentityCompression: def test_name(self): assert IdentityCompression().name() == "identity" From cc5f6739ccaf365f70d70b2b33379422e8d077d5 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:19:52 +0900 Subject: [PATCH 2/5] Treat an empty request compression as identity An empty content-encoding, grpc-encoding, or GET compression parameter was rejected with unimplemented on every path except Connect streams. Treat it as identity everywhere, as connect-go does, so all paths handle request compression the same way. Co-Authored-By: Claude Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- src/connectrpc/_protocol_grpc.py | 2 +- src/connectrpc/_server_async.py | 4 +- src/connectrpc/_server_sync.py | 9 ++-- test/test_compression.py | 79 ++++++++++++++++++++++++++++++-- 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/connectrpc/_protocol_grpc.py b/src/connectrpc/_protocol_grpc.py index 953dcf55..72baee69 100644 --- a/src/connectrpc/_protocol_grpc.py +++ b/src/connectrpc/_protocol_grpc.py @@ -96,7 +96,7 @@ def codec_name_from_content_type(self, content_type: str, *, stream: bool) -> st def negotiate_stream_compression( self, headers: Headers, compressions: dict[str, Compression] ) -> tuple[Compression | None, Compression]: - req_compression_name = headers.get(GRPC_HEADER_COMPRESSION, "identity") + req_compression_name = headers.get(GRPC_HEADER_COMPRESSION) or "identity" req_compression = compressions.get(req_compression_name) accept_compression = headers.get(GRPC_HEADER_ACCEPT_COMPRESSION, "") resp_compression = negotiate_compression(accept_compression, compressions) diff --git a/src/connectrpc/_server_async.py b/src/connectrpc/_server_async.py index 906c86de..1079b3d6 100644 --- a/src/connectrpc/_server_async.py +++ b/src/connectrpc/_server_async.py @@ -338,7 +338,7 @@ async def _read_get_request( message = message.encode("utf-8") # Handle compression - compression_name = params.get("compression", ["identity"])[0] + compression_name = params.get("compression", [""])[0] or "identity" compression = self._compressions.get(compression_name) if not compression: raise ConnectError( @@ -375,7 +375,7 @@ async def _read_post_request( req_body = b"".join(chunks) # Handle compression if specified - compression_name = headers.get("content-encoding", "identity").lower() + compression_name = (headers.get("content-encoding") or "identity").lower() compression = self._compressions.get(compression_name) if not compression: raise ConnectError( diff --git a/src/connectrpc/_server_sync.py b/src/connectrpc/_server_sync.py index 506369a9..60599062 100644 --- a/src/connectrpc/_server_sync.py +++ b/src/connectrpc/_server_sync.py @@ -402,7 +402,9 @@ def _handle_post_request( req_body = b"".join(chunks) # Handle compression if specified - compression_name = environ.get("HTTP_CONTENT_ENCODING", "identity").lower() + compression_name = ( + environ.get("HTTP_CONTENT_ENCODING") or "identity" + ).lower() compression = self._compressions.get(compression_name) if not compression: raise ConnectError( @@ -460,10 +462,7 @@ def _handle_get_request( message = message.encode("utf-8") # Handle compression if specified - if "compression" in params: - compression_name = params["compression"][0] - else: - compression_name = "identity" + compression_name = params.get("compression", [""])[0] or "identity" compression = self._compressions.get(compression_name) if not compression: raise ConnectError( diff --git a/test/test_compression.py b/test/test_compression.py index 8912a3ee..883fe144 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from urllib.parse import urlencode import brotli as brotli_lib import pytest @@ -9,6 +10,7 @@ from connectrpc._compression import IdentityCompression, resolve_compressions from connectrpc._protocol_connect import ConnectServerProtocol +from connectrpc._protocol_grpc import GRPCServerProtocol from connectrpc.client import ResponseMetadata from connectrpc.code import Code from connectrpc.compression.brotli import BrotliCompression @@ -30,6 +32,7 @@ from .connectrpc.example.haberdasher_pb import Hat, Size if TYPE_CHECKING: + from connectrpc._protocol import ServerProtocol from connectrpc.compression import Compression @@ -171,6 +174,13 @@ def make_similar_hats(self, _request, _ctx): ) +@pytest.mark.parametrize( + ("protocol", "header_name"), + [ + pytest.param(ConnectServerProtocol(), "connect-content-encoding", id="connect"), + pytest.param(GRPCServerProtocol(), "grpc-encoding", id="grpc"), + ], +) @pytest.mark.parametrize( ("header", "expected"), [ @@ -181,18 +191,79 @@ def make_similar_hats(self, _request, _ctx): pytest.param("zstd", None, id="unknown"), ], ) -def test_connect_stream_request_compression( - header: str | None, expected: str | None +def test_stream_request_compression( + protocol: ServerProtocol, header_name: str, header: str | None, expected: str | None ) -> None: headers = Headers() if header is not None: - headers["connect-content-encoding"] = header - compression, _ = ConnectServerProtocol().negotiate_stream_compression( + headers[header_name] = header + compression, _ = protocol.negotiate_stream_compression( headers, resolve_compressions(None) ) assert (compression.name() if compression else None) == expected +# An empty compression means identity, as in connect-go. +_empty_compression_requests = [ + pytest.param( + "POST", + "", + {"content-type": "application/json", "content-encoding": ""}, + b'{"inches": 10}', + id="post", + ), + pytest.param( + "GET", + "?" + + urlencode( + {"encoding": "json", "compression": "", "message": '{"inches": 10}'} + ), + {}, + b"", + id="get", + ), +] + + +@pytest.mark.parametrize( + ("method", "query", "headers", "body"), _empty_compression_requests +) +def test_empty_request_compression_sync(method, query, headers, body) -> None: + class SimpleHaberdasher(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="blue") + + transport = WSGITransport(HaberdasherWSGIApplication(SimpleHaberdasher())) + res = SyncClient(transport).execute( + method=method, + url=f"http://localhost/connectrpc.example.Haberdasher/MakeHat{query}", + headers=headers, + content=body, + ) + assert res.status == 200 + assert res.json() == {"size": 10, "color": "blue"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "query", "headers", "body"), _empty_compression_requests +) +async def test_empty_request_compression_async(method, query, headers, body) -> None: + class SimpleHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="blue") + + transport = ASGITransport(HaberdasherASGIApplication(SimpleHaberdasher())) + res = await Client(transport).execute( + method=method, + url=f"http://localhost/connectrpc.example.Haberdasher/MakeHat{query}", + headers=headers, + content=body, + ) + assert res.status == 200 + assert res.json() == {"size": 10, "color": "blue"} + + class TestIdentityCompression: def test_name(self): assert IdentityCompression().name() == "identity" From a77bba7f08035278565a0189c7ba5605f2a9e49e Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:38:39 +0900 Subject: [PATCH 3/5] Match request compression names exactly on Connect unary The Connect unary POST path lowercased content-encoding before looking it up, unlike every other path, connect-go, and connect-es. A compression registered under a name with uppercase letters therefore worked for streams but failed unary requests with unimplemented. Co-Authored-By: Claude Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- src/connectrpc/_server_async.py | 2 +- src/connectrpc/_server_sync.py | 4 +- test/test_compression.py | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/connectrpc/_server_async.py b/src/connectrpc/_server_async.py index f953f02e..7953d49a 100644 --- a/src/connectrpc/_server_async.py +++ b/src/connectrpc/_server_async.py @@ -375,7 +375,7 @@ async def _read_post_request( req_body = b"".join(chunks) # Handle compression if specified - compression_name = (headers.get("content-encoding") or "identity").lower() + compression_name = headers.get("content-encoding") or "identity" compression = self._compressions.get(compression_name) if not compression: raise ConnectError( diff --git a/src/connectrpc/_server_sync.py b/src/connectrpc/_server_sync.py index d34f6a79..dbe23c69 100644 --- a/src/connectrpc/_server_sync.py +++ b/src/connectrpc/_server_sync.py @@ -402,9 +402,7 @@ def _handle_post_request( req_body = b"".join(chunks) # Handle compression if specified - compression_name = ( - environ.get("HTTP_CONTENT_ENCODING") or "identity" - ).lower() + compression_name = environ.get("HTTP_CONTENT_ENCODING") or "identity" compression = self._compressions.get(compression_name) if not compression: raise ConnectError( diff --git a/test/test_compression.py b/test/test_compression.py index 883fe144..95890c6c 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -174,6 +174,72 @@ def make_similar_hats(self, _request, _ctx): ) +class _XorCompression: + """A toy compression registered under a name that is not all lowercase.""" + + def name(self) -> str: + return "Xor" + + def compress(self, data: bytes | bytearray | memoryview) -> bytes: + return bytes(b ^ 0x5A for b in data) + + def decompress( + self, + data: bytes | bytearray | memoryview, + read_max_bytes: int | None = None, # noqa: ARG002 + ) -> bytes: + return bytes(b ^ 0x5A for b in data) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", _streams) +async def test_mixed_case_request_compression_async(stream: bool) -> None: + class SimpleHaberdasher(Haberdasher): + async def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="blue") + + async def make_similar_hats(self, request, _ctx): + yield Hat(size=request.inches, color="blue") + + app = HaberdasherASGIApplication( + SimpleHaberdasher(), compressions=[_XorCompression()] + ) + client = HaberdasherClient( + "http://localhost", + http_client=Client(ASGITransport(app)), + send_compression=_XorCompression(), + ) + if stream: + hats = [hat async for hat in client.make_similar_hats(Size(inches=10))] + else: + hats = [await client.make_hat(Size(inches=10))] + assert hats == [Hat(size=10, color="blue")] + + +@pytest.mark.parametrize("stream", _streams) +def test_mixed_case_request_compression_sync(stream: bool) -> None: + class SimpleHaberdasher(HaberdasherSync): + def make_hat(self, request, _ctx): + return Hat(size=request.inches, color="blue") + + def make_similar_hats(self, request, _ctx): + yield Hat(size=request.inches, color="blue") + + app = HaberdasherWSGIApplication( + SimpleHaberdasher(), compressions=[_XorCompression()] + ) + client = HaberdasherClientSync( + "http://localhost", + http_client=SyncClient(WSGITransport(app)), + send_compression=_XorCompression(), + ) + if stream: + hats = list(client.make_similar_hats(Size(inches=10))) + else: + hats = [client.make_hat(Size(inches=10))] + assert hats == [Hat(size=10, color="blue")] + + @pytest.mark.parametrize( ("protocol", "header_name"), [ From a4df3ebc5b3acbab37c5687236e2852af2db0e62 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:40:11 +0900 Subject: [PATCH 4/5] Share the unknown compression error and stop listing identity The same unknown compression message was built in six places. Build it in one helper, and leave identity out of the supported encodings unless nothing else is supported: identity is always accepted, it only showed up because resolve_compressions adds it for lookups, and connect-go and connect-es list only real compressions. Co-Authored-By: Claude Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- src/connectrpc/_compression.py | 13 +++++++++++++ src/connectrpc/_server_async.py | 23 ++++++++++------------- src/connectrpc/_server_sync.py | 23 ++++++++++------------- test/test_compression.py | 31 ++++++++++++++++++++++++++++--- 4 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/connectrpc/_compression.py b/src/connectrpc/_compression.py index f139fa50..f0ab9250 100644 --- a/src/connectrpc/_compression.py +++ b/src/connectrpc/_compression.py @@ -5,7 +5,9 @@ from connectrpc.compression.gzip import GzipCompression from ._shared import message_too_large_error +from .code import Code from .compression import Compression +from .errors import ConnectError if TYPE_CHECKING: from collections.abc import Iterable @@ -53,3 +55,14 @@ def negotiate_compression( if compression: return compression return _identity + + +def unknown_compression_error( + name: str, compressions: dict[str, Compression] +) -> ConnectError: + # identity is always accepted, so only list it when nothing else is. + supported = [n for n in compressions if n != "identity"] or ["identity"] + return ConnectError( + Code.UNIMPLEMENTED, + f"unknown compression: '{name}': supported encodings are {', '.join(supported)}", + ) diff --git a/src/connectrpc/_server_async.py b/src/connectrpc/_server_async.py index 7953d49a..bef10199 100644 --- a/src/connectrpc/_server_async.py +++ b/src/connectrpc/_server_async.py @@ -12,7 +12,11 @@ from urllib.parse import parse_qs from ._codec import Codec, get_default_codecs -from ._compression import negotiate_compression, resolve_compressions +from ._compression import ( + negotiate_compression, + resolve_compressions, + unknown_compression_error, +) from ._envelope import EnvelopeReader from ._interceptor_async import ( BidiStreamInterceptor, @@ -341,10 +345,7 @@ async def _read_get_request( compression_name = params.get("compression", [""])[0] or "identity" compression = self._compressions.get(compression_name) if not compression: - raise ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", - ) + raise unknown_compression_error(compression_name, self._compressions) # Decompress and decode message if message: # Don't decompress empty messages @@ -378,10 +379,7 @@ async def _read_post_request( compression_name = headers.get("content-encoding") or "identity" compression = self._compressions.get(compression_name) if not compression: - raise ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", - ) + raise unknown_compression_error(compression_name, self._compressions) if req_body: # Don't decompress empty body req_body = compression.decompress(req_body, self._read_max_bytes) @@ -410,10 +408,9 @@ async def _handle_stream( try: await metadata_run.start() if not req_compression: - compression_name = headers.get(protocol.compression_header_name()) - raise ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", + raise unknown_compression_error( + headers.get(protocol.compression_header_name(), ""), + self._compressions, ) request_stream = _request_stream( receive, diff --git a/src/connectrpc/_server_sync.py b/src/connectrpc/_server_sync.py index dbe23c69..2f203ccc 100644 --- a/src/connectrpc/_server_sync.py +++ b/src/connectrpc/_server_sync.py @@ -11,7 +11,11 @@ from . import _server_shared from ._codec import Codec, get_default_codecs -from ._compression import negotiate_compression, resolve_compressions +from ._compression import ( + negotiate_compression, + resolve_compressions, + unknown_compression_error, +) from ._envelope import EnvelopeReader, EnvelopeWriter from ._interceptor_sync import ( BidiStreamInterceptorSync, @@ -405,10 +409,7 @@ def _handle_post_request( compression_name = environ.get("HTTP_CONTENT_ENCODING") or "identity" compression = self._compressions.get(compression_name) if not compression: - raise ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", - ) + raise unknown_compression_error(compression_name, self._compressions) try: req_body = compression.decompress(req_body, self._read_max_bytes) except ConnectError: @@ -463,10 +464,7 @@ def _handle_get_request( compression_name = params.get("compression", [""])[0] or "identity" compression = self._compressions.get(compression_name) if not compression: - raise ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", - ) + raise unknown_compression_error(compression_name, self._compressions) message = compression.decompress(message, self._read_max_bytes) codec_name = params.get("encoding", ("",))[0] @@ -525,10 +523,9 @@ def _handle_stream( try: metadata_run.start() if not req_compression: - compression_name = headers.get(protocol.compression_header_name()) - raise ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{compression_name}': supported encodings are {', '.join(self._compressions.keys())}", + raise unknown_compression_error( + headers.get(protocol.compression_header_name(), ""), + self._compressions, ) request_stream = _request_stream( request_body, diff --git a/test/test_compression.py b/test/test_compression.py index 95890c6c..bc3ab77b 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -8,7 +8,11 @@ from pyqwest import Client, SyncClient from pyqwest.testing import ASGITransport, WSGITransport -from connectrpc._compression import IdentityCompression, resolve_compressions +from connectrpc._compression import ( + IdentityCompression, + resolve_compressions, + unknown_compression_error, +) from connectrpc._protocol_connect import ConnectServerProtocol from connectrpc._protocol_grpc import GRPCServerProtocol from connectrpc.client import ResponseMetadata @@ -139,7 +143,7 @@ async def make_similar_hats(self, _request, _ctx): assert exc_info.value.code == Code.UNIMPLEMENTED assert ( exc_info.value.message - == "unknown compression: 'zstd': supported encodings are gzip, identity" + == "unknown compression: 'zstd': supported encodings are gzip" ) @@ -170,7 +174,28 @@ def make_similar_hats(self, _request, _ctx): assert exc_info.value.code == Code.UNIMPLEMENTED assert ( exc_info.value.message - == "unknown compression: 'zstd': supported encodings are gzip, identity" + == "unknown compression: 'zstd': supported encodings are gzip" + ) + + +@pytest.mark.parametrize( + ("compressions", "supported"), + [ + pytest.param(None, "gzip", id="default"), + pytest.param((), "identity", id="none"), + pytest.param( + (ZstdCompression(), GzipCompression()), "zstd, gzip", id="multiple" + ), + ], +) +def test_unknown_compression_error( + compressions: tuple[Compression, ...] | None, supported: str +) -> None: + error = unknown_compression_error("foo", resolve_compressions(compressions)) + assert error.code == Code.UNIMPLEMENTED + assert ( + error.message + == f"unknown compression: 'foo': supported encodings are {supported}" ) From 15c8062cd7d302cd16aca70621746f0fddec2558 Mon Sep 17 00:00:00 2001 From: i2y <6240399+i2y@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:08:29 +0900 Subject: [PATCH 5/5] Say compression is not supported instead of listing identity With no compression configured, the unknown compression error still listed identity as the only supported encoding. identity is always accepted and is not a compression, so say that compression is not supported instead of listing it or ending with an empty list. Co-Authored-By: Claude Signed-off-by: i2y <6240399+i2y@users.noreply.github.com> --- src/connectrpc/_compression.py | 12 +++++++----- test/test_compression.py | 17 ++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/connectrpc/_compression.py b/src/connectrpc/_compression.py index f0ab9250..6475d084 100644 --- a/src/connectrpc/_compression.py +++ b/src/connectrpc/_compression.py @@ -60,9 +60,11 @@ def negotiate_compression( def unknown_compression_error( name: str, compressions: dict[str, Compression] ) -> ConnectError: - # identity is always accepted, so only list it when nothing else is. - supported = [n for n in compressions if n != "identity"] or ["identity"] - return ConnectError( - Code.UNIMPLEMENTED, - f"unknown compression: '{name}': supported encodings are {', '.join(supported)}", + # identity is always accepted and is not a compression, so it is not listed. + supported = [n for n in compressions if n != "identity"] + detail = ( + f"supported encodings are {', '.join(supported)}" + if supported + else "compression is not supported" ) + return ConnectError(Code.UNIMPLEMENTED, f"unknown compression: '{name}': {detail}") diff --git a/test/test_compression.py b/test/test_compression.py index bc3ab77b..f3d35890 100644 --- a/test/test_compression.py +++ b/test/test_compression.py @@ -179,24 +179,23 @@ def make_similar_hats(self, _request, _ctx): @pytest.mark.parametrize( - ("compressions", "supported"), + ("compressions", "detail"), [ - pytest.param(None, "gzip", id="default"), - pytest.param((), "identity", id="none"), + pytest.param(None, "supported encodings are gzip", id="default"), + pytest.param((), "compression is not supported", id="none"), pytest.param( - (ZstdCompression(), GzipCompression()), "zstd, gzip", id="multiple" + (ZstdCompression(), GzipCompression()), + "supported encodings are zstd, gzip", + id="multiple", ), ], ) def test_unknown_compression_error( - compressions: tuple[Compression, ...] | None, supported: str + compressions: tuple[Compression, ...] | None, detail: str ) -> None: error = unknown_compression_error("foo", resolve_compressions(compressions)) assert error.code == Code.UNIMPLEMENTED - assert ( - error.message - == f"unknown compression: 'foo': supported encodings are {supported}" - ) + assert error.message == f"unknown compression: 'foo': {detail}" class _XorCompression: