Skip to content

Releases: modern-python/httpware

0.11.0 — deep-audit hardening

14 Jun 14:38
c27c163

Choose a tag to compare

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 ResponseTooLargeError

AsyncClient / 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, and StatusError messages/repr are 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 with REDACTED. 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_bytes set, stream() raises
    the new ResponseTooLargeError on a 4xx/5xx whose declared Content-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=True proxy inheritance and the
    fact that exc.response.request exposes Authorization/Cookie headers are
    now called out in architecture/.

Correctness fixes

  • RetryBudget no longer spends a token on a Retry-After give-up. When a
    server's Retry-After exceeds max_delay, the give-up check now runs before
    budget.try_withdraw(), so a Retry-After flood can't drain shared-budget
    capacity for unrelated requests (sync and async).
  • Hostile headers no longer crash the retry loop. A Retry-After of a few
    hundred digits raised OverflowError; _parse_retry_after now degrades it
    to "no hint". full_jitter_delay likewise no longer raises at very large
    attempt indices — it clamps to max_delay as documented.
  • Decoder/extras robustness. The pydantic import is fully guarded so the
    decoder module loads without the extra (the friendly ImportError is the
    real fail-fast path), and msgspec's internal type walk raises a friendly
    ImportError rather than NameError when the extra is absent.
  • AsyncClient streaming-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:///path for the
    credentials-but-no-host edge case.

Public API

  • httpware.middleware now defines __all__, so from httpware.middleware import * no longer leaks httpx2, typing helpers, or submodules.

Quality

  • Test suite hardened: closed coverage gaps (sync terminal mapping, the
    CookieConflict branch, TimeoutError tripping the circuit breaker, the
    three never-constructed status errors), added sync/async parity mirrors and
    Client overload typing tests, replaced a flaky time.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

13 Jun 12:42
dfd6f55

Choose a tag to compare

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

  • AsyncTimeout now rejects non-finite values. AsyncTimeout(timeout=float("nan")) and float("inf") were silently accepted — timeout <= 0 is False for 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_event previously called OpenTelemetry's span.add_event(...) unguarded; a recording span whose add_event raised 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 event field 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.md observability section now lists all four middleware loggers (httpware.retry, httpware.bulkhead, httpware.circuit_breaker, httpware.timeout) and their events with levels.
  • CircuitOpenError added to the resilience-refusal error lists in README.md and docs/index.md.
  • README logging example now sets httpware.circuit_breaker to INFO so the circuit.half_open / circuit.closed recovery events are visible.
  • Corrected the AsyncRetry timeout-knob note (now points to AsyncTimeout), the OPEN-state wording (the first request after reset_timeout becomes 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

13 Jun 10:55
2a2b541

Choose a tag to compare

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 CircuitOpenError

AsyncCircuitBreaker / 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

13 Jun 08:56
6d73b43

Choose a tag to compare

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

10 Jun 08:49
b0b2bca

Choose a tag to compare

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. BaseModel response models route to pydantic, Struct to msgspec, and shared shapes (dict, list[Foo], dataclasses, primitives) route to the first decoder in the list.
  • Type-dispatched routing via can_decode. ResponseDecoder Protocol gains can_decode(model: type) -> bool. The client walks decoders in order and picks the first claimer. Built-in decoders claim broadly within their library; native types of the other library are rejected (pydantic rejects msgspec.Struct; msgspec rejects pydantic.BaseModel via msgspec.inspect.type_info + CustomType filter).
  • MissingDecoderError under ClientError, exported from httpware. Carries model: type and registered_names: tuple[str, ...]. Fires before the HTTP call when response_model= is set but no registered decoder claims it — distinct corrective action from DecodeError (decoder ran, payload bad).
  • Lazy default policy. AsyncClient() / Client() no longer raise ImportError when pydantic is missing. The default decoders=None resolves against is_pydantic_installed / is_msgspec_installed at __init__ time; if neither extra is installed, the default is () and the client works fine for all paths that don't use response_model=.
  • Per-instance decoder caches. Internal refactor: TypeAdapter and msgspec.json.Decoder caches now live on the decoder instance (_adapters / _msgspec_decoders dicts) 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_adapter and httpware.decoders.msgspec._get_msgspec_decoder module-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_decoder and _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

0.8.6 — test mop-up

08 Jun 17:58
8cf01be

Choose a tag to compare

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_drain is behavioral, not internals-peek. The Hypothesis property test for AsyncBulkhead no longer asserts against bulkhead._sem._value; it submits max_concurrent fresh acquires after drain and confirms they all succeed under a tight acquire_timeout.
  • test_threading_with_shared_budget asserts the exact deposit count. The previous len(budget._deposits) > 0 was 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.py covers the sync Client escape hatch. The async test pinned AsyncClient(decoder=fake) bypassing the pydantic fail-fast; the sync Client had no peer. Now it does. _FakeDecoder hoisted to module top to keep the two tests DRY.
  • Sync Bulkhead has Hypothesis property tests. New file tests/test_bulkhead_sync_props.py mirrors tests/test_bulkhead_props.py using threading.Thread + ThreadPoolExecutor instead of asyncio.gather. Three properties: in-flight never exceeds cap; fail-fast rejects at capacity; no slot leak after drain.
  • Sync on_error BaseException propagation is tested. Two new tests in test_middleware_sync.py pin the invariant that the sync on_error decorator's except Exception clause does NOT catch KeyboardInterrupt or SystemExit — both must propagate through compose.

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

08 Jun 13:06
a47e2be

Choose a tag to compare

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) and typing.get_type_hints(compose) now resolve cleanly. The AsyncMiddleware / Middleware imports moved out of the if typing.TYPE_CHECKING: guard in httpware/middleware/chain.py; runtime introspection of the chain-composition signatures works. No behavior change for users not calling get_type_hints.

  • PydanticDecoder no longer has a NameError window on test-reload. httpware/decoders/pydantic.py now imports pydantic.TypeAdapter unconditionally at module top. The optional-extras gate is enforced upstream by client.py:_default_pydantic_decoder(), so loading this module without pydantic was already not a real-world path. The previous conditional import left TypeAdapter undefined when the install flag was patched off, raising NameError instead of the documented ImportError if anyone reloaded the module under the flag patch.

  • LoggingMiddleware example in docs/middleware.md uses logging, not print(). CLAUDE.md lists "No print()" as a non-negotiable invariant; copying the example into a user's project would have failed their own ruff check. The new snippet mirrors the RequestIdMiddleware style further down the same file.

  • Public-API test catches bogus __all__ entries. test_expected_exports previously checked only expected - 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

08 Jun 11:21
b95f357

Choose a tag to compare

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:

  1. opentelemetry is a PEP 420 native namespace package. Any opentelemetry-instrumentation-* package creates the opentelemetry/ directory, so find_spec("opentelemetry") returns a non-None spec even when opentelemetry-api is absent.
  2. The lazy from opentelemetry import trace inside _emit_event was not wrapped in try/except. With the false-positive flag from (1), the import then raised ImportError mid-emit, crashing the middleware calling _emit_eventAsyncRetry, 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_installed now probes via importlib.metadata.distribution("opentelemetry-api") (inside a try/except PackageNotFoundError block). This checks the package registry directly: True only when the opentelemetry-api distribution is actually installed, regardless of whether some other package created the opentelemetry/ 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 by tests/test_optional_extras_isolation.py. The metadata probe has no sys.modules side effects.
  • _emit_event wraps the lazy from opentelemetry import trace in try/except ImportError. On failure (corrupt install, future namespace surprise, monkey-patched sys.modules), emission degrades to log-only — the structured log record fires unconditionally; the OTel add_event call 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

08 Jun 09:00
171d893

Choose a tag to compare

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

  • RetryBudget deposits once per request, not once per attempt. Tighter retry pacing under load — matches the documented Finagle contract.
  • RetryBudget ceiling uses math.ceil, not int(...) truncation. No more silent off-by-one against the configured percent_can_retry.
  • Retry-After > max_delay now raises the underlying StatusError with a PEP 678 note rather than silently capping the sleep at max_delay (and retrying into the same error).
  • RuntimeError → TransportError mapping now keys on httpx2.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 RetryBudget Hypothesis property test (tests/test_budget_props.py) used to compute its expected ceiling with the same int(...) formula as production, so it couldn't detect the off-by-one. Now uses math.ceil and 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

08 Jun 06:30
f884a26

Choose a tag to compare

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 = None

When 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 custom Request (e.g., needed build_request flexibility).
  • client.send_with_response(request, response_model=M) — both, atomically. New.
  • client.stream(...) — streaming responses. send_with_response is not for streaming; it decodes response.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 inside send_with_response. No new fields.
  • Docs: docs/index.md gains a Response metadata + typed body subsection with the pagination example above; planning/engineering.md Seam B contract now names send_with_response alongside send.

Nothing else changed in this release.

See also