Releases: modern-python/httpware
0.5.0
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 anhttpx2.Responsewith a non-pre-read body. Consume viaresponse.aiter_bytes(),response.aiter_text(),response.aiter_lines(), orresponse.aiter_raw(). Auto-raisesStatusErrorsubclasses on 4xx/5xx (with the body pre-read soexc.response.contentworks). Bypasses the middleware chain by design —Retry,Bulkhead, and user-installed middleware do not seestream()calls in v1.Retryrefuses streamed-body requests. When you callclient.post(content=async_gen())(ordata=,files=), the request is marked viarequest.extensions["httpware.streaming_body"]. IfRetrywould 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_mapperfrom_terminalis 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; accessibleWhat'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
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,504forGET / HEAD / OPTIONS / PUT / DELETE(non-idempotent methods likePOSTandPATCHare not retried by default — passretry_methods=to opt in per client) - Retries on
httpware.NetworkErrorandhttpware.TimeoutErrorfor the same method set - Honors
Retry-After(seconds + HTTP-date forms, capped atmax_delay);respect_retry_after=Falsedisables - Optional
attempt_timeout=wall-clock cap per attempt viaasyncio.timeout() - On exhaustion, re-raises the original
StatusErrorsubclass 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). PerRetry-instance by default; pass an explicitRetryBudgetto share across multipleRetrymiddlewares (e.g., severalAsyncClients hitting the same downstream).httpware.RetryBudgetExhaustedError— distinctClientErrorraised when the budget refuses a retry. Carrieslast_response: httpx2.Response | None,last_exception: BaseException | None, andattempts: int. Picklable across process boundaries.httpware.NetworkError(TransportError)— refines theAsyncClientterminal mapping so transienthttpx2.NetworkError-family exceptions (ConnectError,ReadError,WriteError,CloseError) raisehttpware.NetworkError.InvalidURLandCookieConflictcontinue to raise bareTransportError. Pool-acquisition timeouts (httpx2.PoolTimeout) continue to raisehttpware.TimeoutError.httpware.Bulkhead— middleware that caps in-flight requests at the caller layer viaasyncio.Semaphore. Distinct fromhttpx2.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.0seconds, withNone= wait forever and0= fail fast on full bulkhead- On
acquire_timeoutelapsed: raisesBulkheadFullError(ClientError)carryingmax_concurrentandacquire_timeout - Slot release is guaranteed by an explicit
try/finallyaroundnext()— success, exception, and cancellation all release deterministically BulkheadIS the sharable unit; pass the same instance to multipleAsyncClient(middleware=[shared])calls to enforce a joint cap across clients
httpware.BulkheadFullError— distinctClientErrorraised when the Bulkhead refuses to admit a request withinacquire_timeout. Carriesmax_concurrent: intandacquire_timeout: float | None. Picklable across process boundaries.
Backwards compatibility
Subclassing keeps existing catch-blocks working unchanged:
except TransportErrorstill catches all transient + permanent transport-layer failures (NetworkErroris a subclass).except ClientErrorstill catches everything in the httpware exception tree, including the newRetryBudgetExhaustedErrorandBulkheadFullError.
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
- Retry spec:
planning/specs/2026-06-05-retry-and-retry-budget-design.md - Retry plan:
planning/plans/2026-06-05-retry-and-retry-budget-plan.md - Bulkhead spec:
planning/specs/2026-06-05-bulkhead-design.md - Bulkhead plan:
planning/plans/2026-06-05-bulkhead-plan.md - Roadmap:
planning/engineering.md§8
0.3.0 — pydantic as an optional extra
Breaking changes
pydanticis no longer a required dependency. It moved from[project] dependenciesto[project.optional-dependencies]. Install it explicitly:pip install httpware[pydantic]. Thehttpware[all]extra continues to include it.httpware.PydanticDecoderis no longer re-exported from the top-level package. Import directly from the submodule:from httpware.decoders.pydantic import PydanticDecoder. This mirrors the existingMsgspecDecoderimport path.AsyncClient()withdecoder=Noneand no pydantic extra raisesImportErrorat__init__. Passdecoder=MsgspecDecoder()or installhttpware[pydantic]to keep the default behavior.
Other changes
tests/test_decoders_pydantic.pyadds parametrized payload-edge tests that pin current pydantic-core behavior forb"",b"null",b"{}", malformed JSON, and invalid UTF-8.tests/test_optional_extras_isolation.pynow covers both pydantic and msgspec via fresh-subprocessimport httpwarechecks.- README freshness pass: status line corrected from "0.1.0 alpha" to "0.3.0"; post-pivot framing replaces the pre-pivot description;
RecordedTransportreference removed.
Migration
# 0.2.0
from httpware import AsyncClient, PydanticDecoder
async with AsyncClient(base_url="https://api.example.com") as client:
user = await client.get("/users/1", response_model=User)# 0.3.0 — option 1: install the extra, code unchanged
# pip install httpware[pydantic]
from httpware import AsyncClient
async with AsyncClient(base_url="https://api.example.com") as client:
user = await client.get("/users/1", response_model=User)
# 0.3.0 — option 2: import PydanticDecoder from the submodule
from httpware import AsyncClient
from httpware.decoders.pydantic import PydanticDecoder
async with AsyncClient(decoder=PydanticDecoder()) as client:
user = await client.get("/users/1", response_model=User)What's next
Epic 3 (resilience middleware — retry, timeout, bulkhead) and Epic 5 (observability) ship in subsequent releases. See planning/engineering.md §8.
0.2.0
What's Changed
- docs(readme): drop compose() mention; it's not public API by @lesnik512 in #15
- feat(story-2.4): auth coercion as middleware by @lesnik512 in #16
- docs: reorganize docs/, delete bmad archive, add mkdocs site by @lesnik512 in #17
- chore: project hygiene tidy — publish guard, uv_build band, HTTPStatus, Response.json() charset by @lesnik512 in #18
- chore: input-validation pass — Request/Timeout/Limits/ClientConfig post_init guards + charset parser fix by @lesnik512 in #19
- v0.2: thin httpx2 wrapper rewrite by @lesnik512 in #20
Full Changelog: 0.1.0...0.2.0
0.1.0
What's Changed
- feat(story-1.2): core data types — Request, Response, Limits, Timeout, ClientConfig by @lesnik512 in #1
- Review/stories 1 1 and 1 2 patches by @lesnik512 in #2
- feat(story-1.3): exception hierarchy with plain typed fields by @lesnik512 in #3
- feat(story-1.4): transport protocol and Httpx2Transport adapter by @lesnik512 in #4
- feat(story-1.5): ResponseDecoder protocol and pydantic adapter by @lesnik512 in #5
- chore: cutover from bmad to superpowers workflow by @lesnik512 in #6
- docs: retrospective review of stories 1-1 to 1-5 + four deferred items by @lesnik512 in #7
- feat(story-2.1): Middleware protocol, Next type, and chain composition by @lesnik512 in #8
- feat(story-2.2): phase-shortcut decorators @before_request, @after_response, @on_error by @lesnik512 in #9
- feat(story-2.3): Request/Response immutability helper expansion by @lesnik512 in #10
- feat(story-1.6): MsgspecDecoder via the [msgspec] extra by @lesnik512 in #11
- feat(story-1.7): AsyncClient — the v0.1.0 public surface by @lesnik512 in #12
- feat(story-1.8): RecordedTransport — built-in Transport test double by @lesnik512 in #13
- chore: prep 0.1.0 release by @lesnik512 in #14
New Contributors
- @lesnik512 made their first contribution in #1
Full Changelog: https://github.com/modern-python/httpware/commits/0.1.0