From 3c3ab506a1e8a478bdd9be6efddc87fca320d164 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:34:39 -0700 Subject: [PATCH] fix: encode object keys in ingress client URLs --- python/restate/client.py | 5 +-- tests/client.py | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/client.py diff --git a/python/restate/client.py b/python/restate/client.py index 35ed238..7546163 100644 --- a/python/restate/client.py +++ b/python/restate/client.py @@ -13,6 +13,7 @@ """ from datetime import timedelta +from urllib.parse import quote import httpx import typing from contextlib import asynccontextmanager @@ -162,14 +163,14 @@ async def post( verb = "send" if send else "call" endpoint = f"restate/scope/{scope}/{verb}/{service}" if key: - endpoint += f"/{key}" + endpoint += f"/{quote(key, safe='')}" endpoint += f"/{handler}" if send and delay is not None: endpoint = endpoint + f"?delay={delay}" else: endpoint = service if key: - endpoint += f"/{key}" + endpoint += f"/{quote(key, safe='')}" endpoint += f"/{handler}" if send: endpoint += "/send" diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 0000000..96ff228 --- /dev/null +++ b/tests/client.py @@ -0,0 +1,67 @@ +# +# Copyright (c) 2023-2026 - Restate Software, Inc., Restate GmbH +# +# This file is part of the Restate SDK for Python, +# which is released under the MIT license. +# + +"""Ingress client URL tests using an in-memory HTTP transport.""" + +from datetime import timedelta + +import httpx +import pytest + +from restate.client import Client + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +@pytest.mark.anyio +@pytest.mark.parametrize("scope", [None, "tenant"]) +@pytest.mark.parametrize("send", [False, True]) +@pytest.mark.parametrize( + "key, encoded", + [ + ("customer/123", "customer%2F123"), + ("customer?active=true", "customer%3Factive%3Dtrue"), + ("customer#primary", "customer%23primary"), + ("customer%2F123", "customer%252F123"), + ("customer 123", "customer%20123"), + ("customer-123", "customer-123"), + ("café", "caf%C3%A9"), + ], +) +async def test_client_preserves_object_key_in_url(key: str, encoded: str, send: bool, scope: str | None): + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return ( + httpx.Response(200, json={"invocationId": "invocation-123"}) if send else httpx.Response(200, content=b"ok") + ) + + async with httpx.AsyncClient(base_url="http://restate.invalid/", transport=httpx.MockTransport(handle)) as http: + client = Client(http) + if send: + result = await client.generic_send( + "Counter", "add", b"1", key=key, send_delay=timedelta(seconds=2), scope=scope + ) + assert result.invocation_id == "invocation-123" + else: + assert await client.generic_call("Counter", "add", b"1", key=key, scope=scope) == b"ok" + + assert len(requests) == 1 + request = requests[0] + if scope is None: + expected = f"/Counter/{encoded}/add" + ("/send" if send else "") + else: + expected = f"/restate/scope/tenant/{'send' if send else 'call'}/Counter/{encoded}/add" + if send: + expected += "?delay=2000" + assert request.url.raw_path == expected.encode("ascii") + assert request.url.fragment == "" + assert request.content == b"1"