Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions architecture/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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__` 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.

## Security: request headers are reachable via `exc.response.request`
Expand Down
157 changes: 157 additions & 0 deletions planning/changes/2026-07-13.03-errors-keyword-reduce-mixin.md
Original file line number Diff line number Diff line change
@@ -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.
109 changes: 24 additions & 85 deletions src/httpware/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,27 @@ 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. 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, ...]:
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,
Expand All @@ -168,22 +179,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.
Expand All @@ -197,21 +194,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
Expand All @@ -229,20 +213,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
Expand All @@ -268,12 +240,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:
Expand All @@ -287,15 +253,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
Expand All @@ -311,21 +269,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
Expand Down Expand Up @@ -362,9 +307,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),
)