Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions python/restate/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"""

from datetime import timedelta
from urllib.parse import quote
import httpx
import typing
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions tests/client.py
Original file line number Diff line number Diff line change
@@ -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"
Loading