Skip to content

Releases: modern-python/httpware

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

0.5.0

05 Jun 17:08
ec373a7

Choose a tag to compare

httpware 0.5.0 — Streaming responses

0.5.0 is additive. No breaking changes. Code written against 0.4.0 continues to work unchanged.

This release closes Epic 4 by adding AsyncClient.stream() for chunked response bodies, and closes two longstanding deferred-work items along the way.

New features

  • AsyncClient.stream(method, url, **kwargs) — async context manager that yields an httpx2.Response with a non-pre-read body. Consume via response.aiter_bytes(), response.aiter_text(), response.aiter_lines(), or response.aiter_raw(). Auto-raises StatusError subclasses on 4xx/5xx (with the body pre-read so exc.response.content works). Bypasses the middleware chain by design — Retry, Bulkhead, and user-installed middleware do not see stream() calls in v1.
  • Retry refuses streamed-body requests. When you call client.post(content=async_gen()) (or data=, files=), the request is marked via request.extensions["httpware.streaming_body"]. If Retry would otherwise retry on a failure, it re-raises the original exception with a PEP 678 note instead — preventing the "consumed iterator can't replay" footgun.

Backwards compatibility

Subclassing/extensions preserve every existing catch-block:

  • All previously-shipping methods (get, post, etc.) behave identically.
  • The internal refactor that extracted _httpx2_exception_mapper from _terminal is byte-for-byte equivalent in dispatch behavior. Tests prove this.
  • The streaming-body marker (request.extensions["httpware.streaming_body"]) only affects requests that genuinely have async-iterable bodies. Existing code passing bytes / dict / files-as-bytes is unaffected.

Usage

from httpware import AsyncClient


async def main() -> None:
    async with AsyncClient(base_url="https://api.example.com") as client:
        async with client.stream("GET", "/big-file") as response:
            async for chunk in response.aiter_bytes():
                process(chunk)

Catch typed status errors on streams the same way as on regular calls:

from httpware import NotFoundError

try:
    async with client.stream("GET", "/maybe-missing") as response:
        ...
except NotFoundError as exc:
    body_text = exc.response.text  # pre-read; accessible

What's still ahead

  • Epic 5 (observability hooks + OTel middleware) is unstarted; logging of retry / bulkhead / stream decisions plumbs through then.
  • Whether stream() should compose with the middleware chain is deferred to real-user feedback. Adding it later is purely additive (stream(..., apply_middleware: bool = False) opt-in).

References

0.4.0

05 Jun 12:45
2a9aac1

Choose a tag to compare

httpware 0.4.0 — Retry, RetryBudget, and Bulkhead

0.4.0 is additive. No breaking changes. Code written against 0.3.0 continues to work unchanged.

This release ships Epic 3 (Resilience) almost entirely: a Retry middleware with sensible defaults, a Finagle-style RetryBudget token bucket that prevents retry storms, a Bulkhead middleware that caps caller-side concurrency, and a refinement to the exception tree (NetworkError) that lets callers tell transient network failures apart from non-retryable transport failures.

New features

  • httpware.Retry — middleware that automatically retries transient failures on idempotent methods. Defaults:
    • max_attempts=3, base_delay=0.1s, max_delay=5.0s, full-jitter exponential backoff (AWS formulation)
    • Retries on 408, 429, 502, 503, 504 for GET / HEAD / OPTIONS / PUT / DELETE (non-idempotent methods like POST and PATCH are not retried by default — pass retry_methods= to opt in per client)
    • Retries on httpware.NetworkError and httpware.TimeoutError for the same method set
    • Honors Retry-After (seconds + HTTP-date forms, capped at max_delay); respect_retry_after=False disables
    • Optional attempt_timeout= wall-clock cap per attempt via asyncio.timeout()
    • On exhaustion, re-raises the original StatusError subclass unwrapped with a PEP 678 __notes__ entry ("httpware: gave up after N attempts")
  • httpware.RetryBudget — Finagle-style token bucket bounding retry rate to prevent retry storms when downstream services degrade. Defaults: ttl=10s, min_retries_per_sec=10, percent_can_retry=0.2 (match Finagle / AWS SDK / Envoy). Per Retry-instance by default; pass an explicit RetryBudget to share across multiple Retry middlewares (e.g., several AsyncClients hitting the same downstream).
  • httpware.RetryBudgetExhaustedError — distinct ClientError raised when the budget refuses a retry. Carries last_response: httpx2.Response | None, last_exception: BaseException | None, and attempts: int. Picklable across process boundaries.
  • httpware.NetworkError(TransportError) — refines the AsyncClient terminal mapping so transient httpx2.NetworkError-family exceptions (ConnectError, ReadError, WriteError, CloseError) raise httpware.NetworkError. InvalidURL and CookieConflict continue to raise bare TransportError. Pool-acquisition timeouts (httpx2.PoolTimeout) continue to raise httpware.TimeoutError.
  • httpware.Bulkhead — middleware that caps in-flight requests at the caller layer via asyncio.Semaphore. Distinct from httpx2.Limits (which caps the connection pool); Bulkhead caps the number of concurrent calls regardless of pool state. Parameters:
    • max_concurrent (required, no default — there's no universally-correct value; depends on downstream capacity)
    • acquire_timeout=1.0 seconds, with None = wait forever and 0 = fail fast on full bulkhead
    • On acquire_timeout elapsed: raises BulkheadFullError(ClientError) carrying max_concurrent and acquire_timeout
    • Slot release is guaranteed by an explicit try/finally around next() — success, exception, and cancellation all release deterministically
    • Bulkhead IS the sharable unit; pass the same instance to multiple AsyncClient(middleware=[shared]) calls to enforce a joint cap across clients
  • httpware.BulkheadFullError — distinct ClientError raised when the Bulkhead refuses to admit a request within acquire_timeout. Carries max_concurrent: int and acquire_timeout: float | None. Picklable across process boundaries.

Backwards compatibility

Subclassing keeps existing catch-blocks working unchanged:

  • except TransportError still catches all transient + permanent transport-layer failures (NetworkError is a subclass).
  • except ClientError still catches everything in the httpware exception tree, including the new RetryBudgetExhaustedError and BulkheadFullError.

The terminal mapping change only narrows what callers see when they check the exact type. Catch-by-isinstance behaves the same.

Usage

from httpware import AsyncClient, Retry, RetryBudget

async with AsyncClient(
    base_url="https://api.example.com",
    middleware=[Retry()],  # default: 3 attempts, full-jitter backoff, fresh RetryBudget
) as client:
    user = await client.get("/users/1", response_model=User)

Share a budget across several clients hitting the same downstream:

from httpware import AsyncClient, Retry, RetryBudget

shared_budget = RetryBudget()  # one bucket, shared

async with AsyncClient(
    base_url="https://upstream-a.example.com",
    middleware=[Retry(budget=shared_budget)],
) as client_a, AsyncClient(
    base_url="https://upstream-b.example.com",
    middleware=[Retry(budget=shared_budget)],
) as client_b:
    ...

Catch budget exhaustion specifically:

from httpware import RetryBudgetExhaustedError

try:
    response = await client.get("/users/1")
except RetryBudgetExhaustedError as exc:
    # Budget refused a retry; the prior failure is preserved.
    logger.warning(
        "retry budget exhausted after %d attempts; last status %s",
        exc.attempts,
        exc.last_response.status_code if exc.last_response else "n/a",
    )

Tune for tighter SLAs:

Retry(
    max_attempts=5,
    base_delay=0.05,
    max_delay=1.0,
    attempt_timeout=0.5,           # cap each attempt at 500ms wall-clock
    retry_methods=frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "POST"}),
    budget=RetryBudget(percent_can_retry=0.1),  # tighter cap
)

Cap caller-side concurrency with Bulkhead. Note: Bulkhead goes outside Retry in the middleware stack so a retrying request holds one slot across all attempts (rather than re-acquiring per retry):

from httpware import AsyncClient, Bulkhead, Retry

async with AsyncClient(
    base_url="https://api.example.com",
    middleware=[
        Bulkhead(max_concurrent=10),  # cap total in-flight at 10
        Retry(),                       # retries happen inside the Bulkhead slot
    ],
) as client:
    user = await client.get("/users/1", response_model=User)

Catch a full bulkhead:

from httpware import BulkheadFullError

try:
    response = await client.get("/users/1")
except BulkheadFullError as exc:
    logger.warning(
        "bulkhead full: %d in-flight, waited %s",
        exc.max_concurrent,
        exc.acquire_timeout,
    )

Share a Bulkhead across multiple clients hitting the same downstream:

shared_bulkhead = Bulkhead(max_concurrent=20)

async with AsyncClient(
    base_url="https://upstream.example.com/v1",
    middleware=[shared_bulkhead],
) as client_a, AsyncClient(
    base_url="https://upstream.example.com/v2",
    middleware=[shared_bulkhead],
) as client_b:
    ...  # the 20-slot cap is enforced jointly across A and B

What's still ahead

The only remaining Epic 3 work is 3-6 extension-slot documentation, which ships as a docs-only follow-up. Epic 5 (observability hooks + OTel middleware) is unstarted; logging of retry/bulkhead decisions plumbs through then.

Out of scope for this release (per the specs, may revisit on real-user pain): per-call retry override via extensions, a Backoff protocol abstraction, retry_on_exception= configuration, retrying streamed request bodies (the latter waits for AsyncClient.stream in Epic 4), per-host Bulkhead partitioning, and Bulkhead queue-depth metrics.

References