Skip to content

Releases: modern-python/httpware

0.4.0

Choose a tag to compare

@lesnik512 lesnik512 released this 05 Jun 12:45
2a9aac1

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

Choose a tag to compare

@lesnik512 lesnik512 released this 04 Jun 20:40
1222c64

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

Choose a tag to compare

@lesnik512 lesnik512 released this 03 Jun 21:40
3edef19

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

Choose a tag to compare

@lesnik512 lesnik512 released this 31 May 20:52
d195992

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