Python: fix: preserve URL query parameters in DefaultHttpRequestHandler - #7765
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds regression coverage and updates the default HTTP request handler to preserve any query string already present in HttpRequestInfo.url when appending query_parameters.
Changes:
- Added an async regression test ensuring URL query strings are preserved and
query_parametersare appended. - Updated the handler to build the final request URL manually (instead of using
httpx’sparams=) to avoid replacing an existing query string.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python/packages/declarative/tests/test_default_http_request_handler.py | Adds regression test validating preservation of URL-embedded query params when appending query_parameters. |
| python/packages/declarative/agent_framework_declarative/_workflows/_http_handler.py | Changes URL construction to append encoded query_parameters without dropping an existing query string. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
The declarative handler passed query_parameters to httpx's params= kwarg, which replaces any query string already present in the URL. URL-embedded parameters (api-version, tenant, ...) were silently dropped when combined with query_parameters. Append encoded query_parameters to the URL's existing query using the same rules as .NET DefaultHttpRequestHandler.ResolveRequestUri: keep existing parameters, use '&' if the URL already contains '?', skip empty keys, and percent-encode via quote_via=quote so spaces encode as %20 (matching Uri.EscapeDataString) rather than form-encoded '+'. The URL is left unchanged when query_parameters is empty or contains only empty keys. Fixes microsoft#7749 (Python side; .NET was already correct)
7bcb9cc to
0edb412
Compare
Python Test Coverage Report •
Python Unit Test Overview
|
||||||||||||||||||||||||||||||
…equestHandler Address moonbox3 review on microsoft#7765: passing params= or omitting it in client.request lets the httpx AsyncClient's params= replace the URL's raw query, so embedding query_parameters in the URL could silently drop the URL-embedded query whenever a caller's AsyncClient(params=...) was supplied. Collect client params, URL-embedded params, and query_parameters into a single explicit params= list (client < URL < query_parameters precedence, with later sources overriding duplicates) and pass that to client.request. URLunsplit strips the raw query from the URL so it is not double-applied, and urlsplit keeps the final query before any #fragment. Regression test: three-source merge preserved; duplicate key overridden; empty keys dropped. Fixes microsoft#7749 review feedback
|
Evan Mattson (@moonbox3) Gentle ping — no rush, just following up on the options above. My lean is (c) (hybrid: HTTPX handles the common no-client-params path; fall back to httpx.Request + client.send when client.params is non-empty), but if you'd rather keep it simpler, (b1) works too. Happy to implement whichever you prefer whenever you get a chance to weigh in. |
|
Manjunath Janardhan (@manjunathshiva), Thanks for investigating. Let’s use a revised (b1): build through Preserve the original query bytes and append explicit parameters, including duplicate keys. Client defaults should apply only when that key is absent from both request-level sources. Pass timeout to Please add raw-query assertions covering encoding, bare flags, and interleaved duplicates, plus coverage that client headers/cookies/auth and timeout survive when client params are configured. Keep the fragment and trailing-? tests, and update the current precedence test to retain both URL and explicit values while excluding the conflicting client default. Thanks for your help with this. |
…ng it
Implements the revised (b1) shape chosen in review: build through
`client.build_request()`, replace only the request URL's query, then
`client.send()`.
Every earlier iteration routed the query through `httpx.QueryParams`, either as
`params=` or by letting the client's own params merge run, which decodes and
re-encodes it. Measured against httpx 0.28.1, that rewrites `%20` in a value as
`+`, expands a bare `download` into `download=`, and reorders interleaved
duplicates -- `x=1&y=2&x=3` becomes `x=1&x=3&y=2` -- even when nothing needed
merging. Base64 and `%3A` round-trip unchanged, so a signature is not rewritten
by itself; a scheme that signs a value holding an encoded space is, and a server
that distinguishes `download` from `download=` or reads repeated keys
positionally sees a different request regardless. With client params configured
it is worse than a rewrite: building with the query still on the URL drops it
outright and leaves only the client param.
The query is now composed here and written over the built request's `raw_path`,
which httpx sends verbatim -- confirmed by reading the request line off a real
socket, not only from a mock transport. `query_parameters` append rather than
replace, so a URL carrying `filter=region&filter=status` plus
`{"filter": "tenant"}` sends all three, matching the .NET handler. Client-level
params are defaults: they apply only for a key absent from both request-level
sources. `timeout` goes to `build_request()`, and building through the client
keeps its headers, cookies and auth.
Composing bytes by hand means escaping what cannot appear in a request target,
so a query holding literal non-ASCII is percent-encoded rather than raising
`UnicodeEncodeError`. The escape set leaves RFC 3986 sub-delims, `:@/?`, bracket
key names and existing `%` escapes alone, so an already-valid query stays
byte-identical.
Checked against a real Azure Blob SAS URL, which returns 200 both before and
after this change: its keys are unique so nothing reorders, and its signature
characters round-trip, so that particular shape was never broken. The test for
it is kept as a guard and says so, alongside a test for the encoded-space case
that the previous implementation did rewrite.
Tests cover the raw query on the wire for encoding, bare flags and interleaved
duplicates, both with and without client params configured; that client headers,
cookies, auth and timeout survive; that a client default applies only when
neither request-level source names the key; that appended keys and values cannot
inject an extra pair; non-ASCII, presigned and encoded-space queries; and the
existing fragment and trailing-`?` cases. The precedence test now asserts both
request-level values survive while the conflicting client default is excluded.
|
Thanks — revised (b1) is in, pushed as
Two things the shape turned up that you should see rather than discover: Composing bytes by hand means owning the escaping, and a URL query holding literal non-ASCII -- And a correction to my own PR description: I had written that the old behaviour "can invalidate a Two things I would rather you decide than assume: The checklist on this PR still says it is not a breaking change. That was true of the first And the escape set in
|
Motivation & Context
When a declarative HTTP action supplies both a URL already containing query parameters and additional
query_parameters, the URL-embedded parameters were silently dropped.https://api.example.test/items?api-version=2025-01-01&tenant=alphaplusquery_parameters={page: 2}would send only?page=2. This breaks any request that combines a pre-built URL with additional parameters — the issue case (api-version / tenant) is the canonical example of a request silently hitting the wrong endpoint.Description & Review Guide
DefaultHttpRequestHandler.send()passedquery_parametersto httpx'sparams=, which replaces anyquery already on the URL, so URL-embedded parameters were dropped. Review then found that every
variant which routes the query through
httpx.QueryParams--params=, or simply letting theclient's own
AsyncClient.paramsmerge run -- also rewrites the query it keeps.What are the major changes?
Implemented as the revised (b1) shape chosen in review: build through
client.build_request(),overwrite only the query portion of the request's
raw_path, thenclient.send(). The URL's ownquery is never parsed and rebuilt, so it reaches the transport byte-for-byte.
Measured on httpx 0.28.1, routing it through
QueryParamsrewrites%20in a value as+,expands a bare
downloadintodownload=, and reorders interleaved duplicates --x=1&y=2&x=3goes out as
x=1&x=3&y=2. Base64 and%3Around-trip unchanged, so a signature is not rewrittenmerely by being base64; a scheme that signs a value holding an encoded space is. With client params
configured it is worse than a rewrite:
build_request()with the query still on the URL returnsjust the client param, dropping the URL's query outright.
Three query sources now compose with defined roles:
percent-escapes.
query_parametersappend rather than replace, so?filter=region&filter=statusplus{"filter": "tenant"}sends all three, matchingResolveRequestUriin .NET.AsyncClient.paramsare defaults: a key applies only when neither request-levelsource mentions it.
Composing bytes by hand means owning the escaping, so a query holding literal non-ASCII is
percent-encoded rather than raising
UnicodeEncodeError, which the previousparams=path masked.The escape set leaves RFC 3986 sub-delims,
:@/?, bracket key names and existing%escapesalone, so an already-valid query -- including a presigned signature -- is untouched.
What is the impact of these changes?
A declarative HTTP action combining a pre-built URL with
query_parametersnow sends everyparameter, with the URL's portion unmodified. That matters for servers that distinguish
downloadfrom
download=, that read repeated keys positionally, or that verify a signature over a valuecontaining an encoded space.
Checked against a real Azure Blob SAS URL rather than asserted: it returns 200 both before and
after this change, because its keys are unique so nothing reorders and its signature characters
round-trip. An earlier draft of this description said the old behaviour could invalidate a
presigned URL, which is too strong for that shape and is corrected here.
Two observable behaviour changes, both requested in review:
query_parametersno longer replaces the URL's value; both are sent. Anyonedepending on the old override behaviour will see a different request.
%20where it previously went out as+.The client contract is otherwise unchanged, and I verified rather than assumed it: client headers,
cookies including path-scoped ones, and auth all still apply;
timeoutreachesrequest.extensions["timeout"]with noUSE_CLIENT_DEFAULTsentinel leaking through; a clientbase_urlstill contributes its path; redirects behave the same becausesend()defaultsfollow_redirectsandauthtoUSE_CLIENT_DEFAULTexactly asrequest()does; and a signinghttpx.Authflow sees the final composed target, because auth runs insidesend()after therewrite.
Only the caller's own URL query is verbatim. Everything appended is escaped, so a
query_parametersvalue or key containing&or=-- or a client default containing them --cannot introduce an extra query pair. That has its own test.
What do you want reviewers to focus on?
The breaking-change question. The checklist box below says this is not a breaking change, which was
true of the first iteration but is arguable now that a colliding key appends instead of overriding.
It is a bug fix toward .NET parity and it is what review asked for, so I have not changed the label
or the title myself -- please say which you want.
Second, the escape set in
_encode_query. It is deliberately permissive so an already-valid queryis untouched, and I checked it is a no-op against percent-escapes,
+, bare flags, bracket keys,every sub-delim and a presigned signature. If you would rather it were stricter, that is a
one-line change.
Test coverage for the above: 1004 passing in
poe test -P declarative, the changed file at 100%line coverage,
poe syntaxandpoe typingclean across all five checkers. One caveat worthstating: of the new tests,
test_client_param_applies_only_when_key_absent_from_both_sourcesalsopasses against the previous implementation, which happened to produce the same bytes for that
input. It pins the defaults-only rule rather than acting as a regression guard.
Related Issue
Fixes #7749 (Python side; .NET was already correct, per the issue)
Contribution Checklist