Releases: modern-python/httpware
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.
0.8.1
httpware 0.8.1 — DecodeError closes the decoder-exception gap
Patch release with one behavior change. Code that catches httpware.ClientError (the advertised catch-all) now actually catches every failure mode of response_model= decoding. Code that catches pydantic.ValidationError or msgspec.* directly downstream of client.send(..., response_model=...) will no longer match — those exceptions are now wrapped.
The gap
Before 0.8.1, when response_model= was set, Client.send and AsyncClient.send invoked the active ResponseDecoder without a translation step. Whatever the decoder raised — pydantic.ValidationError (schema mismatch or malformed JSON via TypeAdapter.validate_json), msgspec.ValidationError, msgspec.DecodeError, or anything else — escaped untranslated. except httpware.ClientError did not catch it. Consumers either had to import the decoder library at the call site or skip the decoder entirely and decode the raw httpx2.Response by hand.
The fix
New httpware.DecodeError(ClientError) — direct child of ClientError, sibling of StatusError / TransportError / RetryBudgetExhaustedError / BulkheadFullError. Both Client.send and AsyncClient.send now wrap the decoder call:
try:
return self._decoder.decode(response.content, response_model)
except Exception as exc:
raise DecodeError(response=response, model=response_model, original=exc) from excThe middleware/_dispatch call stays outside the try — transport and status errors are unaffected. Decoder implementers do not need to import or raise DecodeError; the seam translates whatever they raise.
Fields on DecodeError:
response: httpx2.Response— the response whose body failed to decode (status, headers, request URL all available).model: type— the type that was passed asresponse_model=.original: BaseException— the underlying library exception. Also available via__cause__.
from httpware import AsyncClient, ClientError, DecodeError
try:
user = await client.get("/users/1", response_model=User)
except DecodeError as exc:
_LOGGER.error(
"decode failed for %s into %s: %s",
exc.response.request.url,
exc.model.__name__,
exc.original,
)
raise
except ClientError:
raiseMigration
If you catch pydantic.ValidationError or msgspec.* directly downstream of client.send(..., response_model=...), switch to except httpware.DecodeError (or the broader except httpware.ClientError). The previously-leaking exceptions weren't a documented contract, so there's no deprecation pass. The fix is the fix.
If you already catch httpware.ClientError, nothing changes — your handler now also covers the decode-failure path it should have covered all along.
Touched surface
httpware.DecodeError— new public class, re-exported from the top level.Client.send/AsyncClient.send— both wrap the decoder call (onetry/excepteach).ResponseDecoder.decode— protocol signature unchanged; docstring grows one sentence documenting the seam wrap.PydanticDecoderandMsgspecDecoder— unchanged.- Docs:
docs/errors.md(hierarchy + new section),planning/engineering.md(Seam B contract + §4 paragraph),README.md(one-line note on theresponse_model=paragraph).
See also
planning/specs/2026-06-07-decoder-error-design.md— design rationale.planning/plans/2026-06-07-decoder-error-plan.md— implementation plan.- PR #32.
0.8.0 — Sync Client + httpx2-aligned naming
httpware 0.8.0 — Sync Client + httpx2-aligned naming
Breaking release. Renames the async middleware surface to use the Async*/async_* prefix (matching httpx2's convention), drops Retry(attempt_timeout=...), and adds a fully-featured sync Client.
If you have existing async code, migration is one mechanical pass through your imports — see "Breaking changes" below.
What's new
- Sync
Client. Full parity withAsyncClient: typed response decoding, middleware chain,Retry+Bulkhead,stream()context manager, lifecycle (with+close()), andhttpx2.Clientinjection. Designed for CLI tools, scripts, Django sync views, Jupyter, and threaded service workers. - Sync
Middleware+Next+ decorators.from httpware import Middleware, Next, before_request, after_response, on_error. Same protocol shape as async; bodies are sync. - Sync
RetryandBulkhead. Same resilience semantics as their async siblings, withtime.sleepandthreading.Semaphore. SyncRetrysharesRetryBudgetwith async — one instance is safe across both worlds. RetryBudgetis now thread-safe via an internalthreading.Lock. Async users see no behavioral difference; the overhead is invisible (~50–100 ns per op).- Shared helpers in
_internal/.map_httpx2_exception,_raise_on_status_error, the streaming-body marker, and the body predicates moved to_internal/exception_mapping.pyand_internal/status.py. No public-API change other than the exports listed below.
Breaking changes
Renames
| Old name | New name |
|---|---|
httpware.Middleware |
httpware.AsyncMiddleware |
httpware.Next |
httpware.AsyncNext |
httpware.Retry |
httpware.AsyncRetry |
httpware.Bulkhead |
httpware.AsyncBulkhead |
httpware.before_request |
httpware.async_before_request |
httpware.after_response |
httpware.async_after_response |
httpware.on_error |
httpware.async_on_error |
httpware.middleware.chain.compose |
httpware.middleware.chain.compose_async |
Removals
Retry(attempt_timeout=...)/AsyncRetry(attempt_timeout=...)is removed. It usedasyncio.timeoutto bound the whole attempt as a structured cancellation; this had no clean sync equivalent and is mostly covered byhttpx2.Timeout(per-phase I/O bounds) for typical use cases. Users who genuinely need whole-attempt wall-clock bounds can compose their own timeout middleware.
New names that previously meant something else
The unprefixed Middleware, Next, Retry, Bulkhead, before_request, after_response, on_error now refer to sync types. Code that imports them and expects async behavior will break at type-check time (or at the first await site).
Migration
A one-pass sed/regex covers most of the work:
# in your project root:
git ls-files '*.py' | xargs sed -i.bak \
-e 's/from httpware import \(.*\)\bMiddleware\b/from httpware import \1AsyncMiddleware/g' \
-e 's/from httpware import \(.*\)\bNext\b/from httpware import \1AsyncNext/g' \
-e 's/from httpware import \(.*\)\bRetry\b/from httpware import \1AsyncRetry/g' \
-e 's/from httpware import \(.*\)\bBulkhead\b/from httpware import \1AsyncBulkhead/g' \
-e 's/from httpware import \(.*\)\bbefore_request\b/from httpware import \1async_before_request/g' \
-e 's/from httpware import \(.*\)\bafter_response\b/from httpware import \1async_after_response/g' \
-e 's/from httpware import \(.*\)\bon_error\b/from httpware import \1async_on_error/g'Then update the symbol references in the file bodies (your type checker will guide you). If you were using Retry(attempt_timeout=...), remove the kwarg and rely on httpx2.Timeout or write a minimal timeout middleware.
References
- Design spec:
planning/specs/2026-06-07-sync-client-design.md - Implementation plan:
planning/plans/2026-06-07-sync-client-plan.md - Engineering notes:
planning/engineering.md§3 Seam A, §5 module layout - Source spec parent (httpx convention):
planning/archive/specs/2026-06-03-thin-httpx2-wrapper-design.md
0.7.0 — First-cut user docs
httpware 0.7.0 — First-cut user docs (docs-only)
0.7.0 is a docs-only release. No API changes. Code written against 0.6.0 continues to work unchanged.
This release ships the first-cut user-facing documentation surface — every shipped feature through 0.6 now has a user-facing reference page, and the two highest-friction adoption recipes (test-mocking and OpenTelemetry wiring) are concrete. Epic 3 (Resilience) closes with this release.
What's new
Four new docs deliverables on the docs site:
docs/middleware.md— write your own middleware againsthttpware.middleware.MiddlewareandNext. Covers the protocol, the phase decorators (@before_request,@after_response,@on_error), a workedRequestIdMiddlewareexample, a "when NOT to write a middleware" section, and an "OpenTelemetry wiring" section with a minimal SDK +opentelemetry-instrumentation-httpxsetup that makes the 0.6.0 Retry/Bulkhead observability events visible as span events.docs/resilience.md— deep-dive reference forRetry,RetryBudget, andBulkhead: every parameter with its default and effect, the retry-rule matrix (status codes × methods), Retry-After parsing, streaming-body refusal contract, the token-bucket formula, why the floor matters, budget/bulkhead sharing across clients, and composition guidance.docs/errors.md— the fullStatusErrorhierarchy as an ASCII tree, the status-to-exception mapping table, practical catching strategies (specific status →StatusError→NetworkError→ resilience errors →ClientErrorcatch-all), theexc.response.*access pattern with the userinfo-stripping security note, and the payloads onRetryBudgetExhaustedError/BulkheadFullErrorfor caller-side logging.docs/testing.md— thehttpx2.MockTransportinjection pattern viaAsyncClient(httpx2_client=...). Recording/stateful handlers, testing custom middleware end-to-end, brief "why not respx" note pointing at the private-internals risk.
Plus discovery: three new mkdocs nav entries (Resilience, Errors, Testing), four new bullets in docs/index.md "Where to go next", and engineering notes updated.
What's not in this release
- No source code changes. The Middleware protocol, phase decorators, resilience primitives, exception tree, and test-transport seam all already existed; this release documents them.
- No new built-in middleware. No CircuitBreaker, no RateLimiter, no auth helpers.
- No API autodoc (e.g., mkdocstrings). Hand-written user docs only.
- No benchmarks page, no migration guide, no speculative cookbook recipes. Reference pages for shipped features + concrete adoption recipes only.
- No mkdocs publish workflow / docs-site infrastructure. That's Epic 6 (story
6-2); this release just keepsmkdocs build --strictgreen.
Epic 3 closed
Epic 3 (Resilience) has shipped end-to-end:
- v0.4 slice 1 —
Retry+RetryBudget+attempt_timeout= - v0.4 slice 2 —
Bulkhead - v0.7 —
3-6extension-slot docs + the rest of the first-cut user-docs surface
Remaining roadmap is Epic 6 (ship v1.0): 6-2 docs site infrastructure (mkdocs publishing, hand-written content only — no autodoc), and 6-5 release flow (Trusted Publishers + Sigstore).
References
- Middleware spec:
planning/specs/2026-06-05-extension-slot-docs-design.md - Docs-expansion spec:
planning/specs/2026-06-05-v0.7-docs-expansion-design.md - Middleware plan:
planning/plans/2026-06-05-extension-slot-docs-plan.md - Docs-expansion plan:
planning/plans/2026-06-05-v0.7-docs-expansion-plan.md - Roadmap:
planning/engineering.md§8
0.6.0 — Resilience observability
httpware 0.6.0 — Resilience observability
0.6.0 is additive. No breaking changes. Code written against 0.5.0 continues to work unchanged.
This release adds operational-event emission to Retry and Bulkhead via two channels — stdlib logging records (always on) and OpenTelemetry span events (opt-in via the otel extra). Re-introduces the otel extra (PR #24 removed it as YAGNI; this release brings it back paired with the code that uses it).
New features
- Structured logging on resilience operations. Acquire
logging.getLogger("httpware.retry")andlogging.getLogger("httpware.bulkhead")to see four operational events:retry.giving_up(WARNING) — max_attempts exhausted; attributes includeattempts,method,url,last_status,last_exception_typeretry.budget_refused(WARNING) —RetryBudgetrefused to permit a retryretry.streaming_refused(WARNING) — streaming-body marker prevented an otherwise-retryable retrybulkhead.rejected(WARNING) —acquire_timeoutelapsed without acquisition; attributes includemax_concurrent,acquire_timeout,method,url
- Optional OpenTelemetry attribute enrichment. Install
httpware[otel](which pullsopentelemetry-api>=1.20, just the API — you supply the SDK). When installed, the same four events are added to the active span viatrace.get_current_span().add_event(name, attributes=...). We never create our own spans — for HTTP-level tracing installopentelemetry-instrumentation-httpxseparately.
Backwards compatibility
Purely additive:
- All previously-shipping methods behave identically.
- Successful retries and successful bulkhead acquisitions emit nothing — the four events fire only on operational concern.
- Per
engineering.md §2, httpware never configures handlers, levels, or callslogging.basicConfig(). Consumers own their logging configuration. - The
otelextra is opt-in —pip install httpwarecontinues to work withoutopentelemetry-api.
Usage
import logging
from httpware import AsyncClient, Bulkhead, Retry
# Enable visibility into retry / bulkhead operational events
logging.getLogger("httpware.retry").setLevel(logging.WARNING)
logging.getLogger("httpware.bulkhead").setLevel(logging.WARNING)
# Your normal application logging config picks up the records
logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(name)s %(message)s")
async with AsyncClient(
base_url="https://api.example.com",
middleware=[Bulkhead(max_concurrent=10), Retry()],
) as client:
await client.get("/users/1")
# On a 503 + retry exhaustion you'll see:
# 2026-06-05 12:00:00 httpware.retry retry gave up after 3 attemptsFor OTel span events:
pip install httpware[otel]
# Plus your SDK + opentelemetry-instrumentation-httpx for HTTP-level spansWhat's still ahead
Epic 5's original 5-1 (hook protocol) and 5-4 (standalone OTel middleware) stories are retired, not deferred. Rationale in the spec: opentelemetry-instrumentation-httpx already covers transport-level tracing, and a hook system without a built-in consumer is infrastructure for code that doesn't exist. The structured-emission contract we're shipping is already extensible — users plug into standard logging handlers without needing httpware-specific hooks.
This effectively closes Epic 5. Remaining roadmap is Epic 6 (ship v1.0): docs site (mkdocs), benchmarks, Trusted Publishers + Sigstore release flow.