Summary
Query parameters are coerced with str(v), so any non-string value is serialised using Python's repr rules rather than URL conventions. Lists become a URL-encoded Python literal instead of repeated keys, and booleans are sent capitalised. Form data with list values fails outright with an opaque error.
Cause
httpr/__init__.py:357 (repeated at lines 595, 744 and 916 for stream, async request and async stream):
if "params" in kwargs and kwargs["params"] is not None:
kwargs["params"] = {k: str(v) for k, v in kwargs["params"].items()}
str(["a", "b"]) is "['a', 'b']", and str(True) is "True". The Rust side receives IndexMap<String, String>, so a single scalar per key is the only representable shape.
For data, src/lib.rs:475 calls request_builder.form(&form_data) on a serde_json::Value; serde_urlencoded cannot serialise a sequence value and reqwest stores the error until send.
Reproduction
Against a local server echoing the request line:
c = httpr.Client()
c.get(url, params={"tag": ["a", "b"]})
c.get(url, params={"n": 5, "flag": True})
c.post(url, data={"tag": ["a", "b"]})
| call |
httpr |
httpx |
params={"tag": ["a","b"]} |
GET /x?tag=%5B%27a%27%2C+%27b%27%5D ❌ |
GET /x?tag=a&tag=b |
params={"n": 5, "flag": True} |
GET /x?n=5&flag=True ❌ |
GET /x?n=5&flag=true |
data={"tag": ["a","b"]} |
RequestError: builder error ❌ |
sends tag=a&tag=b |
The first row decodes to the literal string ['a', 'b'] as the value of tag.
Impact
Repeated query keys are the standard encoding for list-valued filters (?id=1&id=2, ?fields=a&fields=b), and lowercase true/false is what JSON-oriented APIs expect — flag=True is commonly parsed as a non-empty string, i.e. truthy regardless of intent. Both failures are silent. The data case at least errors, but builder error gives no indication of which parameter is at fault.
Expected
Match httpx: sequence values expand to repeated keys, True/False serialise as true/false, numbers serialise normally.
Proposed fix
Two parts:
- Python (
httpr/__init__.py): replace the {k: str(v)} comprehension with a helper that flattens to a list of (key, value) pairs — expanding list/tuple values into repeated keys and mapping bool to "true"/"false" before falling back to str(). Apply it at all four call sites (they're currently copy-pasted; a shared _normalize_params() would fix that too).
- Rust (
src/lib.rs): accept Vec<(String, String)> for params instead of IndexMapSSR. reqwest's .query() already accepts a slice of pairs and emits repeated keys, so the request-building code barely changes.
Update params in httpr/httpr.pyi (lines 18, 360, 399-401, 451, 552) to a type allowing sequence and scalar values.
Form data with list values can be handled the same way, or — if that's more scope than wanted — at minimum turned into a clear error naming the offending key rather than builder error.
Suggested tests
params={"tag": ["a","b"]} produces ?tag=a&tag=b.
params={"flag": True} produces ?flag=true.
params={"n": 5} produces ?n=5.
- Same coverage via
AsyncClient and via stream(), since the coercion is duplicated in all four places.
Size
~1-2 hours.
Summary
Query parameters are coerced with
str(v), so any non-string value is serialised using Python'sreprrules rather than URL conventions. Lists become a URL-encoded Python literal instead of repeated keys, and booleans are sent capitalised. Formdatawith list values fails outright with an opaque error.Cause
httpr/__init__.py:357(repeated at lines 595, 744 and 916 forstream, asyncrequestand asyncstream):str(["a", "b"])is"['a', 'b']", andstr(True)is"True". The Rust side receivesIndexMap<String, String>, so a single scalar per key is the only representable shape.For
data,src/lib.rs:475callsrequest_builder.form(&form_data)on aserde_json::Value;serde_urlencodedcannot serialise a sequence value and reqwest stores the error until send.Reproduction
Against a local server echoing the request line:
params={"tag": ["a","b"]}GET /x?tag=%5B%27a%27%2C+%27b%27%5D❌GET /x?tag=a&tag=bparams={"n": 5, "flag": True}GET /x?n=5&flag=True❌GET /x?n=5&flag=truedata={"tag": ["a","b"]}RequestError: builder error❌tag=a&tag=bThe first row decodes to the literal string
['a', 'b']as the value oftag.Impact
Repeated query keys are the standard encoding for list-valued filters (
?id=1&id=2,?fields=a&fields=b), and lowercasetrue/falseis what JSON-oriented APIs expect —flag=Trueis commonly parsed as a non-empty string, i.e. truthy regardless of intent. Both failures are silent. Thedatacase at least errors, butbuilder errorgives no indication of which parameter is at fault.Expected
Match
httpx: sequence values expand to repeated keys,True/Falseserialise astrue/false, numbers serialise normally.Proposed fix
Two parts:
httpr/__init__.py): replace the{k: str(v)}comprehension with a helper that flattens to a list of(key, value)pairs — expandinglist/tuplevalues into repeated keys and mappingboolto"true"/"false"before falling back tostr(). Apply it at all four call sites (they're currently copy-pasted; a shared_normalize_params()would fix that too).src/lib.rs): acceptVec<(String, String)>forparamsinstead ofIndexMapSSR.reqwest's.query()already accepts a slice of pairs and emits repeated keys, so the request-building code barely changes.Update
paramsinhttpr/httpr.pyi(lines 18, 360, 399-401, 451, 552) to a type allowing sequence and scalar values.Form
datawith list values can be handled the same way, or — if that's more scope than wanted — at minimum turned into a clear error naming the offending key rather thanbuilder error.Suggested tests
params={"tag": ["a","b"]}produces?tag=a&tag=b.params={"flag": True}produces?flag=true.params={"n": 5}produces?n=5.AsyncClientand viastream(), since the coercion is duplicated in all four places.Size
~1-2 hours.