Releases: modern-python/httpware
0.11.0 — deep-audit hardening
Minor release. Additive only — no breaking changes.
This release ships the full remediation of the 2026-06-14 full-codebase deep
audit: 35 confirmed findings closed across security, correctness, public API,
test quality, and documentation.
New public names
from httpware import ResponseTooLargeErrorAsyncClient / Client gain an opt-in max_error_body_bytes: int | None = None.
Security hardening
- URL secret redaction. Request URLs that reach logs, OpenTelemetry span
events, andStatusErrormessages/reprare now sanitized:user:pass@
userinfo is stripped and the values of known-sensitive query/fragment
parameters (api_key,access_token,token,secret,password, …) are
masked withREDACTED. Redaction is centralized at the_emit_event
emission boundary, so every resilience event is covered and a future event
cannot reintroduce the leak. Non-secret URLs are byte-identical to before. - Bounded error bodies. With
max_error_body_bytesset,stream()raises
the newResponseTooLargeErroron a 4xx/5xx whose declaredContent-Length
exceeds the cap, before reading the body. Default (None) is unchanged.
A chunked error body with no declared length is still read — a hard mid-read
cap would require httpx2 private API; that deeper bound is tracked as
deferred work. - Documented trust boundaries.
trust_env=Trueproxy inheritance and the
fact thatexc.response.requestexposesAuthorization/Cookieheaders are
now called out inarchitecture/.
Correctness fixes
- RetryBudget no longer spends a token on a
Retry-Aftergive-up. When a
server'sRetry-Afterexceedsmax_delay, the give-up check now runs before
budget.try_withdraw(), so aRetry-Afterflood can't drain shared-budget
capacity for unrelated requests (sync and async). - Hostile headers no longer crash the retry loop. A
Retry-Afterof a few
hundred digits raisedOverflowError;_parse_retry_afternow degrades it
to "no hint".full_jitter_delaylikewise no longer raises at very large
attempt indices — it clamps tomax_delayas documented. - Decoder/extras robustness. The pydantic import is fully guarded so the
decoder module loads without the extra (the friendlyImportErroris the
real fail-fast path), andmsgspec's internal type walk raises a friendly
ImportErrorrather thanNameErrorwhen the extra is absent. AsyncClientstreaming-body detection now recognizes non-replayable sync
iterables explicitly instead of relying on an undocumented httpx2 detail.- URL sanitizer no longer emits a malformed
http:///pathfor the
credentials-but-no-host edge case.
Public API
httpware.middlewarenow defines__all__, sofrom httpware.middleware import *no longer leakshttpx2, typing helpers, or submodules.
Quality
- Test suite hardened: closed coverage gaps (sync terminal mapping, the
CookieConflictbranch,TimeoutErrortripping the circuit breaker, the
three never-constructed status errors), added sync/async parity mirrors and
Clientoverload typing tests, replaced a flakytime.sleep-based bulkhead
test with a deterministic barrier, and pinned a budget-test clock. - Documentation accuracy fixes across
architecture/and the docs site.
Shipped via
PRs #62 (pydantic isolation), #63 (security cluster), #64 (correctness +
public API), #65 (test quality), #66 (docs). See
planning/audits/2026-06-14-deep-audit.md
for the full audit.
0.10.1 — delta-audit closure
httpware 0.10.1 — 0.10.0 delta-audit closure
Patch release. Bug fixes + hardening from the 0.10.0 delta audit. No breaking changes (one additive observability field; see below).
Audit report: planning/audit/2026-06-13-delta-audit.md.
Fixes
AsyncTimeoutnow rejects non-finite values.AsyncTimeout(timeout=float("nan"))andfloat("inf")were silently accepted —timeout <= 0isFalsefor both under IEEE-754 — producing a nondeterministic (nan) or never-firing (inf) deadline. The constructor now requires a finite positive number.- Observability failures can no longer break the request path.
_emit_eventpreviously called OpenTelemetry'sspan.add_event(...)unguarded; a recording span whoseadd_eventraised would propagate into the caller. Because the circuit breaker emits inside its state transitions, this could strand the circuit permanently in HALF_OPEN. The OTel emission now degrades silently on failure (the structured log record has already fired), at the root for all middleware.
Observability (additive)
- Each event log record now carries an
eventfield holding the event-name string (e.g.event="circuit.opened"). Previously the event name reached only OpenTelemetry span events; it is now also a first-class, filterable field on the stdlib log record. Purely additive — no existing attribute changed.
Docs
docs/index.mdobservability section now lists all four middleware loggers (httpware.retry,httpware.bulkhead,httpware.circuit_breaker,httpware.timeout) and their events with levels.CircuitOpenErroradded to the resilience-refusal error lists inREADME.mdanddocs/index.md.- README logging example now sets
httpware.circuit_breakertoINFOso thecircuit.half_open/circuit.closedrecovery events are visible. - Corrected the AsyncRetry timeout-knob note (now points to
AsyncTimeout), the OPEN-state wording (the first request afterreset_timeoutbecomes the probe), and the observability/breaker docstrings.
Tests
Hardened to assert the stable event-name strings, the exact retry_after value, the 429-resets-the-failure-streak path, success_threshold > 1 with a mid-streak probe failure, and reset_timeout=0 / empty failure_status_codes boundaries. No production behavior change from the test work.
0.10.0 — circuit breaker + async timeout
httpware 0.10.0 — circuit breaker + async timeout
Minor release. Additive only — no breaking changes.
New public names
from httpware.middleware.resilience import AsyncCircuitBreaker # async
from httpware.middleware.resilience import CircuitBreaker # sync
from httpware.middleware.resilience import AsyncTimeout
from httpware import CircuitOpenErrorAsyncCircuitBreaker / CircuitBreaker
Classic consecutive-failure circuit breaker. Counts counted failures (5xx, NetworkError, TimeoutError) and fast-fails with CircuitOpenError once failure_threshold consecutive failures are observed. Recovers via a HALF_OPEN probe after reset_timeout seconds; closes when success_threshold consecutive probe successes are seen.
4xx responses — including 429 — count as successes. A 429 means healthy-but-throttling; tripping the circuit on it would amplify incidents.
CircuitOpenError (a ClientError subclass) carries retry_after: float | None — the seconds until the next probe window (None when HALF_OPEN with a probe already in flight).
Sharable across multiple clients (one shared circuit). A sync CircuitBreaker cannot be shared with an AsyncCircuitBreaker.
AsyncTimeout
Bounds total wall-clock across the inner pipeline — including retries and backoff sleeps. Raises httpware.TimeoutError on expiry. Async-only: sync Python has no cancellation primitive that can interrupt a blocking call mid-flight.
New observability events
| Logger | Event | When |
|---|---|---|
httpware.circuit_breaker |
circuit.opened |
Failure threshold reached |
httpware.circuit_breaker |
circuit.rejected |
Request fast-failed (OPEN or HALF_OPEN probe taken) |
httpware.circuit_breaker |
circuit.half_open |
Reset timeout elapsed; probe admitted |
httpware.circuit_breaker |
circuit.closed |
Success threshold reached; service recovered |
httpware.timeout |
timeout.exceeded |
Overall timeout expired |
Recommended chain ordering
AsyncTimeout → AsyncCircuitBreaker → AsyncBulkhead → AsyncRetry → terminal
Breaker outside retry: an open circuit short-circuits the whole retry loop; the breaker counts one outcome per fully-exhausted retry sequence.
0.9.1 — nested-CustomType fix + can_decode memoization
httpware 0.9.1 — decoder dispatch: nested-CustomType fix + can_decode memoization
Patch release bundling two decoder-dispatch fixes — one correctness, one performance. No public-API change: can_decode's signature, the ResponseDecoder protocol, and every routing verdict are unchanged.
1. MsgspecDecoder stops claiming containers it can't decode (correctness)
One behavior change. When MsgspecDecoder is the only decoder registered (an msgspec-only install, or an explicit decoders=[MsgspecDecoder()]), a response_model= of list[SomePydanticModel], dict[str, SomePydanticModel], SomePydanticModel | None, or any container parameterized by a type msgspec can't natively decode now raises MissingDecoderError before a request is sent — instead of sending the request and failing at decode with DecodeError.
The gap
MsgspecDecoder.can_decode answers the client's pre-flight question "can you decode this type?" — and on a False from every registered decoder, the client raises MissingDecoderError without touching the network. msgspec builds a json.Decoder for almost any type via a generic CustomType fallback, so can_decode used msgspec.inspect.type_info to detect and reject that fallback. But it inspected only the top-level node: type_info(list[PUser]) is a ListType whose item_type is the CustomType, so the top-level check passed, the decoder built, and can_decode returned True. The pre-flight was bypassed, a real HTTP request went out, and decode then raised a validation error (surfaced as DecodeError). The false-positive was cached per instance, so every later request of that shape repeated the wasted round-trip.
Under the default pydantic-first decoders=[PydanticDecoder(), MsgspecDecoder()], this was masked — pydantic claims list[PUser] first. The bug only bit msgspec-only configurations.
The fix
can_decode now walks the full type_info tree and rejects if a CustomType appears anywhere in it, via a recursive helper that visits every nested element type (list/dict/set/tuple/Optional/Union, arbitrarily nested). The walk stops at Struct/dataclass field boundaries automatically, so genuine msgspec targets like list[SomeStruct] stay accepted and self-referential structs can't loop. Only the set of types MsgspecDecoder claims is corrected.
2. can_decode verdicts are memoized (performance)
No behavior change — a performance fix. Both MsgspecDecoder.can_decode and PydanticDecoder.can_decode now cache their per-model verdict, so the type probe runs once per response_model type instead of on every request.
The gap
The client consults decoder.can_decode(model) on every dispatch to route a response_model= to the right decoder. The recursive type_info walk added in fix #1 above is not free — roughly 30 µs per call for a type like list[SomeStruct] (an uncached msgspec.inspect.type_info() plus the tree walk). PydanticDecoder.can_decode had the milder version of the same shape: it re-probed TypeAdapter construction every call and never cached a rejection. Since a client decodes the same handful of model types over and over, that cost was paid on every single request.
The fix
Both decoders gain a per-instance dict[type, bool] verdict cache. can_decode returns the memoized result when present and only runs the probe on the first sighting of a model type. Repeat calls drop from ~30 µs to ~0.15 µs (msgspec) / ~0.07 µs (pydantic) — a ~200× reduction on the hot path. Unhashable models skip the cache and probe fresh, preserving the existing fallback behavior. The cache is bounded by the set of response_model types an application actually uses, mirroring the existing per-instance decoder/adapter caches.
0.9.0 — multi-decoder routing
httpware 0.9.0 — multi-decoder routing
Breaking release. Replaces the single-decoder slot on AsyncClient/Client with a type-dispatched decoders=[...] list. Reverses the 0.3.0 fail-fast for missing pydantic — AsyncClient() no longer raises on missing extras; failure is deferred to the first response_model= use site via the new MissingDecoderError (fires before the HTTP call).
If you currently pass decoder=PydanticDecoder() or rely on the old "pydantic must be installed for AsyncClient()" behavior, migration is one mechanical pass — see "Migration" below.
What's new
- Mixed pydantic + msgspec models in one client.
AsyncClient(decoders=[PydanticDecoder(), MsgspecDecoder()])is the new default when both extras are installed.BaseModelresponse models route to pydantic,Structto msgspec, and shared shapes (dict,list[Foo], dataclasses, primitives) route to the first decoder in the list. - Type-dispatched routing via
can_decode.ResponseDecoderProtocol gainscan_decode(model: type) -> bool. The client walksdecodersin order and picks the first claimer. Built-in decoders claim broadly within their library; native types of the other library are rejected (pydantic rejectsmsgspec.Struct; msgspec rejectspydantic.BaseModelviamsgspec.inspect.type_info+CustomTypefilter). MissingDecoderErrorunderClientError, exported fromhttpware. Carriesmodel: typeandregistered_names: tuple[str, ...]. Fires before the HTTP call whenresponse_model=is set but no registered decoder claims it — distinct corrective action fromDecodeError(decoder ran, payload bad).- Lazy default policy.
AsyncClient()/Client()no longer raiseImportErrorwhen pydantic is missing. The defaultdecoders=Noneresolves againstis_pydantic_installed/is_msgspec_installedat__init__time; if neither extra is installed, the default is()and the client works fine for all paths that don't useresponse_model=. - Per-instance decoder caches. Internal refactor:
TypeAdapterandmsgspec.json.Decodercaches now live on the decoder instance (_adapters/_msgspec_decodersdicts) rather than module-level@functools.lru_cache. Cache lifetime matches the decoder/client. No user-visible change.
Breaking changes
Renames
| Old | New |
|---|---|
AsyncClient(decoder=...) |
AsyncClient(decoders=[...]) |
Client(decoder=...) |
Client(decoders=[...]) |
The old decoder= kwarg raises TypeError: unexpected keyword argument 'decoder' at construction. The error is at construction time, so any 0.8.x → 0.9.0 upgrade trips it immediately rather than at first request.
ResponseDecoder Protocol
Custom ResponseDecoder implementations must add can_decode(model: type) -> bool. For a catch-all decoder, the trivial migration is def can_decode(self, model): return True. Decoders that should only claim specific model types should implement the predicate to return True only for those.
Behavioral reversal
AsyncClient() / Client() constructed without decoders= no longer raise ImportError when pydantic is missing. The 0.3.0 fail-fast (introduced when pydantic moved to an optional extra) is gone — failure now surfaces only when response_model= is used and no registered decoder claims it.
Users who relied on the eager ImportError for container-image validation should add an explicit smoke check, e.g.:
from httpware._internal import import_checker
assert import_checker.is_pydantic_installed, "pydantic extra missing"Removals
httpware.decoders.pydantic._get_adapterandhttpware.decoders.msgspec._get_msgspec_decodermodule-level functions — replaced with instance methods on the decoder classes. These were_-prefixed (private), so unless you were patching them in tests, no migration needed.httpware.client._default_pydantic_decoderand_DEFAULT_DECODER_MISSING_MESSAGE— both_-prefixed; no migration needed.
Migration
decoder= callers
# in your project root:
git ls-files '*.py' | xargs sed -i.bak \
-e 's/AsyncClient(decoder=/AsyncClient(decoders=[/g' \
-e 's/Client(decoder=/Client(decoders=[/g'Then walk the diff and close the brackets () → ])) wherever the kwarg was the only argument. For multi-argument calls, the regex catches the rename and you adjust the closing bracket by hand. Your type checker / first failing test will surface anything left.
Custom ResponseDecoder callers
If you have your own ResponseDecoder implementation, add can_decode. The trivial migration:
class MyDecoder:
def can_decode(self, model: type) -> bool:
return True # claim everything; existing behavior preserved
def decode(self, content: bytes, model: type) -> object:
...If your decoder is specialized to certain model types, gate can_decode accordingly so it doesn't claim models it can't actually handle — otherwise the dispatcher will route to your decoder and you'll raise at decode() time, wrapped as DecodeError. The clean shape is for can_decode to reject what you can't handle, letting another decoder in the list try.
Test-suite patches
If your tests patch httpware.decoders.pydantic._get_adapter or httpware.decoders.msgspec._get_msgspec_decoder (the module-level functions), retarget to the instance methods:
# was:
with patch("httpware.decoders.pydantic._get_adapter", side_effect=TypeError):
...
# now:
with patch.object(PydanticDecoder, "_get_adapter", side_effect=TypeError):
...References
- Design spec:
planning/specs/2026-06-09-multi-decoder-design.md - Implementation plan:
planning/plans/2026-06-09-multi-decoder-plan.md - Cache-refactor spec:
planning/specs/2026-06-10-decoder-instance-cache-design.md - Cache-refactor plan:
planning/plans/2026-06-10-decoder-instance-cache-plan.md - Engineering notes:
planning/engineering.md§3 Seam B - PRs: #41, #42
0.8.6 — test mop-up
httpware 0.8.6 — test mop-up
Patch release. Test-only changes. No API change, no production code change, no behavior change for users. Closes 5 audit findings — all in the test suite.
What changed
test_no_slot_leak_after_drainis behavioral, not internals-peek. The Hypothesis property test forAsyncBulkheadno longer asserts againstbulkhead._sem._value; it submitsmax_concurrentfresh acquires after drain and confirms they all succeed under a tightacquire_timeout.test_threading_with_shared_budgetasserts the exact deposit count. The previouslen(budget._deposits) > 0was a smoke check that would have passed under deque corruption with even one survivor. The new assertion locks the count to(N_SYNC_THREADS * N_OPS_PER_THREAD) + N_ASYNC_TASKS= 220, made deterministic by the 0.8.3 deposit-hoist.test_optional_extras_pydantic_missing.pycovers the syncClientescape hatch. The async test pinnedAsyncClient(decoder=fake)bypassing the pydantic fail-fast; the syncClienthad no peer. Now it does._FakeDecoderhoisted to module top to keep the two tests DRY.- Sync
Bulkheadhas Hypothesis property tests. New filetests/test_bulkhead_sync_props.pymirrorstests/test_bulkhead_props.pyusingthreading.Thread+ThreadPoolExecutorinstead ofasyncio.gather. Three properties: in-flight never exceeds cap; fail-fast rejects at capacity; no slot leak after drain. - Sync
on_errorBaseExceptionpropagation is tested. Two new tests intest_middleware_sync.pypin the invariant that the syncon_errordecorator'sexcept Exceptionclause does NOT catchKeyboardInterruptorSystemExit— both must propagate throughcompose.
Audit status
35 audit findings, all addressed across 0.8.1 → 0.8.6 plus the in-place doc-staleness sweep on main. One chunk-3 hand-review item was excluded as INVALID (the audit looked for AsyncClient construction tests in tests/test_client_methods.py — they actually live in tests/test_client_construction.py + tests/test_client_lifecycle.py).
Upgrade
uv add httpware==0.8.6
# or
pip install -U 'httpware==0.8.6'No import changes. No API changes. Nothing observable from the consumer side.
0.8.5 — small fixes mop-up
httpware 0.8.5 — small fixes mop-up
Patch release. Four small unrelated fixes. No API change, no user-visible behavior change on the happy path. Closes 4 of the remaining audit findings — two Low (chain.py + pydantic.py), two Nit (LoggingMiddleware docs + public-API test).
What changed
-
typing.get_type_hints(compose_async)andtyping.get_type_hints(compose)now resolve cleanly. TheAsyncMiddleware/Middlewareimports moved out of theif typing.TYPE_CHECKING:guard inhttpware/middleware/chain.py; runtime introspection of the chain-composition signatures works. No behavior change for users not callingget_type_hints. -
PydanticDecoderno longer has a NameError window on test-reload.httpware/decoders/pydantic.pynow importspydantic.TypeAdapterunconditionally at module top. The optional-extras gate is enforced upstream byclient.py:_default_pydantic_decoder(), so loading this module without pydantic was already not a real-world path. The previous conditional import leftTypeAdapterundefined when the install flag was patched off, raisingNameErrorinstead of the documentedImportErrorif anyone reloaded the module under the flag patch. -
LoggingMiddlewareexample indocs/middleware.mduseslogging, notprint(). CLAUDE.md lists "Noprint()" as a non-negotiable invariant; copying the example into a user's project would have failed their own ruff check. The new snippet mirrors theRequestIdMiddlewarestyle further down the same file. -
Public-API test catches bogus
__all__entries.test_expected_exportspreviously checked onlyexpected - set(__all__); now it asserts set equality so a symbol added to__all__without a peer update to the expected set is also caught.
Upgrade
uv add httpware==0.8.5
# or
pip install -U 'httpware==0.8.5'No import changes. No API changes. The only behavior change is that from httpware.decoders.pydantic import PydanticDecoder now fails with a real ImportError at import time when pydantic isn't installed (instead of succeeding-then-failing-at-construct). The audit finding documented that the previous behavior was unreachable in practice — the upstream fail-fast at _default_pydantic_decoder() is the real safety net.
0.8.4 — OTel partial-install hardening
httpware 0.8.4 — OTel partial-install no longer crashes a live request
Patch release. Defensive fix. No API change. Closes the two paired audit findings tracking the OpenTelemetry partial-install hazard.
The gap
httpware's observability layer treats opentelemetry-api as an optional extra. It detects whether the extra is installed via find_spec("opentelemetry") at module load time, then takes the OTel branch in _emit_event only if the flag is True.
Two flaws in that gate let a partial install crash a live request:
opentelemetryis a PEP 420 native namespace package. Anyopentelemetry-instrumentation-*package creates theopentelemetry/directory, sofind_spec("opentelemetry")returns a non-None spec even whenopentelemetry-apiis absent.- The lazy
from opentelemetry import traceinside_emit_eventwas not wrapped intry/except. With the false-positive flag from (1), the import then raisedImportErrormid-emit, crashing the middleware calling_emit_event—AsyncRetry,Retry,AsyncBulkhead,Bulkhead— in the middle of a live HTTP request.
The audit's chunk-2 finding named both halves of the hole; this release closes both.
The fix
Two changes:
import_checker.is_otel_installednow probes viaimportlib.metadata.distribution("opentelemetry-api")(inside a try/exceptPackageNotFoundErrorblock). This checks the package registry directly: True only when theopentelemetry-apidistribution is actually installed, regardless of whether some other package created theopentelemetry/namespace directory. Note: the obvious alternative —find_spec("opentelemetry.trace")— was rejected because CPython resolves submodule probes by importing the parent namespace package, which would have broken the existing transitive-import isolation guarantee enforced bytests/test_optional_extras_isolation.py. The metadata probe has nosys.modulesside effects._emit_eventwraps the lazyfrom opentelemetry import traceintry/except ImportError. On failure (corrupt install, future namespace surprise, monkey-patchedsys.modules), emission degrades to log-only — the structured log record fires unconditionally; the OTeladd_eventcall is skipped.
We catch ImportError specifically, not bare Exception. Misconfigured-tracer crashes (RuntimeError, AttributeError out of trace.get_current_span().add_event(...)) still surface; only the install-gate-is-wrong case is in scope.
Upgrade
uv add httpware==0.8.4
# or
pip install -U 'httpware==0.8.4'No import changes. No API surface changes. No behavior change on the happy path (api package installed and importable). The only observable change is "no longer crashes" on partial installs.
0.8.3 — RetryBudget cluster + retry/client robustness
httpware 0.8.3 — RetryBudget cluster + retry/client robustness
Patch release with three behavioral changes you should know about. All driven by the deep audit; collectively close 7 audit findings (3 RetryBudget, 2 retry-surface nits, 2 chunk-3 test rewrites).
TL;DR
RetryBudgetdeposits once per request, not once per attempt. Tighter retry pacing under load — matches the documented Finagle contract.RetryBudgetceiling usesmath.ceil, notint(...)truncation. No more silent off-by-one against the configuredpercent_can_retry.Retry-After > max_delaynow raises the underlyingStatusErrorwith a PEP 678 note rather than silently capping the sleep atmax_delay(and retrying into the same error).RuntimeError → TransportErrormapping now keys onhttpx2.Client.is_closed, not substring-matching "closed" in the exception message.- Streaming-body refusal note is now scoped to where streaming is actually the blocker (not attached to method-ineligible refusals).
The behavioral changes
RetryBudget.deposit() per request, not per attempt
The Finagle retry-budget contract is withdrawals / deposits <= percent_can_retry where the denominator counts original requests. AsyncRetry and Retry previously deposited a token inside the per-attempt loop, so a request that retried twice contributed three deposits and two withdrawals — inflating the ratio by (attempts-1)/attempts and letting through more retries than percent_can_retry allowed.
Now deposit() is hoisted above the attempt loop and runs exactly once per __call__. Users with active retry traffic will see the budget refuse retries earlier than before. This is the documented contract; the previous behavior was the bug.
If you were tuning percent_can_retry against the pre-0.8.3 behavior, re-validate your target retry rate.
RetryBudget ceiling: math.ceil instead of int(...)
try_withdraw's ceiling computed int(deposits * percent) + floor, truncating fractional values. For deposits=4 and percent_can_retry=0.2, the term was int(0.8) = 0 — with a floor=0, no retries were permitted even though the configured percentage says the first retry should be allowed at 5 deposits.
math.ceil makes the threshold honor the configured percentage at the first deposit-count where it is mathematically expressible. The previous behavior was strictly under-permissive; users with min_retries_per_sec > 0 were insulated by the floor, but min_retries_per_sec=0.0 configurations saw the off-by-one.
Retry-After > max_delay raises instead of silently capping
Previously when a server sent Retry-After: 120 and the client had max_delay=5.0, AsyncRetry/Retry clamped to 5s and retried — almost certainly hitting the same 503 or 429 and burning an attempt while violating the server's hint.
Now: when the parsed Retry-After exceeds max_delay, AsyncRetry/Retry re-raises the underlying StatusError (e.g. ServiceUnavailableError) with a PEP 678 note:
httpware: Retry-After (120s) exceeded max_delay (5.0s); giving up
If you want to keep retrying despite the gap, raise max_delay to accommodate the server's hint, or set respect_retry_after=False to drop back to jittered backoff.
RuntimeError → TransportError via is_closed
Both AsyncClient._terminal and Client._terminal mapped RuntimeError to TransportError by substring-matching "closed" in str(exc). Two failure modes: any unrelated RuntimeError whose message happened to contain "closed" was mis-classified as TransportError; conversely, an httpx2 wording change (e.g. "shut down") would silently break the mapping.
Now the check is self._httpx2_client.is_closed — message-independent. Same attribute already used elsewhere in client.py for borrowed-client teardown guards.
Streaming-body refusal note scoped correctly
The early-out branch for method ineligibility OR non-retryable status also attached the streaming-body refusal note whenever retryable_status and STREAMING_BODY_MARKER — misleadingly suggesting the stream was the blocker when the actual reason was method exclusion (e.g. POST not in retry_methods).
The note now fires only at the dedicated streaming-refusal site, where streaming IS the blocker. The diagnostic is precise instead of misleading.
Fixes that aren't user-visible
- The
RetryBudgetHypothesis property test (tests/test_budget_props.py) used to compute its expected ceiling with the sameint(...)formula as production, so it couldn't detect the off-by-one. Now usesmath.ceiland asserts equality. - A new property test on
RetryBudget(tests/test_retry_props.py::test_budget_exhaustion_is_reachable_and_deterministic) exercises the budget-exhaustion path that the existing retry property tests left uncovered.
Audit findings closed
7 of the 35 audit findings from planning/audit/2026-06-07-deep-audit.md — the entire RetryBudget cross-cutting cluster plus 2 adjacent retry-surface nits.
Upgrade
uv add httpware==0.8.3
# or
pip install -U 'httpware==0.8.3'No import changes; no API surface changes; constructor signatures unchanged.
0.8.2 — send_with_response for atomic (response, decoded) pair
httpware 0.8.2 — send_with_response for atomic (response, decoded) pair
Patch release with one additive method. No deprecations, no behavior changes on existing surfaces. Code that uses send(..., response_model=) keeps working exactly as before.
The gap
The existing client.send(request, response_model=M) and verb-method overloads (client.get(url, response_model=M), etc.) decode the body and discard the httpx2.Response. That's the right default for most callers — typed body in, raw response forgotten. But a real class of callers needs both the decoded body and the raw response, atomically: status, headers, and response.request.url. The canonical case is RFC 5988 Link header pagination, where the body deserializes into a page model and the Link header drives the next request.
Before 0.8.2 those callers had to drop down to client.send(request) and re-decode by hand. That bypassed the configured ResponseDecoder (pydantic vs. msgspec swappability went to waste) and re-opened the same exception-leak hole DecodeError closed in 0.8.1 — except httpware.ClientError no longer caught the decode failure because the decode happened outside the seam.
The fix
New method on both client classes:
def send_with_response(
self,
request: httpx2.Request,
*,
response_model: type[T],
) -> tuple[httpx2.Response, T]: ...Routes the request through the middleware chain via _dispatch, decodes via the active ResponseDecoder, returns both values. Decoder failures wrap as DecodeError exactly the way they do in send(..., response_model=) — except httpware.ClientError catches every failure mode.
Canonical use case — Link header pagination
from httpware import AsyncClient
from pydantic import BaseModel
class Tag(BaseModel):
name: str
async def main() -> None:
async with AsyncClient(base_url="https://gitlab.example/api/v4") as client:
url = "/projects/1/repository/tags"
params: dict[str, str] | None = {"per_page": "100", "page": "1"}
while url:
request = client.build_request("GET", url, params=params)
response, tags = await client.send_with_response(request, response_model=list[Tag])
for tag in tags:
process(tag) # caller-defined
url = next_link(response.headers.get("link")) # caller-defined parser
params = NoneWhen to use which
client.get(..., response_model=M)— body-only with a high-level verb.client.send(request, response_model=M)— body-only with a customRequest(e.g., neededbuild_requestflexibility).client.send_with_response(request, response_model=M)— both, atomically. New.client.stream(...)— streaming responses.send_with_responseis not for streaming; it decodesresponse.content, which requires the body to be fully read.
Migration
None. Additive — every existing call site keeps the same shape and return type.
Touched surface
httpware.AsyncClient.send_with_response— new.httpware.Client.send_with_response— new.httpware.DecodeError— reused as the failure-mode for decoder exceptions raised insidesend_with_response. No new fields.- Docs:
docs/index.mdgains aResponse metadata + typed bodysubsection with the pagination example above;planning/engineering.mdSeam B contract now namessend_with_responsealongsidesend.
Nothing else changed in this release.
See also
planning/specs/2026-06-08-send-with-response-design.md— design rationale, non-goals, why a separate method rather than an overload onsend.planning/plans/2026-06-08-send-with-response-plan.md— implementation plan.- PR #33.