From 6e888bc8249a52d7432b3619c11ae4113a00f04f Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 18:01:18 -0800 Subject: [PATCH 1/4] Fix URL parameter merging when both URL and params are provided When a URL contains query parameters and additional params are passed via the params argument, the original URL parameters were being dropped instead of being merged together. This was inconsistent with requests library behavior. This fix modifies the URL.__init__() to merge existing query parameters from the URL with new parameters from the params argument, rather than replacing them. When the same key exists in both, the new params take precedence. Examples: - URL("https://api.com/get?page=1", params={"size": 10}) Before: https://api.com/get?size=10 (page lost) After: https://api.com/get?page=1&size=10 (merged) - URL("https://api.com/get?a=old", params={"a": "new"}) After: https://api.com/get?a=new (overridden) Fixes #3621 --- httpx/_urls.py | 27 +++++++++++++++++-- tests/models/test_url.py | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index 301d0874d5..9f76da1170 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -105,13 +105,36 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: kwargs[key] = value.decode("ascii") if "params" in kwargs: - # Replace any "params" keyword with the raw "query" instead. + # Merge any "params" keyword with existing query params. + # + # When a URL already has query parameters and additional params + # are provided, we merge them together rather than replacing. + # This matches the behavior of the requests library. # # Ensure that empty params use `kwargs["query"] = None` rather # than `kwargs["query"] = ""`, so that generated URLs do not # include an empty trailing "?". params = kwargs.pop("params") - kwargs["query"] = None if not params else str(QueryParams(params)) + + # Get existing query params from the URL + if isinstance(url, str): + parsed_url = urlparse(url) + existing_params = QueryParams(parsed_url.query) if parsed_url.query else QueryParams() + elif isinstance(url, URL): + existing_params = url.params + else: + existing_params = QueryParams() + + # Merge existing and new params + if params: + merged_params = existing_params.merge(params) + kwargs["query"] = str(merged_params) if merged_params else None + elif existing_params: + # params is empty but URL has existing params - keep them + kwargs["query"] = str(existing_params) + else: + # Both empty - no query string + kwargs["query"] = None if isinstance(url, str): self._uri_reference = urlparse(url, **kwargs) diff --git a/tests/models/test_url.py b/tests/models/test_url.py index 03072e8f5c..66015e5960 100644 --- a/tests/models/test_url.py +++ b/tests/models/test_url.py @@ -861,3 +861,61 @@ def test_ipv6_url_copy_with_host(url_str, new_host): assert url.host == "::ffff:192.168.0.1" assert url.netloc == b"[::ffff:192.168.0.1]:1234" assert str(url) == "http://[::ffff:192.168.0.1]:1234" + + +def test_url_params_merge_with_existing_query(): + """ + Test that when constructing a URL with both existing query params + and a params argument, they are merged together. + + Regression test for issue #3621. + """ + # URL with existing query params + additional params argument + url = httpx.URL("https://example.com/get?page=post&s=list", params={"pid": 0, "tags": "test"}) + + assert url.path == "/get" + assert "page=post" in str(url) + assert "s=list" in str(url) + assert "pid=0" in str(url) + assert "tags=test" in str(url) + + # Verify all params are present + params = dict(url.params) + assert params == {"page": "post", "s": "list", "pid": "0", "tags": "test"} + + +def test_url_params_override_with_same_key(): + """ + Test that when a URL has existing query params and new params with + the same key are provided, the new params override the old ones. + """ + url = httpx.URL("https://example.com/get?a=old&b=keep", params={"a": "new", "c": "add"}) + + params = dict(url.params) + assert params["a"] == "new" # Overridden + assert params["b"] == "keep" # Preserved + assert params["c"] == "add" # Added + + +def test_url_params_empty_dict_preserves_existing(): + """ + Test that passing an empty params dict doesn't remove existing URL params. + """ + url = httpx.URL("https://example.com/get?x=5&y=6", params={}) + + assert "x=5" in str(url) + assert "y=6" in str(url) + params = dict(url.params) + assert params == {"x": "5", "y": "6"} + + +def test_url_no_existing_params_with_params_arg(): + """ + Test that a URL without existing query params works normally with params argument. + """ + url = httpx.URL("https://example.com/get", params={"a": "1", "b": "2"}) + + assert "a=1" in str(url) + assert "b=2" in str(url) + params = dict(url.params) + assert params == {"a": "1", "b": "2"} From 3ebaa830c5b0ea1c15798ae9d58bacb236fe3819 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 19:36:39 -0800 Subject: [PATCH 2/4] Format code with ruff --- httpx/_urls.py | 6 +++++- tests/models/test_url.py | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index 9f76da1170..d23e42e290 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -119,7 +119,11 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: # Get existing query params from the URL if isinstance(url, str): parsed_url = urlparse(url) - existing_params = QueryParams(parsed_url.query) if parsed_url.query else QueryParams() + existing_params = ( + QueryParams(parsed_url.query) + if parsed_url.query + else QueryParams() + ) elif isinstance(url, URL): existing_params = url.params else: diff --git a/tests/models/test_url.py b/tests/models/test_url.py index 66015e5960..7f1da47ef9 100644 --- a/tests/models/test_url.py +++ b/tests/models/test_url.py @@ -871,7 +871,9 @@ def test_url_params_merge_with_existing_query(): Regression test for issue #3621. """ # URL with existing query params + additional params argument - url = httpx.URL("https://example.com/get?page=post&s=list", params={"pid": 0, "tags": "test"}) + url = httpx.URL( + "https://example.com/get?page=post&s=list", params={"pid": 0, "tags": "test"} + ) assert url.path == "/get" assert "page=post" in str(url) @@ -889,7 +891,9 @@ def test_url_params_override_with_same_key(): Test that when a URL has existing query params and new params with the same key are provided, the new params override the old ones. """ - url = httpx.URL("https://example.com/get?a=old&b=keep", params={"a": "new", "c": "add"}) + url = httpx.URL( + "https://example.com/get?a=old&b=keep", params={"a": "new", "c": "add"} + ) params = dict(url.params) assert params["a"] == "new" # Overridden From a55dc58d270e2f64946a7c44cf0ce2407da739a8 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 19:50:09 -0800 Subject: [PATCH 3/4] Fix URL parameter merge to preserve copy_* method behavior Updated URL.__init__ to properly handle QueryParams objects vs dict params: - When params is a QueryParams object (from copy_remove_param, etc), use it directly without merging - When params is a dict/list, merge with existing URL query parameters - Updated test expectations to reflect merge behavior instead of replacement This fixes the copy_remove_param test failure while maintaining the intended merge behavior for user-provided params, matching requests library behavior. --- httpx/_urls.py | 49 +++++++++++++++++++---------------- tests/models/test_requests.py | 4 +-- tests/models/test_url.py | 4 +-- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index d23e42e290..77662df5a0 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -116,29 +116,34 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: # include an empty trailing "?". params = kwargs.pop("params") - # Get existing query params from the URL - if isinstance(url, str): - parsed_url = urlparse(url) - existing_params = ( - QueryParams(parsed_url.query) - if parsed_url.query - else QueryParams() - ) - elif isinstance(url, URL): - existing_params = url.params + # If params is a QueryParams object, use it directly + # (for copy_* methods like copy_remove_param) + if isinstance(params, QueryParams): + kwargs["query"] = str(params) if params else None else: - existing_params = QueryParams() - - # Merge existing and new params - if params: - merged_params = existing_params.merge(params) - kwargs["query"] = str(merged_params) if merged_params else None - elif existing_params: - # params is empty but URL has existing params - keep them - kwargs["query"] = str(existing_params) - else: - # Both empty - no query string - kwargs["query"] = None + # Get existing query params from the URL + if isinstance(url, str): + parsed_url = urlparse(url) + existing_params = ( + QueryParams(parsed_url.query) + if parsed_url.query + else QueryParams() + ) + elif isinstance(url, URL): + existing_params = url.params + else: + existing_params = QueryParams() + + # Merge existing and new params + if params: + merged_params = existing_params.merge(params) + kwargs["query"] = str(merged_params) if merged_params else None + elif existing_params: + # params is empty but URL has existing params - keep them + kwargs["query"] = str(existing_params) + else: + # Both empty - no query string + kwargs["query"] = None if isinstance(url, str): self._uri_reference = urlparse(url, **kwargs) diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py index b31fe007be..936ec5137d 100644 --- a/tests/models/test_requests.py +++ b/tests/models/test_requests.py @@ -235,7 +235,7 @@ def test_request_params(): request = httpx.Request( "GET", "http://example.com?c=3", params={"a": "1", "b": "2"} ) - assert str(request.url) == "http://example.com?a=1&b=2" + assert str(request.url) == "http://example.com?c=3&a=1&b=2" request = httpx.Request("GET", "http://example.com?a=1", params={}) - assert str(request.url) == "http://example.com" + assert str(request.url) == "http://example.com?a=1" diff --git a/tests/models/test_url.py b/tests/models/test_url.py index 7f1da47ef9..3899187399 100644 --- a/tests/models/test_url.py +++ b/tests/models/test_url.py @@ -159,8 +159,8 @@ def test_url_params(): url = httpx.URL( "https://example.org:123/path/to/somewhere?b=456", params={"a": "123"} ) - assert str(url) == "https://example.org:123/path/to/somewhere?a=123" - assert url.params == httpx.QueryParams({"a": "123"}) + assert str(url) == "https://example.org:123/path/to/somewhere?b=456&a=123" + assert url.params == httpx.QueryParams({"b": "456", "a": "123"}) # Tests for username and password From 648abb1c3a9c0821650b5a9480b35fb4e459a94f Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 22:38:23 -0800 Subject: [PATCH 4/4] Remove unreachable defensive code to improve coverage Removed the else branch handling when url is neither str nor URL, as the type hint enforces url: URL | str. This was unreachable defensive code that was causing a coverage gap. --- httpx/_urls.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index 77662df5a0..1539cbff47 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -129,10 +129,8 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: if parsed_url.query else QueryParams() ) - elif isinstance(url, URL): + else: # isinstance(url, URL) existing_params = url.params - else: - existing_params = QueryParams() # Merge existing and new params if params: