Skip to content

Commit edfe82b

Browse files
committed
fix: keep body content out of the DecodeError message
The message is now 'failed to decode response into <model>: <type>' with the error count appended when the original exception has error_count(). A non-class model such as X | None is shown by repr, which stops the AttributeError on Python 3.11 to 3.13. exc.original is unchanged.
1 parent b80a339 commit edfe82b

3 files changed

Lines changed: 55 additions & 16 deletions

File tree

‎docs/errors.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,20 @@ Fields:
149149
- `model: type`: the type passed as `response_model=`.
150150
- `original: BaseException`: the decoder's own exception, such as `pydantic.ValidationError`, `msgspec.ValidationError` or `msgspec.DecodeError`. It is also `exc.__cause__`.
151151

152+
The message names the model and the type of `original`, and adds the error count when `original` has an `error_count()` method, as `pydantic.ValidationError` does:
153+
154+
failed to decode response into User: ValidationError (2 errors)
155+
156+
A model that is not a class, such as `User | None` or `list[User]`, appears as its `repr`. The message never quotes the body. `str(exc.original)` can: a pydantic `ValidationError` echoes the input values it rejected, so check what it holds before you log it.
157+
152158
```python
153159
from httpware import AsyncClient, DecodeError
154160

155161

156162
try:
157163
user = await client.get("/users/1", response_model=User)
158164
except DecodeError as exc:
159-
_LOGGER.error(
160-
"decode failed for %s into %s: %s",
161-
exc.response.request.url,
162-
exc.model.__name__,
163-
exc.original,
164-
)
165+
_LOGGER.error("%s", exc)
165166
raise
166167
```
167168

‎src/httpware/errors.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,14 +202,26 @@ def __init__(self, *, retry_after: float | None) -> None:
202202
super().__init__(f"circuit open (retry_after={retry_after:.3f}s)")
203203

204204

205+
def _decode_error_summary(model: type, original: BaseException) -> str:
206+
model_name = model.__name__ if isinstance(model, type) else repr(model)
207+
summary = f"failed to decode response into {model_name}: {type(original).__qualname__}"
208+
error_count = getattr(original, "error_count", None)
209+
if not callable(error_count):
210+
return summary
211+
count = error_count()
212+
return f"{summary} ({count} error{'' if count == 1 else 's'})"
213+
214+
205215
class DecodeError(ClientError):
206216
"""Raised when the active ResponseDecoder failed to decode response.content.
207217
208218
The HTTP call itself succeeded — status was 2xx/3xx and the transport
209219
delivered the body intact — but the body could not be parsed into the
210220
requested response_model. Always chained from the underlying library
211221
exception via ``raise ... from exc``; that exception is also exposed as
212-
``self.original`` for structured handling.
222+
``self.original`` for structured handling. The message names the model
223+
and the original exception's type, plus its error count when it has an
224+
``error_count()`` method (pydantic), and never includes body content.
213225
"""
214226

215227
response: httpx2.Response
@@ -226,7 +238,7 @@ def __init__(
226238
self.response = response
227239
self.model = model
228240
self.original = original
229-
super().__init__(f"failed to decode response into {model.__name__}: {original}")
241+
super().__init__(_decode_error_summary(model, original))
230242

231243

232244
def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str:

‎tests/test_errors.py‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -298,14 +298,40 @@ def test_decode_error_stores_fields() -> None:
298298
assert exc.original is inner
299299

300300

301-
def test_decode_error_summary_includes_model_and_original() -> None:
302-
response = _make_response(200)
303-
inner = ValueError("bad payload")
304-
exc = DecodeError(response=response, model=_DecodeErrorModel, original=inner)
305-
summary = str(exc)
306-
assert "_DecodeErrorModel" in summary
307-
assert "bad payload" in summary
308-
assert summary.startswith("failed to decode response into ")
301+
def _validation_error(payload: bytes) -> pydantic.ValidationError:
302+
with pytest.raises(pydantic.ValidationError) as caught:
303+
_DecodeErrorModel.model_validate_json(payload)
304+
return caught.value
305+
306+
307+
def test_decode_error_summary_names_model_and_exception_type_only() -> None:
308+
exc = DecodeError(response=_make_response(200), model=_DecodeErrorModel, original=ValueError("body: topsecret"))
309+
assert str(exc) == "failed to decode response into _DecodeErrorModel: ValueError"
310+
311+
312+
def test_decode_error_summary_counts_validation_errors_without_body_content() -> None:
313+
original = _validation_error(b'{"id": "topsecret", "extra": 1}')
314+
exc = DecodeError(response=_make_response(200), model=_DecodeErrorModel, original=original)
315+
assert str(exc) == "failed to decode response into _DecodeErrorModel: ValidationError (1 error)"
316+
assert exc.original is original
317+
318+
319+
class _TwoFieldModel(pydantic.BaseModel):
320+
id: int
321+
name: str
322+
323+
324+
def test_decode_error_summary_pluralises_error_count() -> None:
325+
with pytest.raises(pydantic.ValidationError) as caught:
326+
_TwoFieldModel.model_validate_json(b'{"id": "topsecret", "name": 3}')
327+
exc = DecodeError(response=_make_response(200), model=_TwoFieldModel, original=caught.value)
328+
assert str(exc) == "failed to decode response into _TwoFieldModel: ValidationError (2 errors)"
329+
330+
331+
@pytest.mark.parametrize("model", [_DecodeErrorModel | None, list[_DecodeErrorModel]], ids=["optional", "generic"])
332+
def test_decode_error_summary_uses_repr_for_non_class_models(model: type) -> None:
333+
exc = DecodeError(response=_make_response(200), model=model, original=ValueError("x"))
334+
assert str(exc) == f"failed to decode response into {model!r}: ValueError"
309335

310336

311337
def test_decode_error_rejects_positional_args() -> None:

0 commit comments

Comments
 (0)