diff --git a/getstream/base.py b/getstream/base.py index 4dbd48e1..94a2f5d5 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -152,9 +152,10 @@ def _strip_none(obj): def build_path(path: str, path_params: Optional[Dict[str, Any]]) -> str: if path_params is None: return path - for k, v in path_params.items(): - path_params[k] = quote(str(v), safe="") - return path.format(**path_params) + # Quote into a new dict: the caller's mapping is reused when a request is + # retried, so mutating it would percent-encode the values twice. + quoted = {k: quote(str(v), safe="") for k, v in path_params.items()} + return path.format(**quoted) class ResponseParserMixin: diff --git a/tests/test_retry.py b/tests/test_retry.py index 601ebea0..24015563 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -459,3 +459,49 @@ async def capture_sleep(seconds): assert counter.calls == 2 assert slept == [1.0] # clamped from 5s to max_backoff=1.0 await client.aclose() + + +# ── path parameters ───────────────────────────────────────────────── + + +class PathRecorder(Counter): + def __init__(self, responses): + super().__init__(responses) + self.raw_paths = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.raw_paths.append(request.url.raw_path.decode().split("?")[0]) + return super().__call__(request) + + +def test_retry_does_not_re_encode_path_params(monkeypatch): + """A retried GET sends the same URL path as the first attempt.""" + counter = PathRecorder( + [ + httpx.Response(429, json=rate_limited_body()), + httpx.Response(200, json={}), + ] + ) + client = sync_client(counter, retry=ENABLED, monkeypatch=monkeypatch) + client.get("/api/v2/things/{thing_id}", path_params={"thing_id": "a b:c"}) + assert counter.raw_paths == [ + "/api/v2/things/a%20b%3Ac", + "/api/v2/things/a%20b%3Ac", + ] + + +@pytest.mark.asyncio +async def test_async_retry_does_not_re_encode_path_params(monkeypatch): + counter = PathRecorder( + [ + httpx.Response(429, json=rate_limited_body()), + httpx.Response(200, json={}), + ] + ) + client = async_client(counter, retry=ENABLED, monkeypatch=monkeypatch) + await client.get("/api/v2/things/{thing_id}", path_params={"thing_id": "a b:c"}) + assert counter.raw_paths == [ + "/api/v2/things/a%20b%3Ac", + "/api/v2/things/a%20b%3Ac", + ] + await client.aclose()