Skip to content

Commit b00487c

Browse files
authored
fix!: errors and redaction (pickling, query masking, DecodeError message, read-only STATUS_TO_EXCEPTION) (#166)
* fix: keep notes and attributes when pickling or copying any ClientError One __reduce__ on ClientError rebuilds from args without calling __init__ and restores __dict__, replacing _KeywordReduceMixin and the StatusError reconstructor. add_note() notes and user-set attributes now survive pickle, copy.copy and copy.deepcopy for every error class. * fix!: make STATUS_TO_EXCEPTION read-only STATUS_TO_EXCEPTION is now a types.MappingProxyType. Code that assigned to it to remap a status now gets TypeError; catch the error in a middleware and raise your own class instead. * fix: mask every query value in error messages and events redact_url now replaces the value of every query and fragment parameter with REDACTED and keeps the keys, instead of masking an allowlist of sensitive names, so presigned-URL signatures and other unlisted secrets no longer reach messages, reprs, log records or span events. Userinfo is cut at the last @ so a raw password containing @ is stripped whole. * 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. * fix: let phase decorator reprs handle callables without __qualname__ repr() of a middleware built by before_request, after_response, on_error or their async siblings no longer raises AttributeError for a functools.partial or a callable instance; it falls back to repr(f). * docs: describe redaction as masking every query value
1 parent 4073099 commit b00487c

13 files changed

Lines changed: 294 additions & 252 deletions

‎AGENTS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ Every module under `src/httpware/` is named for what it does; read it. What a si
3636
sibling).
3737
- `errors.py` owns the tree and both construction rules. Status-keyed `StatusError` subclasses take
3838
a single positional `response` and never define `__init__`; the six non-status subclasses are
39-
keyword-only and inherit `__reduce__` from `_KeywordReduceMixin`, whose precondition is enforced
40-
by `tests/test_errors.py::test_keyword_reduce_classes_dict_mirrors_their_init_parameters`. Adding
41-
a class means picking a side, and both sides are checked.
39+
keyword-only. Pickling and copying go through one `__reduce__` on `ClientError`: it rebuilds the
40+
instance from `args` without calling `__init__` and restores `__dict__`, so a new class needs no
41+
pickling code, only picklable attributes. Adding a class means picking a construction side and
42+
adding a case to `_ERROR_FACTORIES` in `tests/test_errors.py`, which an invariant test enforces
43+
for every exported error class.
4244
- `decoders/_resolver.py` is the **single dispatch path** for Seam B: `_DecoderResolver.resolve`
4345
walks the frozen decoder tuple, raises `MissingDecoderError` *before* the HTTP call when nothing
4446
claims the model, and returns a `_BoundDecoder` whose `decode` wraps any decoder-side failure as

‎docs/errors.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ ClientError (catch-all for anything httpware raises)
4949

5050
Only statuses from 400 to 599 raise. Any other status returns the response unchanged.
5151

52-
The numbered rows are exported as `STATUS_TO_EXCEPTION`, a `Mapping[int, type[StatusError]]`, so you can look up a class in code with `STATUS_TO_EXCEPTION.get(404)`. The two fallback rows are not in the mapping.
52+
The numbered rows are exported as `STATUS_TO_EXCEPTION`, a `Mapping[int, type[StatusError]]`, so you can look up a class in code with `STATUS_TO_EXCEPTION.get(404)`. The two fallback rows are not in the mapping. The mapping is read-only, and assigning to it raises `TypeError`. To raise your own class for a status, catch the httpware error in a middleware and raise yours from it.
5353

5454
## Catching strategies
5555

@@ -112,7 +112,7 @@ exc.response.request.url # the failing URL (httpx2.URL)
112112
exc.response.request.method # the HTTP method
113113
```
114114

115-
The exception's `repr` and message strip `user:pass@` userinfo and mask the values of known-sensitive query and URL-fragment parameters (`api_key`, `apikey`, `access_token`, `refresh_token`, `token`, `secret`, `client_secret`, `password`, `passwd`, `pwd`, `auth`, `authorization`, `sig`, `signature`, `key`, `private_key`, `session`, `sessionid`, `x-api-key`) as `REDACTED`, keeping the keys. Values under other names are left as they are, so avoid putting other secrets in query strings. Request headers such as `Authorization` and `Cookie` are never redacted, and `exc.response.request.headers` returns them in full.
115+
The exception's `repr` and message strip `user:pass@` userinfo and replace every query-parameter value with `REDACTED`, keeping the keys, so `?page=2&X-Amz-Signature=abc` shows as `?page=REDACTED&X-Amz-Signature=REDACTED`. Fragment values get the same treatment, and a fragment without `=`, like `#section-3`, is kept. The unredacted URL is still on `exc.response.request.url`. Request headers such as `Authorization` and `Cookie` are never redacted, and `exc.response.request.headers` returns them in full.
116116

117117
## Resilience-error payloads
118118

@@ -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

‎docs/middleware.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Middleware is for behavior that applies to every call through a client. Other ca
1313
- Side effects that don't need httpware's exceptions or chain order, including on post-redirect hops: use `event_hooks` on an `httpx2` client you build yourself and pass as `httpx2_client=`. Middleware and phase decorators run in the httpware chain, see httpware exceptions, and compose with `AsyncRetry` and `AsyncBulkhead`. `event_hooks` run a layer below, on every transport attempt. That is also why `httpware` refuses `event_hooks=` as a client option: an `httpx2.HTTPStatusError` raised in a hook (say, by `raise_for_status()`) reaches the chain as a plain `TransportError` instead of a `StatusError`, so `AsyncRetry` never retries it and the circuit breaker never counts it.
1414
- URL or header validation: `httpx2` already does it.
1515
- Creating HTTP spans for tracing: install `opentelemetry-instrumentation-httpx`, which already traces every transport call. See [Observability](observability.md).
16-
- Redaction: httpware redacts URLs before they reach logs, telemetry, and error messages. It strips `user:pass@` userinfo and masks the values of sensitive query and fragment parameters ([Errors](errors.md#excresponse-access-pattern) lists them). It does not touch headers or bodies, so if your middleware logs those, redact them yourself, for example with a `logging.Filter`.
16+
- Redaction: httpware redacts URLs before they reach logs, telemetry, and error messages. It strips `user:pass@` userinfo and replaces every query and fragment parameter value with `REDACTED`, keeping the keys ([Errors](errors.md#excresponse-access-pattern) has an example). It does not touch headers or bodies, so if your middleware logs those, redact them yourself, for example with a `logging.Filter`.
1717

1818
## Writing your own
1919

‎docs/observability.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ The logger and event names below are a stable public contract:
1313

1414
Each log record has an `event` field holding the event name, such as `event="circuit.opened"`, which you can filter on in your log aggregator. Events from `AsyncKeyedCircuitBreaker` and `KeyedCircuitBreaker` also carry `circuit_key`, the circuit they belong to. [Resilience](resilience.md) describes when each event fires.
1515

16+
Events that carry a `url` attribute get it redacted first: httpware strips `user:pass@` userinfo and replaces every query-parameter value with `REDACTED`, keeping the keys. A URL logged as `https://api.test/items?page=REDACTED&sort=REDACTED` came from `https://api.test/items?page=2&sort=name`.
17+
1618
```python
1719
import logging
1820

‎src/httpware/_internal/observability.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _emit_event(
2626
2727
The ``url`` attribute, when present, is run through
2828
``redaction.redact_url`` here — at the single emission boundary — so a
29-
request URL's userinfo and known-sensitive query/fragment secrets never
29+
request URL's userinfo and query/fragment parameter values never
3030
reach a log record or span event, regardless of how a caller built the
3131
attributes dict.
3232
Lines changed: 22 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,19 @@
11
"""URL sanitation for logs, telemetry, and error messages.
22
3-
Strips ``user:pass@`` userinfo and masks the values of known-sensitive query
4-
parameters so secrets embedded in URLs do not leak into observability output.
5-
Shared by ``errors.py`` (StatusError messages) and the resilience middleware
6-
(event attributes).
3+
Strips ``user:pass@`` userinfo and masks every query and fragment parameter
4+
value, keeping the keys, so secrets embedded in URLs do not leak into
5+
observability output. Shared by ``errors.py`` (StatusError messages) and
6+
``observability._emit_event`` (event attributes).
77
"""
88

9-
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
9+
from urllib.parse import urlsplit, urlunsplit
1010

1111

12-
SENSITIVE_QUERY_KEYS = frozenset(
13-
{
14-
"api_key",
15-
"apikey",
16-
"access_token",
17-
"refresh_token",
18-
"token",
19-
"secret",
20-
"client_secret",
21-
"password",
22-
"passwd",
23-
"pwd",
24-
"auth",
25-
"authorization",
26-
"sig",
27-
"signature",
28-
"key",
29-
"private_key",
30-
"session",
31-
"sessionid",
32-
"x-api-key",
33-
}
34-
)
35-
3612
_REDACTED = "REDACTED"
3713

3814

3915
def _reassemble(scheme: str, netloc: str, path: str, query: str, fragment: str) -> str:
40-
"""Like ``urlunsplit``, but avoid the spurious triple-slash for an empty authority.
41-
42-
``urlunsplit(("http", "", "/path", ...))`` yields ``http:///path`` for a
43-
netloc-using scheme. When userinfo stripping leaves no host (e.g.
44-
``http://user:pass@/path``) we want ``http:/path`` (scheme + path), not a
45-
triple-slash. With a non-empty netloc this delegates to ``urlunsplit``, so
46-
normal URLs are byte-identical.
47-
"""
16+
"""Like ``urlunsplit``, but return ``scheme:path`` rather than ``scheme:///path`` for an empty authority."""
4817
if netloc:
4918
return urlunsplit((scheme, netloc, path, query, fragment))
5019
tail = path
@@ -61,56 +30,32 @@ def _strip_userinfo(url: str) -> str:
6130
parts = urlsplit(url)
6231
if parts.username is None and parts.password is None:
6332
return url
64-
# Strip the "user:pass@" prefix from the raw netloc to preserve host:port
65-
# exactly (including IPv6 brackets), rather than reconstructing from parts.
66-
netloc = parts.netloc.split("@", 1)[1] if "@" in parts.netloc else parts.netloc
33+
netloc = parts.netloc.rpartition("@")[2]
6734
return _reassemble(parts.scheme, netloc, parts.path, parts.query, parts.fragment)
6835

6936

70-
def _mask_component(component: str) -> tuple[str, bool]:
71-
"""Mask sensitive key=value pairs in a query or fragment string.
72-
73-
Returns ``(masked_component, was_changed)``; when no sensitive key is
74-
found the original string is returned unchanged (``was_changed=False``).
75-
"""
76-
pairs = parse_qsl(component, keep_blank_values=True)
77-
if not any(key.strip().lower() in SENSITIVE_QUERY_KEYS for key, _ in pairs):
78-
return component, False
79-
masked = [(key, _REDACTED if key.strip().lower() in SENSITIVE_QUERY_KEYS else value) for key, value in pairs]
80-
return urlencode(masked), True
37+
def _mask_component(component: str) -> str:
38+
"""Replace the value of every ``key=value`` pair with ``REDACTED``; a component without ``=`` is kept."""
39+
if "=" not in component:
40+
return component
41+
return "&".join(
42+
key + "=" + _REDACTED if sep else key for key, sep, _ in (pair.partition("=") for pair in component.split("&"))
43+
)
8144

8245

8346
def _mask_query(url: str) -> str:
84-
parts = urlsplit(url)
85-
has_query = bool(parts.query)
86-
has_fragment = bool(parts.fragment)
87-
88-
if not has_query and not has_fragment:
47+
if "=" not in url:
8948
return url
90-
91-
new_query = parts.query
92-
new_fragment = parts.fragment
93-
changed = False
94-
95-
if has_query:
96-
new_query, q_changed = _mask_component(parts.query)
97-
changed = changed or q_changed
98-
99-
if has_fragment:
100-
new_fragment, f_changed = _mask_component(parts.fragment)
101-
changed = changed or f_changed
102-
103-
if not changed:
104-
return url # common-path guard: nothing sensitive, leave bytes untouched
105-
106-
return _reassemble(parts.scheme, parts.netloc, parts.path, new_query, new_fragment)
49+
rest, hash_sign, fragment = url.partition("#")
50+
base, question_mark, query = rest.partition("?")
51+
return base + question_mark + _mask_component(query) + hash_sign + _mask_component(fragment)
10752

10853

10954
def redact_url(url: str) -> str:
110-
"""Return ``url`` safe for logs/telemetry/errors.
55+
"""Return ``url`` safe for logs, telemetry and error messages.
11156
112-
Userinfo is stripped and the values of known-sensitive query parameters are
113-
replaced with ``REDACTED`` (keys preserved). URLs with no sensitive query
114-
key are returned byte-identical to the userinfo-stripped input.
57+
Userinfo is stripped and every query and fragment parameter value is
58+
replaced with ``REDACTED``, keeping the keys as written. A fragment with no
59+
``=`` in it, such as ``#section-3``, is kept as it is.
11560
"""
11661
return _mask_query(_strip_userinfo(url))

0 commit comments

Comments
 (0)