Skip to content

Python: fix: preserve URL query parameters in DefaultHttpRequestHandler - #7765

Open
Manjunath Janardhan (manjunathshiva) wants to merge 8 commits into
microsoft:mainfrom
manjunathshiva:python-declarative-preserve-url-query-7749
Open

Python: fix: preserve URL query parameters in DefaultHttpRequestHandler#7765
Manjunath Janardhan (manjunathshiva) wants to merge 8 commits into
microsoft:mainfrom
manjunathshiva:python-declarative-preserve-url-query-7749

Conversation

@manjunathshiva

Copy link
Copy Markdown
Contributor

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=alpha plus query_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

In DefaultHttpRequestHandler.send() (packages/declarative/_workflows/_http_handler.py), the handler passed query_parameters straight through to httpx's params= keyword argument, which replaces any query string already present in the URL. Build the final URL explicitly instead.

The implementation mirrors the .NET DefaultHttpRequestHandler.ResolveRequestUri behavior exactly:

  • Append URL-encoded query_parameters after existing URL query with &, or start a new query with ? if none.
  • Skip entries with empty keys; leave the URL unchanged when nothing remains.
  • Percent-encode via quote_via=quote so spaces become %20 (matching Uri.EscapeDataString), not form-encoded +. Insertion order is preserved (no sorting), matching .NET's dictionary enumeration.

No change to .NET — its handler already preserves the URL query string.

  • What are the major changes? URL composition in _http_handler.py + one regression test test_query_parameters_preserve_url_query_string covering api-version + tenant + added page.
  • What is the impact of these changes? Declarative HTTP actions that combine URL-embedded query parameters with explicit query_parameters now retain both, matching .NET parity. No behavior change when either side is empty.
  • What do you want reviewers to focus on? Whether the order-preservation choice and the percent-encoding choice (%20 vs +) match the project's cross-language parity expectations.

Related Issue

Fixes #7749 (Python side; .NET was already correct, per the issue)

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.

Copilot AI left a comment

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.

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_parameters are appended.
  • Updated the handler to build the final request URL manually (instead of using httpx’s params=) 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.

@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Aug 19, 2026
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)
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/declarative/agent_framework_declarative/_workflows
   _http_handler.py970100% 
TOTAL47257436490% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9617 36 💤 0 ❌ 0 🔥 2m 7s ⏱️

…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

_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.)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: Python: [Bug]: Declarative HTTP action drops existing URL query parameters

3 participants