A Python HTTP client framework with sync and async clients for building resilient service clients. httpware is a thin opinionated wrapper around httpx2 — it re-exports httpx2.Request/httpx2.Response as the public request/response surface, adds a middleware chain (with a built-in resilience suite: AsyncRetry/Retry + RetryBudget, AsyncBulkhead/Bulkhead, AsyncCircuitBreaker/CircuitBreaker, and AsyncTimeout), opt-in typed response decoding, and a status-keyed exception tree raised automatically on 4xx/5xx.
Why httpware¶
Typed exceptions per HTTP status, typed response bodies, and composable
resilience (retry, bulkhead, circuit breaker, timeout) — a thin wrapper over
httpx2, not a new HTTP abstraction. See the
project README for
the full pitch.
Status: Pre-1.0. Public API is subject to change between minor releases until v1.0.
Install¶
Optional extras:
pip install httpware[pydantic] # PydanticDecoder — handles BaseModel + dataclasses + primitives + generics
pip install httpware[msgspec] # MsgspecDecoder — handles Struct + dataclasses + primitives + generics
pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec
First request¶
Async usage:
import asyncio
from httpware import AsyncClient
async def main() -> None:
async with AsyncClient(base_url="https://jsonplaceholder.typicode.com") as client:
response = await client.get("/users/1")
print(response.json())
asyncio.run(main())
Sync usage:
from httpware import Client
with Client(base_url="https://jsonplaceholder.typicode.com") as client:
response = client.get("/users/1")
print(response.json())
Typed decoding via response_model= works the same way in both worlds:
from httpware import AsyncClient
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
async def main() -> None:
async with AsyncClient(base_url="https://api.example.com") as client:
user = await client.get("/users/1", response_model=User)
print(user.name)
Need the raw response and a decoded body from the same call (e.g., for header-based pagination)? See Link header pagination — it uses send_with_response.
Decoder dispatch¶
When response_model= is set, the client walks decoders in order and picks
the first decoder whose can_decode returns True; ordering encodes your
preference for shapes more than one decoder could claim. If none claims your
response_model, the call raises MissingDecoderError before the HTTP
request. See Decoders for the resolution rules and
pydantic/msgspec routing.
With resilience middleware¶
Compose resilience middleware at construction; AsyncBulkhead goes outside AsyncRetry so one slot covers all retry attempts.
from httpware import AsyncClient, AsyncBulkhead, AsyncRetry
async def main() -> None:
async with AsyncClient(
base_url="https://api.example.com",
middleware=[
AsyncBulkhead(max_concurrent=10), # cap total in-flight
AsyncRetry(), # default: 3 attempts, full-jitter backoff
],
) as client:
user = await client.get("/users/1", response_model=User)
Streaming responses¶
For large responses or server-sent events, stream the body chunk-by-chunk. stream() is an async context manager:
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)
stream() auto-raises StatusError subclasses on 4xx/5xx with the response body pre-read, so exc.response.content is accessible from the caught exception.
It does NOT pass through the middleware chain: AsyncRetry, AsyncBulkhead, and any custom middleware are bypassed. (AsyncRetry separately refuses to retry any request — stream or non-stream — whose body was an async-iterable, since streams can't replay across attempts.)
Capping response body size¶
Both clients accept an opt-in max_response_body_bytes: int | None = None. When set, a response body that exceeds the cap raises ResponseTooLargeError instead of being returned; the default None is unbounded. See Errors for the full trip conditions.
Errors¶
All errors inherit httpware.ClientError: 4xx/5xx responses raise a typed
StatusError subclass automatically, and response_model= decode failures
raise DecodeError. See Errors for the full tree and
catching strategies.
Observability¶
Every resilience middleware emits stdlib-logging records (always) and OTel
span events (when opentelemetry-api is installed), under stable logger and
event names. See Observability for the full contract.
Where to go next¶
- Resilience reference — every parameter on
AsyncRetry,RetryBudget, andAsyncBulkhead; the retry-rule matrix; Retry-After parsing; budget sharing. - Middleware guide — write your own middleware. Covers the AsyncMiddleware Protocol, the phase decorators, a worked Request-ID propagation example, and OpenTelemetry wiring.
- Errors reference — the full exception tree, catching strategies,
exc.response.*access pattern. - Observability — the stdlib-
loggingand OTel span-event contract emitted by the resilience middleware. - Testing guide — mock-transport injection pattern for testing code that uses
httpware. - Recipes — wiring
AsyncClientinto amodern-dicontainer. - Architecture Notes — per-capability design notes — invariants, the three protocol seams, exception contract, module layout, testing patterns — under
architecture/. Lives in the repo underarchitecture/. - Contributing — setup, conventions, workflow.
- Release notes — per-version changelogs.
Part of modern-python¶
httpware ships under the modern-python org. See the org profile for the categorized index of related templates and libraries.