Skip to content

refactor: declare per-request options once as TypedDicts in client.py - #186

Merged
lesnik512 merged 1 commit into
mainfrom
refactor/request-options
Oct 17, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
refactor/request-options

Conversation

@lesnik512

Copy link
Copy Markdown
Member

What changed

client.py spelled out the same per-request keywords (params, headers, cookies, timeout,
extensions, plus json, content, data, files for methods that can send a body) on every
overload, every implementation and every private layer they were forwarded through:
verb → _request_with_body / _request_with_body_with_response → _prepare_request →
_assemble_request_kwargs. Both clients carried a copy.

They are now declared once:

class _RequestOptionsBase(typing_extensions.TypedDict, total=False):
    params: typing.Any | None
    headers: typing.Any | None
    cookies: typing.Any | None
    timeout: typing.Any
    extensions: typing.Any | None

class _RequestOptions(_RequestOptionsBase, total=False, closed=True): ...

class _BodyRequestOptions(_RequestOptionsBase, total=False, closed=True):
    json: typing.Any | None
    content: typing.Any | None
    data: typing.Any | None
    files: typing.Any | None

get, head, options and get_with_response take **kwargs: Unpack[_RequestOptions]. post,
put, patch, delete, request, their *_with_response siblings and stream() take
**kwargs: Unpack[_BodyRequestOptions]. The base is open and the two leaves are closed because a
closed TypedDict cannot be extended, and the type checker only rejects unknown keywords when the
TypedDict is closed.

_request_with_body, _request_with_body_with_response and _assemble_request_kwargs are gone.
Each verb now calls _prepare_request and then _send or _send_with_response. stream() shares
the body options and goes through the same _request_kwargs helper. send(),
send_with_response() and build_request() are untouched. All the # noqa: PLR0913 comments in
client.py are gone; none is needed any more.

Before and after: AsyncClient.get

Before (the two overloads repeat the same five keywords, with response_model: None = None and
response_model: type[T]):

async def get(  # noqa: PLR0913 — mirrors httpx2 per-method signatures
    self,
    url: str,
    *,
    params: typing.Any | None = None,
    headers: typing.Any | None = None,
    cookies: typing.Any | None = None,
    timeout: typing.Any = httpx2.USE_CLIENT_DEFAULT,
    extensions: typing.Any | None = None,
    response_model: type[T] | None = None,
) -> httpx2.Response | T:
    """Send a GET request."""
    return await self._request_with_body(
        "GET",
        url,
        params=params,
        headers=headers,
        cookies=cookies,
        timeout=timeout,
        extensions=extensions,
        response_model=response_model,
    )

_request_with_body then forwarded all nine keywords to _prepare_request, which forwarded them
to _assemble_request_kwargs.

After:

@typing.overload
async def get(
    self, url: str, *, response_model: None = None, **kwargs: typing.Unpack[_RequestOptions]
) -> httpx2.Response: ...

@typing.overload
async def get(
    self, url: str, *, response_model: type[T], **kwargs: typing.Unpack[_RequestOptions]
) -> T: ...

async def get(
    self, url: str, *, response_model: type[T] | None = None, **kwargs: typing.Unpack[_RequestOptions]
) -> httpx2.Response | T:
    """Send a GET request."""
    request = self._prepare_request("get", "GET", url, kwargs, _REQUEST_OPTION_DEFAULTS)
    return await self._send(request, response_model)

(ruff format puts one parameter per line in the file.)

Line counts

src/httpware/client.py: 2,314 → 1,436 lines (1,096 deleted, 218 added, net −878). Across the
whole PR, tests included: 1,200 deleted, 375 added.

Caller-facing API

Unchanged. Method names are the same, url (and method for request/stream) is still
positional, every option is still keyword-only, and the keyword names are the same per method.
"Not given" means the same thing as before:

  • a key that is absent, or set to None, is not passed to httpx2;
  • timeout is the exception: absent or httpx2.USE_CLIENT_DEFAULT means the client default, and
    an explicit timeout=None means no timeout (the 0.22.0 rule).

_request_kwargs encodes this with one map per TypedDict from option name to its "not given"
value (None, or USE_CLIENT_DEFAULT for timeout) and drops a value that is that sentinel. The
old tests for _assemble_request_kwargs were ported to it.

Hover trade-off: an IDE hover on client.get now shows **kwargs: Unpack[_RequestOptions] instead
of the five named parameters, though completion and type checking still list and check each
keyword (there is no generated API reference to worry about, ADR-0007).

Unknown keywords

With a type checker, ty treats **kwargs: Unpack[<closed TypedDict>] as exactly those keywords, so
client.get(url, foo=1) and client.get(url, json=...) fail with no-matching-overload, and
client.post_with_response(url, response_model=M, foo=1) fails with unknown-argument.
tests/test_client_typing.py now has those calls (plus request, request_with_response and
stream, sync and async), each with a # ty: ignore[<code>]. A new ty override in
pyproject.toml makes unused-ignore-comment an error for that file only, so if a TypedDict ever
stops rejecting unknown keywords, the ignore becomes unused and just lint fails. I checked this by
removing closed=True from _RequestOptions: ty reported four unused-ignore errors. The file also
gained assert_type checks that get(..., params=..., timeout=None) still returns Response,
post(..., json=..., response_model=M) returns M, and request_with_response returns
tuple[Response, M].

At runtime, Python no longer rejects the keyword while binding arguments, so _request_kwargs does
it. It already walks the given options to drop the "not given" ones, and the same dict lookup that
finds an option's sentinel also finds an unknown name, so the check adds no work per request. The
error mirrors Python's own:

TypeError: AsyncClient.get() got an unexpected keyword argument 'foo'

Tests cover every verb and *_with_response method in both clients, plus request,
request_with_response and stream.

One small difference: stream() is a context manager, so the TypeError now comes from entering it
(with client.stream(..., foo=1):) rather than from the call expression. In normal use both are
the same line.

Parity test

_parameter_shape in tests/test_client_parity.py expands a **kwargs: Unpack[TD] parameter into
the keyword-only names of TD. Sync/async drift in the options (or one world pointing at a
TypedDict with different keys) is still caught, and the comparison lists each keyword a caller can
pass. The constructor check gains the same expansion for _ClientOptions and
_AsyncClientOptions. The close/aclose and AsyncTimeout exceptions are unchanged.

Performance

In-process A/B: the origin/main package was imported under another name next to this branch and
the two were timed alternately, best of 3 per round, 16 rounds, then the paired new/base ratio per
round. The machine was heavily loaded (load average 13 to 55), so absolute numbers moved a lot
between runs; the ratios were stable.

"Forwarding" stubs out httpx2.Client.build_request and _send, which isolates the code this PR
touches. "Full" is a real call on httpx2.MockTransport.

case new/base, paired median (two runs)
sync get, forwarding 0.59 to 0.63 (quietest run: 483 → 285 ns)
async get, forwarding 0.66 to 0.69 (739 → 492 ns)
sync post(json=...), forwarding 0.78 to 0.89 (594 → 533 ns)
async post(json=...), forwarding 0.79 to 0.87 (871 → 757 ns)
sync get(params=..., timeout=...), forwarding 0.93 to 0.95
full calls on MockTransport (sync/async get and post) 0.95 to 1.01, within noise

The first cut checked for streaming bodies with three dict.get calls on every request, which made
get(params=..., timeout=...) about 5% slower than main (ratio 1.05). It now tests whether
content, data or files is present before looking any of them up.

Behaviour change for the 0.22.0 notes

None for callers. The one visible difference is where an unknown keyword to stream() raises (on
entering the context manager instead of at the call). The message text is unchanged for Client
and AsyncClient instances; a subclass instance now gets its own class name in the message where
Python used to print the defining class.

Every verb, *_with_response sibling and stream() in both clients now takes **kwargs: Unpack[_RequestOptions] or Unpack[_BodyRequestOptions]. The pass-through layers _request_with_body, _request_with_body_with_response and _assemble_request_kwargs are gone; _request_kwargs drops options left at their default and raises TypeError on an unknown keyword.
@lesnik512
lesnik512 merged commit c924aad into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the refactor/request-options branch October 17, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant