Skip to content

Releases: modern-python/httpware

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

0.8.1

08 Jun 04:36
a38ace1

Choose a tag to compare

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 exc

The 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 as response_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:
    raise

Migration

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 (one try/except each).
  • ResponseDecoder.decode — protocol signature unchanged; docstring grows one sentence documenting the seam wrap.
  • PydanticDecoder and MsgspecDecoder — unchanged.
  • Docs: docs/errors.md (hierarchy + new section), planning/engineering.md (Seam B contract + §4 paragraph), README.md (one-line note on the response_model= paragraph).

See also

0.8.0 — Sync Client + httpx2-aligned naming

07 Jun 18:27
d2c24f2

Choose a tag to compare

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 with AsyncClient: typed response decoding, middleware chain, Retry + Bulkhead, stream() context manager, lifecycle (with + close()), and httpx2.Client injection. 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 Retry and Bulkhead. Same resilience semantics as their async siblings, with time.sleep and threading.Semaphore. Sync Retry shares RetryBudget with async — one instance is safe across both worlds.
  • RetryBudget is now thread-safe via an internal threading.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.py and _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 used asyncio.timeout to bound the whole attempt as a structured cancellation; this had no clean sync equivalent and is mostly covered by httpx2.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

0.7.0 — First-cut user docs

05 Jun 20:26
12f6c92

Choose a tag to compare

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 against httpware.middleware.Middleware and Next. Covers the protocol, the phase decorators (@before_request, @after_response, @on_error), a worked RequestIdMiddleware example, a "when NOT to write a middleware" section, and an "OpenTelemetry wiring" section with a minimal SDK + opentelemetry-instrumentation-httpx setup that makes the 0.6.0 Retry/Bulkhead observability events visible as span events.
  • docs/resilience.md — deep-dive reference for Retry, RetryBudget, and Bulkhead: 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 full StatusError hierarchy as an ASCII tree, the status-to-exception mapping table, practical catching strategies (specific status → StatusErrorNetworkError → resilience errors → ClientError catch-all), the exc.response.* access pattern with the userinfo-stripping security note, and the payloads on RetryBudgetExhaustedError / BulkheadFullError for caller-side logging.
  • docs/testing.md — the httpx2.MockTransport injection pattern via AsyncClient(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 keeps mkdocs build --strict green.

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-6 extension-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

0.6.0 — Resilience observability

05 Jun 18:47
7cf653b

Choose a tag to compare

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") and logging.getLogger("httpware.bulkhead") to see four operational events:
    • retry.giving_up (WARNING) — max_attempts exhausted; attributes include attempts, method, url, last_status, last_exception_type
    • retry.budget_refused (WARNING) — RetryBudget refused to permit a retry
    • retry.streaming_refused (WARNING) — streaming-body marker prevented an otherwise-retryable retry
    • bulkhead.rejected (WARNING) — acquire_timeout elapsed without acquisition; attributes include max_concurrent, acquire_timeout, method, url
  • Optional OpenTelemetry attribute enrichment. Install httpware[otel] (which pulls opentelemetry-api>=1.20, just the API — you supply the SDK). When installed, the same four events are added to the active span via trace.get_current_span().add_event(name, attributes=...). We never create our own spans — for HTTP-level tracing install opentelemetry-instrumentation-httpx separately.

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 calls logging.basicConfig(). Consumers own their logging configuration.
  • The otel extra is opt-in — pip install httpware continues to work without opentelemetry-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 attempts

For OTel span events:

pip install httpware[otel]
# Plus your SDK + opentelemetry-instrumentation-httpx for HTTP-level spans

What'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.

References