Skip to content

Releases: modern-python/httpware

0.14.0 — read-only circuit-breaker state introspection

16 Jun 12:47
7938984

Choose a tag to compare

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 degraded

The 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

16 Jun 10:53
2087b68

Choose a tag to compare

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

16 Jun 07:12
2b7d9b7

Choose a tag to compare

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

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.