From c986987116f40a41b93b4272a3f69de2f6cd7bed Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 12:48:32 +0300 Subject: [PATCH] perf: build the StatusError message on first use instead of at construction --- src/httpware/errors.py | 22 +++++++++++++++++++++- tests/test_errors.py | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/httpware/errors.py b/src/httpware/errors.py index 123711b..e2b6acd 100644 --- a/src/httpware/errors.py +++ b/src/httpware/errors.py @@ -23,6 +23,9 @@ from httpware._internal.redaction import redact_url +_EXCEPTION_ARGS: types.GetSetDescriptorType = BaseException.__dict__["args"] + + def _restore_client_error(cls: "type[ClientError]", args: tuple[Any, ...]) -> "ClientError": return cls.__new__(cls, *args) @@ -71,7 +74,24 @@ class StatusError(ClientError): def __init__(self, response: httpx2.Response) -> None: self.response = response - super().__init__(self._summary()) + + @property + def args(self) -> tuple[Any, ...]: + """The one-element ``(message,)`` tuple, the message built on first access.""" + args = _EXCEPTION_ARGS.__get__(self) + # BaseException.__new__ stored the constructor's (response,) here. + if len(args) == 1 and args[0] is vars(self).get("response"): + args = (self._summary(),) + _EXCEPTION_ARGS.__set__(self, args) + return args + + @args.setter + def args(self, value: tuple[Any, ...]) -> None: + _EXCEPTION_ARGS.__set__(self, value) + + def __str__(self) -> str: + args = self.args + return str(args[0]) if len(args) == 1 else super().__str__() def _summary(self) -> str: method = self.response.request.method diff --git a/tests/test_errors.py b/tests/test_errors.py index abe98e0..277513c 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -99,6 +99,29 @@ def test_status_error_summary_message_includes_status_method_url() -> None: assert str(exc) == "404 GET https://example.test/missing" +def test_status_error_args_hold_the_summary_message() -> None: + exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x?token=s", method="GET")) + assert exc.args == ("404 GET https://example.test/x?token=REDACTED",) + + +def test_status_error_builds_its_message_on_first_use(monkeypatch: pytest.MonkeyPatch) -> None: + redacted: list[str] = [] + monkeypatch.setattr(httpware.errors, "redact_url", redacted.append) + NotFoundError(_make_response(404)) + assert redacted == [] + + +@pytest.mark.parametrize( + ("args", "message"), + [(("custom",), "custom"), (("a", "b"), "('a', 'b')"), ((), "")], +) +def test_status_error_assigned_args_replace_the_message(args: tuple[str, ...], message: str) -> None: + exc = NotFoundError(_make_response(404)) + exc.args = args + assert exc.args == args + assert str(exc) == message + + def test_status_error_strips_userinfo_in_summary_message() -> None: exc = NotFoundError(_make_response(404, url="https://user:pass@example.test/x")) assert "user" not in str(exc)