0.8.2 — send_with_response for atomic (response, decoded) pair
httpware 0.8.2 — send_with_response for atomic (response, decoded) pair
Patch release with one additive method. No deprecations, no behavior changes on existing surfaces. Code that uses send(..., response_model=) keeps working exactly as before.
The gap
The existing client.send(request, response_model=M) and verb-method overloads (client.get(url, response_model=M), etc.) decode the body and discard the httpx2.Response. That's the right default for most callers — typed body in, raw response forgotten. But a real class of callers needs both the decoded body and the raw response, atomically: status, headers, and response.request.url. The canonical case is RFC 5988 Link header pagination, where the body deserializes into a page model and the Link header drives the next request.
Before 0.8.2 those callers had to drop down to client.send(request) and re-decode by hand. That bypassed the configured ResponseDecoder (pydantic vs. msgspec swappability went to waste) and re-opened the same exception-leak hole DecodeError closed in 0.8.1 — except httpware.ClientError no longer caught the decode failure because the decode happened outside the seam.
The fix
New method on both client classes:
def send_with_response(
self,
request: httpx2.Request,
*,
response_model: type[T],
) -> tuple[httpx2.Response, T]: ...Routes the request through the middleware chain via _dispatch, decodes via the active ResponseDecoder, returns both values. Decoder failures wrap as DecodeError exactly the way they do in send(..., response_model=) — except httpware.ClientError catches every failure mode.
Canonical use case — Link header pagination
from httpware import AsyncClient
from pydantic import BaseModel
class Tag(BaseModel):
name: str
async def main() -> None:
async with AsyncClient(base_url="https://gitlab.example/api/v4") as client:
url = "/projects/1/repository/tags"
params: dict[str, str] | None = {"per_page": "100", "page": "1"}
while url:
request = client.build_request("GET", url, params=params)
response, tags = await client.send_with_response(request, response_model=list[Tag])
for tag in tags:
process(tag) # caller-defined
url = next_link(response.headers.get("link")) # caller-defined parser
params = NoneWhen to use which
client.get(..., response_model=M)— body-only with a high-level verb.client.send(request, response_model=M)— body-only with a customRequest(e.g., neededbuild_requestflexibility).client.send_with_response(request, response_model=M)— both, atomically. New.client.stream(...)— streaming responses.send_with_responseis not for streaming; it decodesresponse.content, which requires the body to be fully read.
Migration
None. Additive — every existing call site keeps the same shape and return type.
Touched surface
httpware.AsyncClient.send_with_response— new.httpware.Client.send_with_response— new.httpware.DecodeError— reused as the failure-mode for decoder exceptions raised insidesend_with_response. No new fields.- Docs:
docs/index.mdgains aResponse metadata + typed bodysubsection with the pagination example above;planning/engineering.mdSeam B contract now namessend_with_responsealongsidesend.
Nothing else changed in this release.
See also
planning/specs/2026-06-08-send-with-response-design.md— design rationale, non-goals, why a separate method rather than an overload onsend.planning/plans/2026-06-08-send-with-response-plan.md— implementation plan.- PR #33.