Skip to content

Releases: modern-python/httpware

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

0.3.0 — pydantic as an optional extra

04 Jun 20:40
1222c64

Choose a tag to compare

Breaking changes

  • pydantic is no longer a required dependency. It moved from [project] dependencies to [project.optional-dependencies]. Install it explicitly: pip install httpware[pydantic]. The httpware[all] extra continues to include it.
  • httpware.PydanticDecoder is no longer re-exported from the top-level package. Import directly from the submodule: from httpware.decoders.pydantic import PydanticDecoder. This mirrors the existing MsgspecDecoder import path.
  • AsyncClient() with decoder=None and no pydantic extra raises ImportError at __init__. Pass decoder=MsgspecDecoder() or install httpware[pydantic] to keep the default behavior.

Other changes

  • tests/test_decoders_pydantic.py adds parametrized payload-edge tests that pin current pydantic-core behavior for b"", b"null", b"{}", malformed JSON, and invalid UTF-8.
  • tests/test_optional_extras_isolation.py now covers both pydantic and msgspec via fresh-subprocess import httpware checks.
  • README freshness pass: status line corrected from "0.1.0 alpha" to "0.3.0"; post-pivot framing replaces the pre-pivot description; RecordedTransport reference 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

03 Jun 21:40
3edef19

Choose a tag to compare

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

31 May 20:52
d195992

Choose a tag to compare

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

Full Changelog: https://github.com/modern-python/httpware/commits/0.1.0