Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
from urllib.parse import urlsplit, urlunsplit

import httpx

Expand Down Expand Up @@ -173,12 +174,47 @@ async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
# callers (not just the YAML executor) get sensible defaults.
headers["Content-Type"] = info.body_content_type or "text/plain"

params: Mapping[str, str] | None = info.query_parameters or None
# Merge all three query sources into one ``params=`` list. Passing only
# ``query_parameters`` into ``params=`` (the pre-#7765 bug) drops URL-embedded
# params, and passing nothing (#7765 first iteration) lets the *client*'
# ``AsyncClient.params`` (via ``Client._merge_queryparams``) drop the URL's
# query. Collecting client params + URL params + query_parameters into one
# explicit list preserves every source. Precedence is client (lowest) < URL
# query < ``query_parameters`` (highest): on a duplicate key the later source'
# value replaces the earlier one (dict-of-lists assignment), matching the old
# ``_merge_queryparams.merge(params)`` per-request-wins behavior. urlsplit/
# urlunsplit keeps the final query before any ``#fragment``.
url = info.url
raw = urlsplit(url)
merged: dict[str, list[str]] = {}
overridden: set[str] = set()

def _merge_layer(pairs: list[tuple[str, str]]) -> None:
seen: set[str] = set()
for key, value in pairs:
if key in overridden and key not in seen:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when the same key is already present in the URL? At _http_handler.py:195, the first explicit occurrence resets the list, so ...?filter=region&filter=status plus query_parameters={"filter": "tenant"} sends only filter=tenant. The issue and .NET implementation preserve the existing values and append the new pair. Could the client-default override happen separately, then treat the URL and query_parameters as one request-level list?

@manjunathshiva Manjunath Janardhan (manjunathshiva) Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends on which fix shape you pick on the other thread (verbatim-URL reply directly above — the one about
?term=a%20b round-tripping through QueryParams). In (a) I can switch query_parameters to append-only to match .NET exactly (client params still override URL keys when they collide, per the old _merge_queryparams behaviour). In (b) / (c) the URL stays byte-identical and query_parameters lands as a separate appended delta anyway, so duplicate keys just work. Happy to proceed once the main choice lands.

# First occurrence in this layer overrides the lower-precedence value.
merged[key] = [value]
else:
merged.setdefault(key, []).append(value)
seen.add(key)

_merge_layer(list(client.params.multi_items()))
overridden.update(merged)
_merge_layer(list(httpx.URL(url).params.multi_items()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on the earlier report: this commit preserves client defaults, but could we avoid round-tripping the URL's existing query through httpx.URL.params? Even when client.params and query_parameters are empty, ?term=a%20b&download is sent as ?term=a+b&download=, and interleaved pairs are reordered. That can invalidate presigned URLs or change server interpretation, despite the requirement that an otherwise untouched URL remain unchanged. Could we retain raw.query verbatim and append only the separately encoded additions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both concerns confirmed against httpx 0.28.1 — and they're coupled, so wanted to put the options in front of you before writing code:

What I verified

  • URL(u) parse alone is byte-preserving; any URL(u, params=…) re-encodes through QueryParams, which decodes %20+, expands downloaddownload=, and normalises pair order. copy_with(raw_path=…) bypasses this channel and is verbatim.
  • Client.request/build_request/send all route through Client._merge_queryparams. Empirically: any params value (empty QueryParams(), empty dict) is falsy, so when client.params is non-empty, the merge fires no matter what request-level params are passed — the final URL always ends up re-encoded through QueryParams unless the call goes through params=None.
  • Client._merge_queryparams therefore has three controllable call shapes only:
    1. client.request(..., params=<list-as-strings>) — merged, but re-encoded (what Adding Microsoft SECURITY.MD #2 complains about)
    2. client.request(..., params=None) — client params still merged in (still re-encoded)
    3. bypass client.request entirely: client.send(httpx.Request(method, url_str, headers=..., content=...)) — URL is verbatim (no params merge fires)

Fix shapes I can see

  • (a) params=<merged list> accepting re-encode of the whole query (current state after d81257a). Handles all three sources; comment 1's ?term=a%20b&download round-trip remains. Also has the duplicate-key semantic moonbox3 flagged in Adding Microsoft SECURITY.MD #2.
  • (b) Verbatim URL: build the URL string first by appending urlencoded(client.params MINUS url-keyed collisions) + urlencoded(query_parameters) after the raw query via urlsplit/urlunsplit, then call without params=. Matches .NET ResolveRequestUri byte-for-byte. To avoid _merge_queryparams re-introducing client.params on the dispatch side, either:
    • (b1) use httpx.Request + client.send(request, timeout=...) — verbatim and correct but loses client-level headers/cookies/auth merges (we already rely on none of these on the agent-side call path, though a caller-built AsyncClient(headers=…, cookies=…, auth=…) would see a silent behaviour change), or
    • (b2) temporarily clear client.params under a lock at the send site (thread-safe but still not async-safe across two concurrent sends on the same client — two overlapping sends can leave the client's params stripped).
  • (c) Hybrid: HTTPX stays in control for clients where client.params is empty (99% of devui users — preserves headers/cookies/auth merges, accepts the URL round-trip), switch to httpx.Request + client.send only when client.params is non-empty (accepting the header/cookies/auth loss for that corner).

Tradeoffs

  • Comment 2 ("URL duplicate keys must survive along with appended query_parameters") is trivially fixable in (a) by treating query_parameters as append-only over the URL layer; but (a) can't address comment 1.
  • Comment 1 requires dropping params= at the call site; the only lossless options then are (b1) / (c). (b2) is unsafe.

Which do you want? My lean is (c), falling back to (b1) if you'd rather keep it simple and accept the small behaviour drift for client-configured headers/cookies/auth.

(Also flagging: when client.params is non-empty, client.request(params=None) re-encodes the URL even today on main, before this PR — so strictly, comment 1's ?term=a%20b → ?term=a+b regression is only about the URL+query_parameters path when no client params are set; if you'd like me to narrow the scope and not fix the no-client-params case to verbatim, happy to take that route too.)

overridden.update(merged)
_merge_layer([(key, value) for key, value in info.query_parameters.items() if key])

params: list[tuple[str, Any]] = [(k, v) for k, values in merged.items() for v in values]
if params:
# urlunsplit with an empty query part strips the raw query so it is not
# double-applied; ``params=`` now carries all three sources.
url = urlunsplit((raw.scheme, raw.netloc, raw.path, "", raw.fragment))

response = await client.request(
method=info.method,
url=info.url,
params=params,
url=url,
Comment thread
manjunathshiva marked this conversation as resolved.
params=params or None,
headers=headers or None,
content=content,
timeout=timeout,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,199 @@ def respond(request: httpx.Request) -> httpx.Response:
assert req.url.params.get("q") == "alpha"
assert req.url.params.get("limit") == "5"

@pytest.mark.asyncio
async def test_query_parameters_preserve_url_query_string(self) -> None:
"""query_parameters must be appended, not replace the URL's existing query string.

Regression for https://github.com/microsoft/agent-framework/issues/7749:
passing ``params=`` to httpx replaces the URL's existing query string, and the
handler used to do exactly that — URL parameters (api-version, tenant, ...) were
silently dropped. The .NET DefaultHttpRequestHandler.ResolveRequestUri preserves
them; Python now matches that behavior.
"""
Comment thread
manjunathshiva marked this conversation as resolved.
captured: dict[str, httpx.Request] = {}

def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")

handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items?api-version=2025-01-01&tenant=alpha",
query_parameters={"page": "2"},
)
)
finally:
await handler.aclose()

req = captured["req"]
# URL-supplied params are preserved and query_parameters are appended on top
assert req.url.params.get("api-version") == "2025-01-01"
assert req.url.params.get("tenant") == "alpha"
assert req.url.params.get("page") == "2"

@pytest.mark.asyncio
async def test_query_parameters_append_before_fragment(self) -> None:
"""query_parameters must be inserted before any fragment, not appended after.

Plain string concatenation would produce ``url#frag?key=val`` (an invalid URL
— the query string must come before the fragment). The fix uses urlsplit so
reassembly keeps the fragment last.
"""
captured: dict[str, httpx.Request] = {}

def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")

handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items?api-version=2025-01-01#section",
query_parameters={"page": "2"},
)
)
finally:
await handler.aclose()

req = captured["req"]
assert req.url.params.get("api-version") == "2025-01-01"
assert req.url.params.get("page") == "2"
# The fragment is preserved and stays last
assert str(req.url).endswith("#section")
assert req.url.fragment == "section"

@pytest.mark.asyncio
async def test_query_parameters_when_url_ends_with_question_mark(self) -> None:
"""query_parameters must append cleanly when the URL already ends with ``?``.

urlsplit parses the trailing ``?`` as an empty query part, so the result is
a clean ``?key=val`` rather than a malformed ``?&key=val`` that plain string
concatenation would produce.
"""
captured: dict[str, httpx.Request] = {}

def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")

handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items?",
query_parameters={"page": "2"},
)
)
finally:
await handler.aclose()

req = captured["req"]
assert req.url.params.get("page") == "2"
# Result is a clean query string, not && or && at the start
assert str(req.url) == "https://api.example.test/items?page=2"

@pytest.mark.asyncio
async def test_query_parameters_preserve_client_level_params(self) -> None:
"""Client-level ``AsyncClient.params`` must survive alongside URL-embedded and
``query_parameters`` params.

moonbox3 review on #7765: when a caller-supplied ``AsyncClient`` is built with
``params=``, passing ``params=`` (or nothing) into ``client.request``
re-merges and lets the client's params *replace* the URL's query. The handler
must collect all three sources into one explicit ``params=`` list.
"""
captured: dict[str, httpx.Request] = {}

def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")

client = httpx.AsyncClient(
params={"client-default": "v"},
transport=httpx.MockTransport(respond),
)
handler = DefaultHttpRequestHandler(client=client)
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items?tenant=alpha&page=2",
query_parameters={"limit": "5"},
)
)
finally:
await handler.aclose()
await client.aclose()

req_url = str(captured["req"].url)
for needle in ("client-default=v", "tenant=alpha", "page=2", "limit=5"):
assert needle in req_url, f"missing {needle!r} in {req_url!r}"
# Lower-precedence source's duplicate key is overridden (query_parameters win).
assert "tenant=alpha" in req_url # smoke: distinct keys all survive

@pytest.mark.asyncio
async def test_query_parameters_higher_precedence_than_client_params(self) -> None:
"""On duplicate keys, ``query_parameters`` and URL params override client-defaults."""
captured: dict[str, httpx.Request] = {}

def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")

client = httpx.AsyncClient(
params={"key": "clientval"},
transport=httpx.MockTransport(respond),
)
handler = DefaultHttpRequestHandler(client=client)
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items?key=urlval",
query_parameters={"key": "qpval"},
)
)
finally:
await handler.aclose()
await client.aclose()

req_url = str(captured["req"].url)
assert "key=qpval" in req_url
assert "key=urlval" not in req_url
assert "key=clientval" not in req_url

@pytest.mark.asyncio
async def test_query_parameters_empty_key_skipped(self) -> None:
"""Empty query-parameter keys are dropped (matches .NET ResolveRequestUri)."""
captured: dict[str, httpx.Request] = {}

def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")

handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items",
query_parameters={"": "shouldbedropped", "page": "2"},
)
)
finally:
await handler.aclose()

req_url = str(captured["req"].url)
assert "shouldbedropped" not in req_url
assert "page=2" in req_url

@pytest.mark.asyncio
async def test_body_content_type_forwarded(self) -> None:
captured: dict[str, httpx.Request] = {}
Expand Down
Loading