From 289a9ad361bbe2490f6f16d13169e096cb9043c0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:46:43 +0300 Subject: [PATCH 1/3] docs(planning): add errors keyword-reduce mixin design --- ...26-07-13.03-errors-keyword-reduce-mixin.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 planning/changes/2026-07-13.03-errors-keyword-reduce-mixin.md diff --git a/planning/changes/2026-07-13.03-errors-keyword-reduce-mixin.md b/planning/changes/2026-07-13.03-errors-keyword-reduce-mixin.md new file mode 100644 index 0000000..d783b3d --- /dev/null +++ b/planning/changes/2026-07-13.03-errors-keyword-reduce-mixin.md @@ -0,0 +1,157 @@ +--- +summary: Collapse the 6 hand-duplicated reconstruct-function + __reduce__ pairs in errors.py into one shared _KeywordReduceMixin. +--- + +# Design: Collapse errors.py's per-exception reconstruct/reduce boilerplate + +## Summary + +Six non-status `ClientError` subclasses (`RetryBudgetExhaustedError`, +`BulkheadFullError`, `CircuitOpenError`, `DecodeError`, +`MissingDecoderError`, `ResponseTooLargeError`) each hand-repeat a +module-level `_reconstruct_X` function plus a `__reduce__` method, purely +because their keyword-only `__init__` signatures are incompatible with +`BaseException`'s default `__reduce__` (`cls(*self.args)`). This change +replaces all six pairs with one shared `_KeywordReduceMixin`, added to +each of the six classes' bases, plus one shared `_reconstruct_kwonly` +function. No behavior change. + +## Motivation + +- The six `_reconstruct_X`/`__reduce__` pairs (`errors.py:139-145+171-175`, + `178-183+200-204`, `207-211+232-234`, `236-242+271-275`, + `290-295+314-315`, `318-325+366-370`) are structurally identical — + only field names/counts differ. ~25-30 lines repeated six times for one + mechanism. +- **Verified precondition:** for every one of the six classes, the + instance `__dict__` after `__init__` contains *exactly* the keyword-only + `__init__` parameters, nothing derived or extra — confirmed by reading + each `__init__` body. This makes a fully generic + `self.__dict__`-based reconstruction (`cls(**kwargs)`) behaviorally + identical to each class's current bespoke reduce. +- **Deletion test:** delete the shared mixin and all six classes lose + picklability identically — one concern wearing six costumes, not six + independent concerns. + +## Non-goals + +- No behavior change. Pickling output, `__cause__`/message reconstruction, + and every existing pickle-round-trip test in `tests/test_errors.py` + stay green unchanged — no test file edits in this change. +- **Not touching `ClientError` itself.** A blanket `self.__dict__`-based + `__reduce__` on the root `ClientError` was considered and rejected: + `TransportError`/`NetworkError`/`TimeoutError` have no custom `__init__` + (plain `Exception(message)` construction — the message lives in + `self.args`, and `self.__dict__` is empty for them). A root-level + generic `__reduce__` would silently break their pickling by + reconstructing them with no arguments. The mixin is applied only to the + six classes whose `__dict__`-mirrors-`__init__` precondition actually + holds. +- **Not touching `StatusError`.** Its `_reconstruct_status_error` + + `__reduce__` pair is a different, single, already-shared mechanism + (positional `response` arg, one function serving every `StatusError` + subclass via inheritance) — already fine, out of scope. +- **Not adding new tests.** The six existing pickle-round-trip tests + already exercise the shared mixin directly (each round-trips through + the real `__reduce__` → `_reconstruct_kwonly` → `cls(**kwargs)` path) + across varied field-counts (1 field for `CircuitOpenError`, up to 4 for + `ResponseTooLargeError`). A synthetic dedicated mixin test would only + re-prove what six real classes already prove. + +## Design + +### 1. `_KeywordReduceMixin` + `_reconstruct_kwonly` + +Added to `errors.py`, placed immediately before `RetryBudgetExhaustedError` +(replacing where `_reconstruct_budget_exhausted` currently starts the +block of six classes): + +```python +def _reconstruct_kwonly(cls: type, kwargs: dict[str, Any]) -> Any: + return cls(**kwargs) + + +class _KeywordReduceMixin: + """Shared __reduce__ for ClientError subclasses whose __init__ is + keyword-only and whose instance __dict__ exactly mirrors it. + + Do not add this mixin to a class that stores any attribute beyond its + __init__'s keyword parameters — reconstruction replays self.__dict__ + as keyword arguments, so an extra/derived attribute would either be + silently dropped or raise a TypeError on unpickle. + """ + + def __reduce__(self) -> tuple[Any, ...]: + return (_reconstruct_kwonly, (type(self), self.__dict__)) +``` + +### 2. Each of the six classes + +Add `_KeywordReduceMixin` as the first base, remove the class's own +`_reconstruct_X` function and `__reduce__` method. `__init__` bodies are +otherwise unchanged. Example (`BulkheadFullError`): + +```python +class BulkheadFullError(_KeywordReduceMixin, ClientError): + """Raised when ``acquire_timeout`` elapses before an AsyncBulkhead slot becomes available. + + Carries the configured caps for caller logging/alerting. + """ + + max_concurrent: int + acquire_timeout: float | None + + def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> None: + self.max_concurrent = max_concurrent + self.acquire_timeout = acquire_timeout + super().__init__(f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})") +``` + +(`_reconstruct_bulkhead_full` and `BulkheadFullError.__reduce__` deleted +entirely.) The same transformation applies to `RetryBudgetExhaustedError`, +`CircuitOpenError`, `DecodeError`, `MissingDecoderError`, and +`ResponseTooLargeError` — mixin first in the bases tuple, `_reconstruct_X` +function and `__reduce__` method deleted, `__init__` body unchanged. + +### 3. Documentation + +Add one sentence to `architecture/errors.md`, next to the existing +`__init__`-divergence rule, so a future contributor adding a 7th kwonly +`ClientError` subclass sees the constraint where they're already reading: + +> Non-status `ClientError` subclasses with keyword-only `__init__` fields +> inherit `__reduce__` from `_KeywordReduceMixin`, which pickles via +> `self.__dict__`. This requires `self.__dict__` to exactly mirror the +> `__init__` keyword parameters — do not store any additional derived +> attribute on these classes without also updating `__init__`'s +> parameters, or pickling will silently drop it. + +## Testing + +- **Parity net:** all six existing pickle-round-trip tests in + `tests/test_errors.py` (`test_retry_budget_exhausted_error_pickleable`, + `test_bulkhead_full_error_pickleable`, `test_decode_error_pickleable`, + `test_missing_decoder_error_pickle_roundtrip`, + `test_circuit_open_error_pickleable_with_float`/`_with_none`, + `test_response_too_large_error_pickle_round_trip`) stay green with zero + edits — byte-identical behavior is the bar. +- `just lint && just test` both clean; 100% coverage maintained (the + mixin's `__reduce__` and `_reconstruct_kwonly` are exercised by the six + existing tests, so no coverage gap opens). + +## Risk + +- **A future kwonly subclass violates the mixin's precondition** (i.e. + stores a derived attribute beyond its `__init__` params) — the + documented risk this change explicitly calls out in both the mixin's + own docstring and `architecture/errors.md`. *Mitigation:* the docstring + and doc line are the guardrail; no automated check is added (matching + this codebase's existing pattern of review-only invariants for + errors.py, per `architecture/overview.md`'s enforcement table). +- **MRO/inheritance concern** (unlikely × low): adding `_KeywordReduceMixin` + as an additional base could theoretically interact with exception + matching (`except ClientError:`) or `isinstance` checks. *Mitigation:* + `_KeywordReduceMixin` inherits only from `object`; `ClientError` remains + in every affected class's bases, so `isinstance`/`except` behavior is + unchanged. The existing test suite's exception-type assertions + (`test_errors.py`, `test_client_construction.py`, etc.) cover this net. From 700b6bedecd49d2ac66e988cd8c093b3938d12a5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 14:52:52 +0300 Subject: [PATCH 2/3] refactor(errors): collapse per-exception reconstruct/reduce boilerplate with _KeywordReduceMixin Replace six hand-repeated _reconstruct_X functions and __reduce__ methods with a single _KeywordReduceMixin that pickles via self.__dict__. Updates architecture/errors.md with the pickling invariant. Pure refactor, no behavior change; all 775 tests pass with 100% coverage. Co-Authored-By: Claude Sonnet 5 --- architecture/errors.md | 2 + src/httpware/errors.py | 108 +++++++++-------------------------------- 2 files changed, 25 insertions(+), 85 deletions(-) diff --git a/architecture/errors.md b/architecture/errors.md index f29c42d..1ef81f4 100644 --- a/architecture/errors.md +++ b/architecture/errors.md @@ -18,6 +18,8 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except The "no `__init__` override" rule scopes only to `StatusError` subclasses. Non-status `ClientError` subclasses — `DecodeError`, `MissingDecoderError`, `BulkheadFullError`, `RetryBudgetExhaustedError`, `CircuitOpenError`, `ResponseTooLargeError` — deliberately define `__init__` with keyword-only fields. +These six non-status `ClientError` subclasses inherit `__reduce__` from `_KeywordReduceMixin`, which pickles via `self.__dict__`. This requires `self.__dict__` to exactly mirror the `__init__` keyword parameters — do not store any additional derived attribute on these classes without also updating `__init__`'s parameters, or pickling will silently drop it. + `ResponseTooLargeError` is raised when `max_response_body_bytes` is set and a response body would exceed the cap — status-agnostic (a `200` can trip it), counting **decoded** bytes. It fires from the non-streaming terminal (`send()`) and from `stream()`'s internal error pre-read; user-driven `stream()` iteration is never capped. The `reason` field discriminates the two trip modes: `"declared"` (the declared `Content-Length` already exceeds the cap, rejected before any byte is read — `content_length` holds it) and `"streamed"` (the decoded body crossed the cap mid-read, the chunked or compression-bomb case, where the true size is unknown by design). It is a non-status `ClientError`; it does not carry a `StatusError`-style positional `response` and is not in `STATUS_TO_EXCEPTION`. Because it is neither a `StatusError`, `NetworkError`, nor `TimeoutError`, it is not retried and does not count toward the circuit breaker. ## Security: request headers are reachable via `exc.response.request` diff --git a/src/httpware/errors.py b/src/httpware/errors.py index a539aee..26b22ae 100644 --- a/src/httpware/errors.py +++ b/src/httpware/errors.py @@ -136,16 +136,26 @@ class ServiceUnavailableError(ServerStatusError): } -def _reconstruct_budget_exhausted( - cls: "type[RetryBudgetExhaustedError]", - last_response: httpx2.Response | None, - last_exception: BaseException | None, - attempts: int, -) -> "RetryBudgetExhaustedError": - return cls(last_response=last_response, last_exception=last_exception, attempts=attempts) +def _reconstruct_kwonly(cls: type, kwargs: dict[str, Any]) -> Any: # noqa: ANN401 + return cls(**kwargs) -class RetryBudgetExhaustedError(ClientError): +class _KeywordReduceMixin: + """Shared __reduce__ for keyword-only ClientError subclasses. + + For subclasses whose __init__ is keyword-only and whose instance + __dict__ exactly mirrors it. Do not add this mixin to a class that + stores any attribute beyond its __init__'s keyword parameters — + reconstruction replays self.__dict__ as keyword arguments, so an + extra/derived attribute would either be silently dropped or raise a + TypeError on unpickle. + """ + + def __reduce__(self) -> tuple[Any, ...]: + return (_reconstruct_kwonly, (type(self), self.__dict__)) + + +class RetryBudgetExhaustedError(_KeywordReduceMixin, ClientError): """Raised when a retry was needed but the RetryBudget refused to permit it. Carries the last response and/or exception observed before the budget refused, @@ -168,22 +178,8 @@ def __init__( self.attempts = attempts super().__init__(f"retry budget exhausted after {attempts} attempt(s)") - def __reduce__(self) -> tuple[Any, ...]: - return ( - _reconstruct_budget_exhausted, - (type(self), self.last_response, self.last_exception, self.attempts), - ) - - -def _reconstruct_bulkhead_full( - cls: "type[BulkheadFullError]", - max_concurrent: int, - acquire_timeout: float | None, -) -> "BulkheadFullError": - return cls(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout) - -class BulkheadFullError(ClientError): +class BulkheadFullError(_KeywordReduceMixin, ClientError): """Raised when ``acquire_timeout`` elapses before an AsyncBulkhead slot becomes available. Carries the configured caps for caller logging/alerting. @@ -197,21 +193,8 @@ def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> Non self.acquire_timeout = acquire_timeout super().__init__(f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})") - def __reduce__(self) -> tuple[Any, ...]: - return ( - _reconstruct_bulkhead_full, - (type(self), self.max_concurrent, self.acquire_timeout), - ) - - -def _reconstruct_circuit_open( - cls: "type[CircuitOpenError]", - retry_after: float | None, -) -> "CircuitOpenError": - return cls(retry_after=retry_after) - -class CircuitOpenError(ClientError): +class CircuitOpenError(_KeywordReduceMixin, ClientError): """Raised when a CircuitBreaker refuses a request because the circuit is not closed. Fires when the circuit is OPEN, or when it is HALF_OPEN and the single probe @@ -229,20 +212,8 @@ def __init__(self, *, retry_after: float | None) -> None: else: super().__init__(f"circuit open (retry_after={retry_after:.3f}s)") - def __reduce__(self) -> tuple[Any, ...]: - return (_reconstruct_circuit_open, (type(self), self.retry_after)) - - -def _reconstruct_decode_error( - cls: "type[DecodeError]", - response: httpx2.Response, - model: type, - original: BaseException, -) -> "DecodeError": - return cls(response=response, model=model, original=original) - -class DecodeError(ClientError): +class DecodeError(_KeywordReduceMixin, ClientError): """Raised when the active ResponseDecoder failed to decode response.content. The HTTP call itself succeeded — status was 2xx/3xx and the transport @@ -268,12 +239,6 @@ def __init__( self.original = original super().__init__(f"failed to decode response into {model.__name__}: {original}") - def __reduce__(self) -> tuple[Any, ...]: - return ( - _reconstruct_decode_error, - (type(self), self.response, self.model, self.original), - ) - def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str: if not registered_names: @@ -287,15 +252,7 @@ def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> return f"no decoder for response_model={model!r}: {hint}" -def _reconstruct_missing_decoder( - cls: "type[MissingDecoderError]", - model: type, - registered_names: tuple[str, ...], -) -> "MissingDecoderError": - return cls(model=model, registered_names=registered_names) - - -class MissingDecoderError(ClientError): +class MissingDecoderError(_KeywordReduceMixin, ClientError): """Raised when response_model= is set but no registered decoder claims the model. Fires at .send() entry, BEFORE the HTTP call — no point sending a request @@ -311,21 +268,8 @@ def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None: self.registered_names = registered_names super().__init__(_missing_decoder_summary(model, registered_names)) - def __reduce__(self) -> tuple[Any, ...]: - return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names)) - - -def _reconstruct_response_too_large( - cls: "type[ResponseTooLargeError]", - status_code: int, - limit: int, - content_length: int | None, - reason: 'Literal["declared", "streamed"]', -) -> "ResponseTooLargeError": - return cls(status_code=status_code, limit=limit, content_length=content_length, reason=reason) - -class ResponseTooLargeError(ClientError): +class ResponseTooLargeError(_KeywordReduceMixin, ClientError): """Raised when a response body exceeds the client's max_response_body_bytes cap. Status-agnostic: fires on any non-streaming send() and on stream()'s internal @@ -362,9 +306,3 @@ def __init__( else: detail = f"decoded body exceeded max_response_body_bytes={limit}" super().__init__(f"response body too large: status={status_code} {detail}") - - def __reduce__(self) -> tuple[Any, ...]: - return ( - _reconstruct_response_too_large, - (type(self), self.status_code, self.limit, self.content_length, self.reason), - ) From bc2a3f0a7d43a1df3bbb2991de9ba2315671ebeb Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 13 Jul 2026 15:08:28 +0300 Subject: [PATCH 3/3] fix(errors): correct failure-mode wording in _KeywordReduceMixin docs Final review caught that the docstring and architecture/errors.md both described the wrong failure mode: an extra attribute beyond __init__'s keyword params raises TypeError on unpickle (loud), not a silent drop. The silent-drop case is the opposite: a keyword param __init__ forgets to assign to self, which reverts to its default on unpickle if it has one. --- architecture/errors.md | 2 +- src/httpware/errors.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/architecture/errors.md b/architecture/errors.md index 1ef81f4..56bcac4 100644 --- a/architecture/errors.md +++ b/architecture/errors.md @@ -18,7 +18,7 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except The "no `__init__` override" rule scopes only to `StatusError` subclasses. Non-status `ClientError` subclasses — `DecodeError`, `MissingDecoderError`, `BulkheadFullError`, `RetryBudgetExhaustedError`, `CircuitOpenError`, `ResponseTooLargeError` — deliberately define `__init__` with keyword-only fields. -These six non-status `ClientError` subclasses inherit `__reduce__` from `_KeywordReduceMixin`, which pickles via `self.__dict__`. This requires `self.__dict__` to exactly mirror the `__init__` keyword parameters — do not store any additional derived attribute on these classes without also updating `__init__`'s parameters, or pickling will silently drop it. +These six non-status `ClientError` subclasses inherit `__reduce__` from `_KeywordReduceMixin`, which pickles via `self.__dict__` and reconstructs via `cls(**kwargs)`. This requires `self.__dict__` to exactly mirror the `__init__` keyword parameters: an attribute stored beyond those parameters raises `TypeError` on unpickle (unexpected keyword argument), and a keyword parameter `__init__` doesn't assign to `self` is silently dropped if it has a default (unpickle reverts to it) or raises `TypeError` if it doesn't. `ResponseTooLargeError` is raised when `max_response_body_bytes` is set and a response body would exceed the cap — status-agnostic (a `200` can trip it), counting **decoded** bytes. It fires from the non-streaming terminal (`send()`) and from `stream()`'s internal error pre-read; user-driven `stream()` iteration is never capped. The `reason` field discriminates the two trip modes: `"declared"` (the declared `Content-Length` already exceeds the cap, rejected before any byte is read — `content_length` holds it) and `"streamed"` (the decoded body crossed the cap mid-read, the chunked or compression-bomb case, where the true size is unknown by design). It is a non-status `ClientError`; it does not carry a `StatusError`-style positional `response` and is not in `STATUS_TO_EXCEPTION`. Because it is neither a `StatusError`, `NetworkError`, nor `TimeoutError`, it is not retried and does not count toward the circuit breaker. diff --git a/src/httpware/errors.py b/src/httpware/errors.py index 26b22ae..81dd82f 100644 --- a/src/httpware/errors.py +++ b/src/httpware/errors.py @@ -144,11 +144,12 @@ class _KeywordReduceMixin: """Shared __reduce__ for keyword-only ClientError subclasses. For subclasses whose __init__ is keyword-only and whose instance - __dict__ exactly mirrors it. Do not add this mixin to a class that - stores any attribute beyond its __init__'s keyword parameters — - reconstruction replays self.__dict__ as keyword arguments, so an - extra/derived attribute would either be silently dropped or raise a - TypeError on unpickle. + __dict__ exactly mirrors it. Reconstruction replays self.__dict__ as + keyword arguments (cls(**kwargs)): an attribute stored beyond + __init__'s keyword parameters raises TypeError on unpickle (unexpected + keyword argument); a keyword parameter __init__ doesn't assign to self + is silently dropped if it has a default (unpickle reverts to it) or + raises TypeError if it doesn't (missing required argument). """ def __reduce__(self) -> tuple[Any, ...]: