Releases: modern-python/httpware
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.
References
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