Skip to content

Commit a46b72a

Browse files
committed
feat(errors): add MissingDecoderError raised by future multi-decoder dispatch
1 parent fff1fba commit a46b72a

4 files changed

Lines changed: 100 additions & 0 deletions

File tree

‎src/httpware/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DecodeError,
1313
ForbiddenError,
1414
InternalServerError,
15+
MissingDecoderError,
1516
NetworkError,
1617
NotFoundError,
1718
RateLimitedError,
@@ -57,6 +58,7 @@
5758
"ForbiddenError",
5859
"InternalServerError",
5960
"Middleware",
61+
"MissingDecoderError",
6062
"NetworkError",
6163
"Next",
6264
"NotFoundError",

‎src/httpware/errors.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,43 @@ def __reduce__(self) -> tuple[Any, ...]:
254254
_reconstruct_decode_error,
255255
(type(self), self.response, self.model, self.original),
256256
)
257+
258+
259+
def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str:
260+
if not registered_names:
261+
hint = (
262+
"no decoders registered. Install `pip install httpware[pydantic]` "
263+
"or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly."
264+
)
265+
else:
266+
joined = " + ".join(registered_names)
267+
hint = f"registered decoders ({joined}) all rejected it. Pass a custom decoder via decoders=[...]."
268+
return f"no decoder for response_model={model!r}: {hint}"
269+
270+
271+
def _reconstruct_missing_decoder(
272+
cls: "type[MissingDecoderError]",
273+
model: type,
274+
registered_names: tuple[str, ...],
275+
) -> "MissingDecoderError":
276+
return cls(model=model, registered_names=registered_names)
277+
278+
279+
class MissingDecoderError(ClientError):
280+
"""Raised when response_model= is set but no registered decoder claims the model.
281+
282+
Fires at .send() entry, BEFORE the HTTP call — no point sending a request
283+
whose response cannot be decoded. Distinct from DecodeError, which means
284+
the decoder ran and the payload was malformed.
285+
"""
286+
287+
model: type
288+
registered_names: tuple[str, ...]
289+
290+
def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None:
291+
self.model = model
292+
self.registered_names = registered_names
293+
super().__init__(_missing_decoder_summary(model, registered_names))
294+
295+
def __reduce__(self) -> tuple[Any, ...]:
296+
return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names))

‎tests/test_errors.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
DecodeError,
1919
ForbiddenError,
2020
InternalServerError,
21+
MissingDecoderError,
2122
NetworkError,
2223
NotFoundError,
2324
RateLimitedError,
@@ -305,3 +306,54 @@ def test_decode_error_pickleable() -> None:
305306
assert isinstance(restored.original, ValueError)
306307
assert str(restored.original) == "bad payload"
307308
assert restored.response.status_code == HTTPStatus.OK
309+
310+
311+
class _Foo:
312+
pass
313+
314+
315+
def test_missing_decoder_error_carries_model() -> None:
316+
exc = MissingDecoderError(model=_Foo, registered_names=())
317+
assert exc.model is _Foo
318+
319+
320+
def test_missing_decoder_error_carries_registered_names() -> None:
321+
exc = MissingDecoderError(model=_Foo, registered_names=("PydanticDecoder",))
322+
assert exc.registered_names == ("PydanticDecoder",)
323+
324+
325+
def test_missing_decoder_error_no_registered_message() -> None:
326+
exc = MissingDecoderError(model=_Foo, registered_names=())
327+
msg = str(exc)
328+
assert "no decoders registered" in msg
329+
assert "httpware[pydantic]" in msg
330+
assert "httpware[msgspec]" in msg
331+
332+
333+
def test_missing_decoder_error_single_registered_message() -> None:
334+
exc = MissingDecoderError(model=_Foo, registered_names=("PydanticDecoder",))
335+
assert "registered decoders (PydanticDecoder) all rejected" in str(exc)
336+
337+
338+
def test_missing_decoder_error_two_registered_message() -> None:
339+
exc = MissingDecoderError(
340+
model=_Foo,
341+
registered_names=("PydanticDecoder", "MsgspecDecoder"),
342+
)
343+
assert "registered decoders (PydanticDecoder + MsgspecDecoder) all rejected" in str(exc)
344+
345+
346+
def test_missing_decoder_error_is_client_error() -> None:
347+
exc = MissingDecoderError(model=_Foo, registered_names=())
348+
assert isinstance(exc, ClientError)
349+
350+
351+
def test_missing_decoder_error_pickle_roundtrip() -> None:
352+
exc = MissingDecoderError(
353+
model=_Foo,
354+
registered_names=("PydanticDecoder", "MsgspecDecoder"),
355+
)
356+
revived = pickle.loads(pickle.dumps(exc)) # noqa: S301
357+
assert isinstance(revived, MissingDecoderError)
358+
assert revived.model is _Foo
359+
assert revived.registered_names == ("PydanticDecoder", "MsgspecDecoder")

‎tests/test_public_api.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def test_expected_exports() -> None:
4444
"ForbiddenError",
4545
"InternalServerError",
4646
"Middleware",
47+
"MissingDecoderError",
4748
"NetworkError",
4849
"Next",
4950
"NotFoundError",
@@ -71,3 +72,8 @@ def test_expected_exports() -> None:
7172
assert expected == actual, (
7273
f"__all__ mismatch:\n missing from __all__: {expected - actual}\n unexpected in __all__: {actual - expected}"
7374
)
75+
76+
77+
def test_missing_decoder_error_exported() -> None:
78+
assert "MissingDecoderError" in httpware.__all__
79+
assert httpware.MissingDecoderError.__module__ == "httpware.errors"

0 commit comments

Comments
 (0)