Skip to content

Commit 700b6be

Browse files
lesnik512claude
andcommitted
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 <noreply@anthropic.com>
1 parent 289a9ad commit 700b6be

2 files changed

Lines changed: 25 additions & 85 deletions

File tree

architecture/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ The error-mapping table (what `httpx2` exception maps to which `httpware` except
1818

1919
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.
2020

21+
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.
22+
2123
`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.
2224

2325
## Security: request headers are reachable via `exc.response.request`

src/httpware/errors.py

Lines changed: 23 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,26 @@ class ServiceUnavailableError(ServerStatusError):
136136
}
137137

138138

139-
def _reconstruct_budget_exhausted(
140-
cls: "type[RetryBudgetExhaustedError]",
141-
last_response: httpx2.Response | None,
142-
last_exception: BaseException | None,
143-
attempts: int,
144-
) -> "RetryBudgetExhaustedError":
145-
return cls(last_response=last_response, last_exception=last_exception, attempts=attempts)
139+
def _reconstruct_kwonly(cls: type, kwargs: dict[str, Any]) -> Any: # noqa: ANN401
140+
return cls(**kwargs)
146141

147142

148-
class RetryBudgetExhaustedError(ClientError):
143+
class _KeywordReduceMixin:
144+
"""Shared __reduce__ for keyword-only ClientError subclasses.
145+
146+
For subclasses whose __init__ is keyword-only and whose instance
147+
__dict__ exactly mirrors it. Do not add this mixin to a class that
148+
stores any attribute beyond its __init__'s keyword parameters —
149+
reconstruction replays self.__dict__ as keyword arguments, so an
150+
extra/derived attribute would either be silently dropped or raise a
151+
TypeError on unpickle.
152+
"""
153+
154+
def __reduce__(self) -> tuple[Any, ...]:
155+
return (_reconstruct_kwonly, (type(self), self.__dict__))
156+
157+
158+
class RetryBudgetExhaustedError(_KeywordReduceMixin, ClientError):
149159
"""Raised when a retry was needed but the RetryBudget refused to permit it.
150160
151161
Carries the last response and/or exception observed before the budget refused,
@@ -168,22 +178,8 @@ def __init__(
168178
self.attempts = attempts
169179
super().__init__(f"retry budget exhausted after {attempts} attempt(s)")
170180

171-
def __reduce__(self) -> tuple[Any, ...]:
172-
return (
173-
_reconstruct_budget_exhausted,
174-
(type(self), self.last_response, self.last_exception, self.attempts),
175-
)
176-
177-
178-
def _reconstruct_bulkhead_full(
179-
cls: "type[BulkheadFullError]",
180-
max_concurrent: int,
181-
acquire_timeout: float | None,
182-
) -> "BulkheadFullError":
183-
return cls(max_concurrent=max_concurrent, acquire_timeout=acquire_timeout)
184181

185-
186-
class BulkheadFullError(ClientError):
182+
class BulkheadFullError(_KeywordReduceMixin, ClientError):
187183
"""Raised when ``acquire_timeout`` elapses before an AsyncBulkhead slot becomes available.
188184
189185
Carries the configured caps for caller logging/alerting.
@@ -197,21 +193,8 @@ def __init__(self, *, max_concurrent: int, acquire_timeout: float | None) -> Non
197193
self.acquire_timeout = acquire_timeout
198194
super().__init__(f"bulkhead full (max_concurrent={max_concurrent}, acquire_timeout={acquire_timeout})")
199195

200-
def __reduce__(self) -> tuple[Any, ...]:
201-
return (
202-
_reconstruct_bulkhead_full,
203-
(type(self), self.max_concurrent, self.acquire_timeout),
204-
)
205-
206-
207-
def _reconstruct_circuit_open(
208-
cls: "type[CircuitOpenError]",
209-
retry_after: float | None,
210-
) -> "CircuitOpenError":
211-
return cls(retry_after=retry_after)
212-
213196

214-
class CircuitOpenError(ClientError):
197+
class CircuitOpenError(_KeywordReduceMixin, ClientError):
215198
"""Raised when a CircuitBreaker refuses a request because the circuit is not closed.
216199
217200
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:
229212
else:
230213
super().__init__(f"circuit open (retry_after={retry_after:.3f}s)")
231214

232-
def __reduce__(self) -> tuple[Any, ...]:
233-
return (_reconstruct_circuit_open, (type(self), self.retry_after))
234-
235-
236-
def _reconstruct_decode_error(
237-
cls: "type[DecodeError]",
238-
response: httpx2.Response,
239-
model: type,
240-
original: BaseException,
241-
) -> "DecodeError":
242-
return cls(response=response, model=model, original=original)
243-
244215

245-
class DecodeError(ClientError):
216+
class DecodeError(_KeywordReduceMixin, ClientError):
246217
"""Raised when the active ResponseDecoder failed to decode response.content.
247218
248219
The HTTP call itself succeeded — status was 2xx/3xx and the transport
@@ -268,12 +239,6 @@ def __init__(
268239
self.original = original
269240
super().__init__(f"failed to decode response into {model.__name__}: {original}")
270241

271-
def __reduce__(self) -> tuple[Any, ...]:
272-
return (
273-
_reconstruct_decode_error,
274-
(type(self), self.response, self.model, self.original),
275-
)
276-
277242

278243
def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str:
279244
if not registered_names:
@@ -287,15 +252,7 @@ def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) ->
287252
return f"no decoder for response_model={model!r}: {hint}"
288253

289254

290-
def _reconstruct_missing_decoder(
291-
cls: "type[MissingDecoderError]",
292-
model: type,
293-
registered_names: tuple[str, ...],
294-
) -> "MissingDecoderError":
295-
return cls(model=model, registered_names=registered_names)
296-
297-
298-
class MissingDecoderError(ClientError):
255+
class MissingDecoderError(_KeywordReduceMixin, ClientError):
299256
"""Raised when response_model= is set but no registered decoder claims the model.
300257
301258
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:
311268
self.registered_names = registered_names
312269
super().__init__(_missing_decoder_summary(model, registered_names))
313270

314-
def __reduce__(self) -> tuple[Any, ...]:
315-
return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names))
316-
317-
318-
def _reconstruct_response_too_large(
319-
cls: "type[ResponseTooLargeError]",
320-
status_code: int,
321-
limit: int,
322-
content_length: int | None,
323-
reason: 'Literal["declared", "streamed"]',
324-
) -> "ResponseTooLargeError":
325-
return cls(status_code=status_code, limit=limit, content_length=content_length, reason=reason)
326271

327-
328-
class ResponseTooLargeError(ClientError):
272+
class ResponseTooLargeError(_KeywordReduceMixin, ClientError):
329273
"""Raised when a response body exceeds the client's max_response_body_bytes cap.
330274
331275
Status-agnostic: fires on any non-streaming send() and on stream()'s internal
@@ -362,9 +306,3 @@ def __init__(
362306
else:
363307
detail = f"decoded body exceeded max_response_body_bytes={limit}"
364308
super().__init__(f"response body too large: status={status_code} {detail}")
365-
366-
def __reduce__(self) -> tuple[Any, ...]:
367-
return (
368-
_reconstruct_response_too_large,
369-
(type(self), self.status_code, self.limit, self.content_length, self.reason),
370-
)

0 commit comments

Comments
 (0)