Skip to content

Commit 6e888bc

Browse files
committed
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
1 parent ae1b9f6 commit 6e888bc

2 files changed

Lines changed: 83 additions & 2 deletions

File tree

httpx/_urls.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,36 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None:
105105
kwargs[key] = value.decode("ascii")
106106

107107
if "params" in kwargs:
108-
# Replace any "params" keyword with the raw "query" instead.
108+
# Merge any "params" keyword with existing query params.
109+
#
110+
# When a URL already has query parameters and additional params
111+
# are provided, we merge them together rather than replacing.
112+
# This matches the behavior of the requests library.
109113
#
110114
# Ensure that empty params use `kwargs["query"] = None` rather
111115
# than `kwargs["query"] = ""`, so that generated URLs do not
112116
# include an empty trailing "?".
113117
params = kwargs.pop("params")
114-
kwargs["query"] = None if not params else str(QueryParams(params))
118+
119+
# Get existing query params from the URL
120+
if isinstance(url, str):
121+
parsed_url = urlparse(url)
122+
existing_params = QueryParams(parsed_url.query) if parsed_url.query else QueryParams()
123+
elif isinstance(url, URL):
124+
existing_params = url.params
125+
else:
126+
existing_params = QueryParams()
127+
128+
# Merge existing and new params
129+
if params:
130+
merged_params = existing_params.merge(params)
131+
kwargs["query"] = str(merged_params) if merged_params else None
132+
elif existing_params:
133+
# params is empty but URL has existing params - keep them
134+
kwargs["query"] = str(existing_params)
135+
else:
136+
# Both empty - no query string
137+
kwargs["query"] = None
115138

116139
if isinstance(url, str):
117140
self._uri_reference = urlparse(url, **kwargs)

tests/models/test_url.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,3 +861,61 @@ def test_ipv6_url_copy_with_host(url_str, new_host):
861861
assert url.host == "::ffff:192.168.0.1"
862862
assert url.netloc == b"[::ffff:192.168.0.1]:1234"
863863
assert str(url) == "http://[::ffff:192.168.0.1]:1234"
864+
865+
866+
def test_url_params_merge_with_existing_query():
867+
"""
868+
Test that when constructing a URL with both existing query params
869+
and a params argument, they are merged together.
870+
871+
Regression test for issue #3621.
872+
"""
873+
# URL with existing query params + additional params argument
874+
url = httpx.URL("https://example.com/get?page=post&s=list", params={"pid": 0, "tags": "test"})
875+
876+
assert url.path == "/get"
877+
assert "page=post" in str(url)
878+
assert "s=list" in str(url)
879+
assert "pid=0" in str(url)
880+
assert "tags=test" in str(url)
881+
882+
# Verify all params are present
883+
params = dict(url.params)
884+
assert params == {"page": "post", "s": "list", "pid": "0", "tags": "test"}
885+
886+
887+
def test_url_params_override_with_same_key():
888+
"""
889+
Test that when a URL has existing query params and new params with
890+
the same key are provided, the new params override the old ones.
891+
"""
892+
url = httpx.URL("https://example.com/get?a=old&b=keep", params={"a": "new", "c": "add"})
893+
894+
params = dict(url.params)
895+
assert params["a"] == "new" # Overridden
896+
assert params["b"] == "keep" # Preserved
897+
assert params["c"] == "add" # Added
898+
899+
900+
def test_url_params_empty_dict_preserves_existing():
901+
"""
902+
Test that passing an empty params dict doesn't remove existing URL params.
903+
"""
904+
url = httpx.URL("https://example.com/get?x=5&y=6", params={})
905+
906+
assert "x=5" in str(url)
907+
assert "y=6" in str(url)
908+
params = dict(url.params)
909+
assert params == {"x": "5", "y": "6"}
910+
911+
912+
def test_url_no_existing_params_with_params_arg():
913+
"""
914+
Test that a URL without existing query params works normally with params argument.
915+
"""
916+
url = httpx.URL("https://example.com/get", params={"a": "1", "b": "2"})
917+
918+
assert "a=1" in str(url)
919+
assert "b=2" in str(url)
920+
params = dict(url.params)
921+
assert params == {"a": "1", "b": "2"}

0 commit comments

Comments
 (0)