Releases: modern-python/httpware
0.14.0 — read-only circuit-breaker state introspection
httpware 0.14.0 — read-only circuit-breaker state introspection
Minor release. Additive only — no breaking changes.
This release adds a read-only state property and a public CircuitState enum
to both AsyncCircuitBreaker and CircuitBreaker, enabling health checks,
readiness probes, dashboards, and test assertions against the current circuit
state without any impact on circuit behavior.
New behavior
Both breakers now expose a state property that returns one of three values from
the new CircuitState enum (CLOSED, OPEN, HALF_OPEN). The enum is exported
from the top-level httpware package:
from httpware import CircuitState
from httpware.middleware.resilience import AsyncCircuitBreaker
breaker = AsyncCircuitBreaker(failure_threshold=5)
# In a health or readiness handler:
if breaker.state is CircuitState.OPEN:
... # report the dependency as degradedThe same property exists on the sync CircuitBreaker.
Semantics
state is a raw read of the stored state. The OPEN→HALF_OPEN transition is lazy:
it fires on the next request admitted after reset_timeout elapses, not on a
clock tick. This means state will report OPEN until a request is actually
admitted as the probe — reading the property never triggers the transition.
This is intentional. A health endpoint that polls state cannot accidentally
promote the circuit to HALF_OPEN by reading it; only real traffic does.
What is NOT in this release
The following remain deferred and are not part of 0.14.0:
- Manual circuit control (
force_open,force_closed) - Writable state transitions (e.g., reset via property assignment)
- Count-based sliding windows
- Slow-call detection
Shipped via
PR #70 — read-only circuit-breaker state introspection.
0.13.0 — time-based failure-rate circuit-breaker mode
httpware 0.13.0 — time-based failure-rate trip mode for the circuit breaker
Minor release. Additive only — no breaking changes.
This release adds an opt-in time-based failure-rate trip mode to both
AsyncCircuitBreaker and CircuitBreaker. Classic consecutive-failure behavior
is the default and is unchanged.
New behavior
The classic circuit breaker trips after failure_threshold consecutive counted
failures — a simple and effective policy for hard outages. It can miss partial
degradation, though: a downstream returning errors on half of all requests may
never form a long enough consecutive streak to trip the circuit.
Rate mode addresses this. Pass failure_rate_threshold to switch:
from httpware import AsyncClient
from httpware.middleware.resilience import AsyncCircuitBreaker
breaker = AsyncCircuitBreaker(
failure_rate_threshold=0.5, # open at ≥50% failures
window_seconds=30.0, # over a rolling 30s window
minimum_calls=20, # but only once 20+ calls are observed
)
async with AsyncClient(
base_url="https://api.example.com",
middleware=[breaker],
) as client:
response = await client.get("/users/1")The circuit opens when the observed failure rate over the rolling window_seconds
window meets or exceeds failure_rate_threshold — but only once minimum_calls
outcomes have been recorded in that window. The minimum_calls guard prevents a
single early failure from immediately tripping the circuit before a meaningful
sample has accumulated.
New constructor parameters
Both AsyncCircuitBreaker and CircuitBreaker accept three new keyword
arguments:
| Parameter | Default | Effect |
|---|---|---|
failure_rate_threshold |
None |
Float in (0, 1]. When set, switches the breaker to rate mode. None keeps classic consecutive-failure mode. ≤0 or >1 raises ValueError. |
window_seconds |
30.0 |
Rolling window width for rate mode. Ignored in classic mode. ≤0 raises ValueError. |
minimum_calls |
20 |
Minimum outcomes in the window before the rate is evaluated. Ignored in classic mode. <1 raises ValueError. |
In rate mode, failure_threshold is ignored — the trip condition is purely
rate-based. All other parameters (reset_timeout, success_threshold,
failure_status_codes) apply in both modes.
Observability
Event names are identical in both modes: circuit.opened, circuit.rejected,
circuit.half_open, circuit.closed. In rate mode the circuit.opened event
carries additional attributes — failure_rate, failure_rate_threshold,
window_seconds, observed_calls — and its message is
"circuit opened — failure rate threshold reached".
What is NOT in this release
The following remain deferred and are not part of 0.13.0:
- Count-based sliding windows (the current implementation is time-based only)
- Slow-call detection (a separate trip axis based on latency percentiles)
- Manual circuit control (
force_open,force_closed) - State introspection properties on the breaker instance
Shipped via
PR #69 — time-based failure-rate trip mode for the circuit breaker.
0.12.0 — per-verb *_with_response siblings
httpware 0.12.0 — per-verb *_with_response siblings
Minor release. Additive only — no breaking changes.
This release adds ergonomic per-verb shortcuts for the common pattern of needing
both the raw httpx2.Response (headers, status, request URL) and a typed body in
a single call — without having to pair build_request(...) with
send_with_response(...).
New public names
Six methods on both AsyncClient and Client:
from httpware import AsyncClient
client = AsyncClient(base_url="https://api.example.com", decoders=[...])
# One call — response metadata and typed body together
response, users = await client.get_with_response(
"/users", params={"page": 2}, response_model=list[User]
)
next_url = response.headers.get("Link")
etag = response.headers.get("ETag")| Method | Verb |
|---|---|
get_with_response |
GET |
post_with_response |
POST |
put_with_response |
PUT |
patch_with_response |
PATCH |
delete_with_response |
DELETE |
request_with_response |
any |
Details
Signature: each method requires response_model (keyword-only) and returns
tuple[httpx2.Response, T]. All other kwargs (params, headers, json,
content, timeout, …) pass through to httpx2 unchanged — identical to the
non-_with_response siblings.
Use case: response metadata alongside a typed body — Link-header pagination,
ETag caching, rate-limit reads (X-RateLimit-Remaining), request URL logging on
redirect. When you don't need the raw response, the existing get / post /
… methods remain the preferred form.
Scope: no head_with_response or options_with_response — HEAD is
bodiless and OPTIONS is rarely decoded. request_with_response(method, url, …)
is the escape hatch for any other verb.
Inherited behavior: MissingDecoderError is raised before the HTTP call
when no decoder claims the model type; DecodeError wraps a failure inside
decode(). Both propagate unchanged through the new methods, identical to
send_with_response.
Shipped via
PR #68 — per-verb *_with_response siblings on AsyncClient and Client
(src/httpware/client.py).
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.