Skip to content

0.8.2 — send_with_response for atomic (response, decoded) pair

Choose a tag to compare

@lesnik512 lesnik512 released this 08 Jun 06:30
· 178 commits to main since this release
f884a26

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 = None

When 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 custom Request (e.g., needed build_request flexibility).
  • client.send_with_response(request, response_model=M) — both, atomically. New.
  • client.stream(...) — streaming responses. send_with_response is not for streaming; it decodes response.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 inside send_with_response. No new fields.
  • Docs: docs/index.md gains a Response metadata + typed body subsection with the pagination example above; planning/engineering.md Seam B contract now names send_with_response alongside send.

Nothing else changed in this release.

See also