From 956432c5c08112ff4e924cac7d9be02347b23a30 Mon Sep 17 00:00:00 2001 From: Arthur Depasse Date: Mon, 7 Sep 2026 15:24:37 +0000 Subject: [PATCH 1/4] replace httpx with httpx2 (cherry picked from commit fcb9991f33cbf061a7e79aea03f7814ef60d3ae7) --- pyproject.toml | 4 ++-- python/restate/client.py | 6 ++--- python/restate/harness.py | 12 +++++----- tests/servercontext.py | 4 ++-- uv.lock | 48 ++++++++++++++++++++++++++------------- 5 files changed, 45 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 13b86df0..ea5a4ffe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,9 @@ Source = "https://github.com/restatedev/sdk-python" [project.optional-dependencies] test = ["pytest", "hypercorn", "anyio"] lint = ["mypy>=1.11.2", "pyright>=1.1.390", "ruff>=0.6.9"] -harness = ["testcontainers", "hypercorn", "httpx"] +harness = ["testcontainers", "hypercorn", "httpx2[http2]>=2.12,<3"] serde = ["dacite", "pydantic", "msgspec"] -client = ["httpx[http2]"] +client = ["httpx2[http2]>=2.12,<3"] adk = ["google-adk>=1.20.0"] openai = ["openai-agents>=0.6.1"] pydantic_ai = ["pydantic-ai-slim>=2.0,<3"] diff --git a/python/restate/client.py b/python/restate/client.py index 35ed2382..8e7cdbaf 100644 --- a/python/restate/client.py +++ b/python/restate/client.py @@ -13,7 +13,7 @@ """ from datetime import timedelta -import httpx +import httpx2 import typing from contextlib import asynccontextmanager @@ -35,7 +35,7 @@ class Client(RestateClient): def __init__( self, - client: httpx.AsyncClient, + client: httpx2.AsyncClient, headers: typing.Optional[dict] = None, journal_codec: typing.Optional[JournalValueCodec] = None, ): @@ -497,5 +497,5 @@ async def create_client( call responses decoded through it. It must match the codec configured on the endpoint. NOTE: This is experimental and may change in future releases. """ - async with httpx.AsyncClient(base_url=ingress, headers=headers, http2=True) as http_client: + async with httpx2.AsyncClient(base_url=ingress, headers=headers, http2=True) as http_client: yield Client(http_client, headers, journal_codec=journal_value_codec) diff --git a/python/restate/harness.py b/python/restate/harness.py index 7a2c7394..303fa0e7 100644 --- a/python/restate/harness.py +++ b/python/restate/harness.py @@ -27,7 +27,7 @@ from testcontainers.core.container import DockerContainer # type: ignore from testcontainers.core.wait_strategies import CompositeWaitStrategy, HttpWaitStrategy -import httpx +import httpx2 def find_free_port(): @@ -170,12 +170,12 @@ def admin_url(self): return f"http://{self.get_container_host_ip()}:{self.get_exposed_port(9070)}" def get_admin_client(self): - """return an httpx client to access the admin interface""" - return httpx.Client(base_url=self.admin_url()) + """return an HTTPX2 client to access the admin interface""" + return httpx2.Client(base_url=self.admin_url()) def get_ingress_client(self): - """return an httpx client to access the ingress interface""" - return httpx.Client(base_url=self.ingress_url()) + """return an HTTPX2 client to access the ingress interface""" + return httpx2.Client(base_url=self.ingress_url()) def start(self, stream_logs=False): """start the container and wait for health checks to pass""" @@ -254,7 +254,7 @@ def stop(self): self.bind_address.cleanup() def ingress_client(self): - """return an httpx client to access the restate server's ingress""" + """return an HTTPX2 client to access the restate server's ingress""" if self.restate is None: raise AssertionError("The Restate server has not been started. Use .start()") return self.restate.get_ingress_client() diff --git a/tests/servercontext.py b/tests/servercontext.py index 4a30de4a..25288283 100644 --- a/tests/servercontext.py +++ b/tests/servercontext.py @@ -90,7 +90,7 @@ async def test_retryable_exception(): invocation_retry_policy=InvocationRetryPolicy( max_attempts=3, # Something really long to trigger a test timeout. - # Default httpx client timeout is 5 seconds. + # Default HTTPX2 client timeout is 5 seconds. initial_interval=timedelta(hours=1), ), ) @@ -120,7 +120,7 @@ async def test_accidentally_wrapped_retryable_exception(): invocation_retry_policy=InvocationRetryPolicy( max_attempts=3, # Something really long to trigger a test timeout. - # Default httpx client timeout is 5 seconds. + # Default HTTPX2 client timeout is 5 seconds. initial_interval=timedelta(hours=1), ), ) diff --git a/uv.lock b/uv.lock index 948f4614..bc93bd70 100644 --- a/uv.lock +++ b/uv.lock @@ -1228,15 +1228,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.3.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb" }, ] [[package]] @@ -1282,17 +1282,33 @@ wheels = [ [[package]] name = "httpx2" -version = "2.3.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32" }, ] [[package]] @@ -1325,11 +1341,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4" }, ] [[package]] @@ -2898,10 +2914,10 @@ adk = [ { name = "google-adk" }, ] client = [ - { name = "httpx", extra = ["http2"] }, + { name = "httpx2", extra = ["http2"] }, ] harness = [ - { name = "httpx" }, + { name = "httpx2", extra = ["http2"] }, { name = "hypercorn" }, { name = "testcontainers" }, ] @@ -2944,8 +2960,8 @@ requires-dist = [ { name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'zstd'" }, { name = "dacite", marker = "extra == 'serde'" }, { name = "google-adk", marker = "extra == 'adk'", specifier = ">=1.20.0" }, - { name = "httpx", marker = "extra == 'harness'" }, - { name = "httpx", extras = ["http2"], marker = "extra == 'client'" }, + { name = "httpx2", extras = ["http2"], marker = "extra == 'client'", specifier = ">=2.12,<3" }, + { name = "httpx2", extras = ["http2"], marker = "extra == 'harness'", specifier = ">=2.12,<3" }, { name = "hypercorn", marker = "extra == 'harness'" }, { name = "hypercorn", marker = "extra == 'test'" }, { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.0.0" }, From 9db7aff8b1e321bec89281599e660e2de8460c88 Mon Sep 17 00:00:00 2001 From: Maksim Zayats <49313691+maksimzayats@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:06:41 +0300 Subject: [PATCH 2/4] Add optional httpx2 support to the ingress client Accept structural HTTP clients without importing either HTTP package. Keep httpx as the factory default and add explicit httpx2 selection with a separate optional extra. Restore the harness and existing extras to their upstream behavior. Cover backend selection, request and error behavior, imports, and types. Refs #247 --- README.md | 33 +++ pyproject.toml | 5 +- python/restate/__init__.py | 19 +- python/restate/client.py | 32 ++- python/restate/client_types.py | 22 ++ python/restate/harness.py | 12 +- tests/client.py | 353 +++++++++++++++++++++++++++++++++ tests/servercontext.py | 4 +- uv.lock | 100 ++++++++-- 9 files changed, 530 insertions(+), 50 deletions(-) create mode 100644 tests/client.py diff --git a/README.md b/README.md index e2b6e256..1eabee59 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,39 @@ To use this SDK, add the dependency to your project: pip install restate_sdk ``` +### Ingress client + +The ingress client factory uses httpx by default. Install its optional dependency with: + +```shell +python -m pip install "restate-sdk[client]" +``` + +```python +from restate import create_client + +async with create_client("http://localhost:8080") as client: + ... +``` + +To use httpx2, install `restate-sdk[client-httpx2]` and select it explicitly: + +```python +async with create_client("http://localhost:8080", backend="httpx2") as client: + ... +``` + +The factory enables HTTP/2 and closes the HTTP client on exit. It requires the +selected package and does not fall back to another package. + +You can also pass an existing async HTTP client to `restate.client.Client`. +The `HttpClient` and `HttpResponse` protocols in `restate.client_types` describe +the required interface. They are also available from `restate`. Both +`httpx.AsyncClient` and `httpx2.AsyncClient` satisfy this interface. `Client` +keeps the supplied object and leaves its lifetime to the caller. Importing +`Client` requires neither HTTP package. Transport errors keep the supplied +client's exception types. + ## Versions The compatibility with Restate is described in the following table. sdk-python versions < 0.10 are deprecated and no longer supported. diff --git a/pyproject.toml b/pyproject.toml index ea5a4ffe..15bf07b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,9 +23,10 @@ Source = "https://github.com/restatedev/sdk-python" [project.optional-dependencies] test = ["pytest", "hypercorn", "anyio"] lint = ["mypy>=1.11.2", "pyright>=1.1.390", "ruff>=0.6.9"] -harness = ["testcontainers", "hypercorn", "httpx2[http2]>=2.12,<3"] +harness = ["testcontainers", "hypercorn", "httpx"] serde = ["dacite", "pydantic", "msgspec"] -client = ["httpx2[http2]>=2.12,<3"] +client = ["httpx[http2]"] +client-httpx2 = ["httpx2[http2]>=2.12,<3"] adk = ["google-adk>=1.20.0"] openai = ["openai-agents>=0.6.1"] pydantic_ai = ["pydantic-ai-slim>=2.0,<3"] diff --git a/python/restate/__init__.py b/python/restate/__init__.py index 3b27a446..e1f48f5b 100644 --- a/python/restate/__init__.py +++ b/python/restate/__init__.py @@ -26,7 +26,7 @@ from .context import Context, ObjectContext, ObjectSharedContext from .context import WorkflowContext, WorkflowSharedContext from .retry_policy import InvocationRetryPolicy -from .client_types import RestateClient, HttpError, RestateClientSendHandle +from .client_types import HttpClient, HttpResponse, RestateClient, HttpError, RestateClientSendHandle # pylint: disable=line-too-long from .context import ( @@ -76,20 +76,7 @@ def test_harness( raise ImportError("Install restate-sdk[harness] to use this feature") # type: ignore -try: - from .client import create_client -except ImportError: - # we don't have the appropriate dependencies installed - - @asynccontextmanager - async def create_client( - ingress: str, - headers: typing.Optional[dict] = None, - journal_value_codec: typing.Optional[JournalValueCodec] = None, - ) -> typing.AsyncGenerator[RestateClient, None]: - """a dummy client constructor to raise ImportError. Install restate-sdk[client] to use this feature""" - raise ImportError("Install restate-sdk[client] to use this feature") - yield # type: ignore +from .client import create_client __all__ = [ @@ -128,5 +115,7 @@ async def create_client( "RestateClient", "RestateClientSendHandle", "HttpError", + "HttpClient", + "HttpResponse", "create_client", ] diff --git a/python/restate/client.py b/python/restate/client.py index 8e7cdbaf..bdbe1def 100644 --- a/python/restate/client.py +++ b/python/restate/client.py @@ -13,11 +13,10 @@ """ from datetime import timedelta -import httpx2 import typing from contextlib import asynccontextmanager -from .client_types import RestateClient, RestateClientSendHandle, RestateScopedClient, HttpError +from .client_types import HttpClient, RestateClient, RestateClientSendHandle, RestateScopedClient, HttpError from .context import HandlerType from .entry_codec import JournalValueCodec @@ -35,7 +34,7 @@ class Client(RestateClient): def __init__( self, - client: httpx2.AsyncClient, + client: HttpClient, headers: typing.Optional[dict] = None, journal_codec: typing.Optional[JournalValueCodec] = None, ): @@ -486,6 +485,8 @@ async def create_client( ingress: str, headers: typing.Optional[dict] = None, journal_value_codec: typing.Optional[JournalValueCodec] = None, + *, + backend: typing.Literal["httpx", "httpx2"] = "httpx", ) -> typing.AsyncGenerator[RestateClient, None]: """ Create a new Restate client. @@ -496,6 +497,29 @@ async def create_client( journal_value_codec: Optional journal value codec. When set, request bodies are encoded and call responses decoded through it. It must match the codec configured on the endpoint. NOTE: This is experimental and may change in future releases. + backend: HTTP package to use. Defaults to httpx. Install restate-sdk[client] for httpx + or restate-sdk[client-httpx2] for httpx2. The selected package must be installed. """ - async with httpx2.AsyncClient(base_url=ingress, headers=headers, http2=True) as http_client: + if backend not in ("httpx", "httpx2"): + raise ValueError(f"Unsupported HTTP backend: {backend!r}. Expected 'httpx' or 'httpx2'.") + + http_client_context: typing.AsyncContextManager[HttpClient] + try: + if backend == "httpx": + import httpx + + http_client_context = httpx.AsyncClient(base_url=ingress, headers=headers, http2=True) + else: + import httpx2 + + http_client_context = httpx2.AsyncClient(base_url=ingress, headers=headers, http2=True) + except ModuleNotFoundError as exc: + if exc.name != backend: + raise + extra = "client" if backend == "httpx" else "client-httpx2" + raise ImportError( + f'The {backend} backend requires {backend}. Install it with: python -m pip install "restate-sdk[{extra}]"' + ) from exc + + async with http_client_context as http_client: yield Client(http_client, headers, journal_codec=journal_value_codec) diff --git a/python/restate/client_types.py b/python/restate/client_types.py index 5908f7e3..3489e5b2 100644 --- a/python/restate/client_types.py +++ b/python/restate/client_types.py @@ -23,6 +23,28 @@ O = typing.TypeVar("O") +class HttpResponse(typing.Protocol): + """The buffered HTTP response used by the ingress client.""" + + @property + def status_code(self) -> int: ... + + @property + def reason_phrase(self) -> str: ... + + @property + def text(self) -> str: ... + + @property + def content(self) -> bytes: ... + + +class HttpClient(typing.Protocol): + """An HTTP client that can send ingress requests asynchronously.""" + + async def post(self, url: str, *, headers: dict[str, str], content: bytes) -> HttpResponse: ... + + class RestateClientSendHandle: """ A handle for a send operation. diff --git a/python/restate/harness.py b/python/restate/harness.py index 303fa0e7..7a2c7394 100644 --- a/python/restate/harness.py +++ b/python/restate/harness.py @@ -27,7 +27,7 @@ from testcontainers.core.container import DockerContainer # type: ignore from testcontainers.core.wait_strategies import CompositeWaitStrategy, HttpWaitStrategy -import httpx2 +import httpx def find_free_port(): @@ -170,12 +170,12 @@ def admin_url(self): return f"http://{self.get_container_host_ip()}:{self.get_exposed_port(9070)}" def get_admin_client(self): - """return an HTTPX2 client to access the admin interface""" - return httpx2.Client(base_url=self.admin_url()) + """return an httpx client to access the admin interface""" + return httpx.Client(base_url=self.admin_url()) def get_ingress_client(self): - """return an HTTPX2 client to access the ingress interface""" - return httpx2.Client(base_url=self.ingress_url()) + """return an httpx client to access the ingress interface""" + return httpx.Client(base_url=self.ingress_url()) def start(self, stream_logs=False): """start the container and wait for health checks to pass""" @@ -254,7 +254,7 @@ def stop(self): self.bind_address.cleanup() def ingress_client(self): - """return an HTTPX2 client to access the restate server's ingress""" + """return an httpx client to access the restate server's ingress""" if self.restate is None: raise AssertionError("The Restate server has not been started. Use .start()") return self.restate.get_ingress_client() diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 00000000..3c1384ec --- /dev/null +++ b/tests/client.py @@ -0,0 +1,353 @@ +# +# 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. +# +# You can find a copy of the license in file LICENSE in the root +# directory of this repository or package, or at +# https://github.com/restatedev/sdk-typescript/blob/main/LICENSE +# +"""Tests for optional ingress HTTP clients.""" + +import builtins +from dataclasses import dataclass +from datetime import timedelta +import subprocess +import sys +from types import ModuleType +import typing +from unittest.mock import Mock + +import httpx +import httpx2 +import pytest + +import restate +from restate import HttpClient, HttpError, HttpResponse, JournalValueCodec +from restate.client import Client, create_client +from restate.serde import JsonSerde + + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" + + +pytestmark = pytest.mark.anyio + + +@dataclass +class StubResponse: + status_code: int = 200 + reason_phrase: str = "OK" + text: str = '"pong"' + content: bytes = b'"pong"' + + +class StubClient: + def __init__(self, response: HttpResponse): + self.response = response + self.calls: list[tuple[str, dict[str, str], bytes]] = [] + + async def post(self, url: str, *, headers: dict[str, str], content: bytes) -> HttpResponse: + self.calls.append((url, headers, content)) + return self.response + + +class PrefixCodec(JournalValueCodec): + def encode(self, buf: bytes) -> bytes: + return b"codec:" + buf + + async def decode(self, buf: bytes) -> bytes: + assert buf.startswith(b"codec:") + return buf[len(b"codec:") :] + + +@pytest.mark.parametrize("blocked", [("httpx",), ("httpx", "httpx2")]) +def test_import_without_http_packages(blocked: tuple[str, ...]): + code = f""" +import sys +for name in {blocked!r}: + sys.modules[name] = None +from restate.client import Client, create_client +from restate import HttpClient, HttpResponse +import restate +assert restate.create_client is create_client +assert restate.HttpClient is HttpClient +assert restate.HttpResponse is HttpResponse +for name in {blocked!r}: + assert sys.modules[name] is None +""" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +async def test_supplied_client_request_and_codec(): + codec = PrefixCodec() + http_client = StubClient(StubResponse(content=codec.encode(b'"pong"'))) + client = Client(http_client, journal_codec=codec) + assert client.client is http_client + headers = {"X-Test": "value"} + + result = await client.do_raw_call( + service="greeter", + handler="greet", + input_param="ping", + input_serde=JsonSerde[str](), + output_serde=JsonSerde[str](), + key="object", + scope="tenant", + headers=headers, + idempotency_key="request-1", + limit_key="customer/user", + ) + + assert result == "pong" + assert http_client.calls == [ + ( + "restate/scope/tenant/call/greeter/object/greet", + {"X-Test": "value", "Idempotency-Key": "request-1", "x-restate-limit-key": "customer/user"}, + b'codec:"ping"', + ) + ] + assert headers == {"X-Test": "value"} + + +async def test_supplied_client_send(): + http_client = StubClient(StubResponse(content=b'{"invocationId": "inv_123"}')) + client = Client(http_client, journal_codec=PrefixCodec()) + result = await client.do_raw_call( + service="greeter", + handler="greet", + input_param="ping", + input_serde=JsonSerde[str](), + output_serde=JsonSerde[dict](), + send=True, + send_delay=timedelta(seconds=2), + ) + assert result == {"invocationId": "inv_123"} + assert http_client.calls == [("greeter/greet/send?delay=2000", {}, b'codec:"ping"')] + + +@pytest.mark.parametrize("status_code", [400, 503]) +async def test_http_error_fields(status_code: int): + response = StubResponse(status_code, "custom reason", "exact body: ü\n") + with pytest.raises(HttpError) as error: + await Client(StubClient(response)).post("service", "handler", False, b"input") + assert error.value.status_code == status_code + assert error.value.message == response.reason_phrase + assert error.value.body == response.text + assert str(error.value) == f"HTTP {status_code}: custom reason" + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize("status_code", [200, 400, 503]) +async def test_real_http_clients(backend: typing.Literal["httpx", "httpx2"], status_code: int): + requests: list[tuple[str, dict[str, str], bytes]] = [] + + def respond_httpx(request: httpx.Request) -> httpx.Response: + requests.append((str(request.url), dict(request.headers), request.content)) + response = httpx.Response(status_code, content=b'"pong"') + typed_response: HttpResponse = response + assert typed_response.content == b'"pong"' + return response + + def respond_httpx2(request: httpx2.Request) -> httpx2.Response: + requests.append((str(request.url), dict(request.headers), request.content)) + response = httpx2.Response(status_code, content=b'"pong"') + typed_response: HttpResponse = response + assert typed_response.content == b'"pong"' + return response + + # These assignments check both real client types against the protocol in Mypy and Pyright. + context: typing.AsyncContextManager[HttpClient] + if backend == "httpx": + httpx_client: HttpClient = httpx.AsyncClient( + base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx.MockTransport(respond_httpx) + ) + assert isinstance(httpx_client, httpx.AsyncClient) + context = httpx_client + else: + httpx2_client: HttpClient = httpx2.AsyncClient( + base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx2.MockTransport(respond_httpx2) + ) + assert isinstance(httpx2_client, httpx2.AsyncClient) + context = httpx2_client + + async with context as supplied: + client = Client(supplied) + assert client.client is supplied + if status_code >= 400: + with pytest.raises(HttpError) as error: + await client.post("service", "handler", False, b"input") + assert error.value.status_code == status_code + assert error.value.message == ("Bad Request" if status_code == 400 else "Service Unavailable") + assert error.value.body == '"pong"' + else: + result = await client.do_raw_call( + "service", "handler", "ping", JsonSerde[str](), JsonSerde[str](), headers={"X-Call": "call"} + ) + assert result == "pong" + + url, headers, content = requests[0] + assert url == "http://ingress/service/handler" + assert headers["x-default"] == "default" + if status_code == 200: + assert headers["x-call"] == "call" + assert content == b'"ping"' + else: + assert content == b"input" + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize("failure", ["timeout", "transport"]) +async def test_transport_errors_propagate(backend: typing.Literal["httpx", "httpx2"], failure: str): + httpx_error = httpx.ReadTimeout("timeout") if failure == "timeout" else httpx.ConnectError("connection") + httpx2_error = httpx2.ReadTimeout("timeout") if failure == "timeout" else httpx2.ConnectError("connection") + + def fail_httpx(request: httpx.Request) -> httpx.Response: + raise httpx_error + + def fail_httpx2(request: httpx2.Request) -> httpx2.Response: + raise httpx2_error + + context: typing.AsyncContextManager[HttpClient] + if backend == "httpx": + context = httpx.AsyncClient(base_url="http://ingress/", transport=httpx.MockTransport(fail_httpx)) + expected: Exception = httpx_error + else: + context = httpx2.AsyncClient(base_url="http://ingress/", transport=httpx2.MockTransport(fail_httpx2)) + expected = httpx2_error + async with context as supplied: + with pytest.raises(type(expected)) as error: + await Client(supplied).post("service", "handler", False, b"input") + assert error.value is expected + + +class FactoryHttpClient(StubClient): + def __init__(self, *, base_url: str, headers: dict | None, http2: bool): + super().__init__(StubResponse()) + self.options = (base_url, headers, http2) + self.entered = False + self.closed = False + self.exit_exception: BaseException | None = None + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, exc_type, exc, traceback): + self.closed = True + self.exit_exception = exc + + +@pytest.fixture +def factory_backends(monkeypatch: pytest.MonkeyPatch) -> dict[str, Mock]: + constructors = {} + for backend in ("httpx", "httpx2"): + constructor = Mock(side_effect=FactoryHttpClient) + module = ModuleType(backend) + module.AsyncClient = constructor # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, backend, module) + constructors[backend] = constructor + return constructors + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize("available", [(), ("httpx",), ("httpx2",), ("httpx", "httpx2")]) +async def test_factory_selection( + backend: typing.Literal["httpx", "httpx2"], + available: tuple[str, ...], + factory_backends: dict[str, Mock], + monkeypatch: pytest.MonkeyPatch, +): + for name in ("httpx", "httpx2"): + if name not in available: + monkeypatch.setitem(sys.modules, name, None) # type: ignore[arg-type] + if backend not in available: + with pytest.raises(ImportError) as error: + async with create_client("http://ingress/", backend=backend): + pytest.fail("Missing backend was accepted") + extra = "client" if backend == "httpx" else "client-httpx2" + assert f'python -m pip install "restate-sdk[{extra}]"' in str(error.value) + assert isinstance(error.value.__cause__, ModuleNotFoundError) + assert error.value.__cause__.name == backend + assert not any(constructor.called for constructor in factory_backends.values()) + return + + headers = {"X-Default": "default"} + codec = PrefixCodec() + async with create_client("http://ingress/", headers, codec, backend=backend) as client: + assert isinstance(client, Client) + supplied = client.client + assert isinstance(supplied, FactoryHttpClient) + assert supplied.entered and not supplied.closed + assert supplied.options == ("http://ingress/", headers, True) + assert client.headers is headers + assert client.journal_codec is codec + assert supplied.closed + factory_backends[backend].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + other = "httpx2" if backend == "httpx" else "httpx" + factory_backends[other].assert_not_called() + + +async def test_default_factory_and_positional_arguments(factory_backends: dict[str, Mock]): + headers = {"X-Default": "value"} + codec = PrefixCodec() + async with restate.create_client("http://ingress/", headers, codec) as client: + assert isinstance(client, Client) + assert client.headers is headers + assert client.journal_codec is codec + factory_backends["httpx"].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + factory_backends["httpx2"].assert_not_called() + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +async def test_factory_cleanup_on_error(backend: typing.Literal["httpx", "httpx2"], factory_backends: dict[str, Mock]): + failure = RuntimeError("body failed") + supplied: HttpClient | None = None + with pytest.raises(RuntimeError) as error: + async with create_client("http://ingress/", backend=backend) as client: + assert isinstance(client, Client) + supplied = client.client + assert isinstance(supplied, FactoryHttpClient) + raise failure + assert error.value is failure + assert isinstance(supplied, FactoryHttpClient) + assert supplied.closed + assert supplied.exit_exception is failure + + +async def test_invalid_backend_and_keyword_only(factory_backends: dict[str, Mock]): + with pytest.raises(ValueError, match="Unsupported HTTP backend: 'unknown'"): + async with create_client("http://ingress/", backend="unknown"): # type: ignore[arg-type] + pytest.fail("Invalid backend was accepted") + with pytest.raises(TypeError): + create_client("http://ingress/", None, None, "httpx2") # type: ignore[misc] + assert not any(constructor.called for constructor in factory_backends.values()) + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize( + "failure", [ModuleNotFoundError("missing dependency", name="dependency"), ImportError("broken")] +) +async def test_broken_backend_import_propagates( + backend: typing.Literal["httpx", "httpx2"], + failure: ImportError, + factory_backends: dict[str, Mock], + monkeypatch: pytest.MonkeyPatch, +): + original_import = builtins.__import__ + + def import_backend(name, *args, **kwargs): + if name == backend: + raise failure + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_backend) + with pytest.raises(type(failure)) as error: + async with create_client("http://ingress/", backend=backend): + pytest.fail("Broken import was accepted") + assert error.value is failure + assert not any(constructor.called for constructor in factory_backends.values()) diff --git a/tests/servercontext.py b/tests/servercontext.py index 25288283..4a30de4a 100644 --- a/tests/servercontext.py +++ b/tests/servercontext.py @@ -90,7 +90,7 @@ async def test_retryable_exception(): invocation_retry_policy=InvocationRetryPolicy( max_attempts=3, # Something really long to trigger a test timeout. - # Default HTTPX2 client timeout is 5 seconds. + # Default httpx client timeout is 5 seconds. initial_interval=timedelta(hours=1), ), ) @@ -120,7 +120,7 @@ async def test_accidentally_wrapped_retryable_exception(): invocation_retry_policy=InvocationRetryPolicy( max_attempts=3, # Something really long to trigger a test timeout. - # Default HTTPX2 client timeout is 5 seconds. + # Default httpx client timeout is 5 seconds. initial_interval=timedelta(hours=1), ), ) diff --git a/uv.lock b/uv.lock index bc93bd70..1305dbb1 100644 --- a/uv.lock +++ b/uv.lock @@ -479,7 +479,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -505,7 +505,8 @@ name = "genai-prices" version = "0.0.71" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx2" }, + { name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "httpx2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/e4/5072862613fba039da2b7c981a8649c6c6bbcb2863bd8bc81617c09ce5ee/genai_prices-0.0.71.tar.gz", hash = "sha256:de4db34ec38404f9ef383cb1ab29e204d16ccf27071af0b16d5747ee7affe36b", size = 82105, upload-time = "2026-07-10T00:38:30.491Z" } @@ -1228,15 +1229,37 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.12.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ff/b53af1b49bb2cb5fa97c92215614b173ca5c1a99ded80e2d52f15a94fa6d/httpcore2-2.14.0.tar.gz", hash = "sha256:4f57a1a8249fe6af589d935cfd456f1d13bbdcb0f0fc72af9142368fc94811d7", size = 69096, upload-time = "2026-10-18T16:29:21.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb" }, + { url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, ] [[package]] @@ -1282,19 +1305,49 @@ wheels = [ [[package]] name = "httpx2" -version = "2.12.0" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx2" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, - { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'emscripten'" }, { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf" } +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36" }, + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, ] [package.optional-dependencies] @@ -1306,9 +1359,9 @@ http2 = [ name = "httpx2-jsfetch" version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -1341,11 +1394,11 @@ wheels = [ [[package]] name = "idna" -version = "3.19" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -2914,10 +2967,14 @@ adk = [ { name = "google-adk" }, ] client = [ - { name = "httpx2", extra = ["http2"] }, + { name = "httpx", extra = ["http2"] }, +] +client-httpx2 = [ + { name = "httpx2", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, extra = ["http2"], marker = "python_full_version < '3.11'" }, + { name = "httpx2", version = "2.16.0", source = { registry = "https://pypi.org/simple" }, extra = ["http2"], marker = "python_full_version >= '3.11'" }, ] harness = [ - { name = "httpx2", extra = ["http2"] }, + { name = "httpx" }, { name = "hypercorn" }, { name = "testcontainers" }, ] @@ -2960,8 +3017,9 @@ requires-dist = [ { name = "backports-zstd", marker = "python_full_version < '3.14' and extra == 'zstd'" }, { name = "dacite", marker = "extra == 'serde'" }, { name = "google-adk", marker = "extra == 'adk'", specifier = ">=1.20.0" }, - { name = "httpx2", extras = ["http2"], marker = "extra == 'client'", specifier = ">=2.12,<3" }, - { name = "httpx2", extras = ["http2"], marker = "extra == 'harness'", specifier = ">=2.12,<3" }, + { name = "httpx", marker = "extra == 'harness'" }, + { name = "httpx", extras = ["http2"], marker = "extra == 'client'" }, + { name = "httpx2", extras = ["http2"], marker = "extra == 'client-httpx2'", specifier = ">=2.12,<3" }, { name = "hypercorn", marker = "extra == 'harness'" }, { name = "hypercorn", marker = "extra == 'test'" }, { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.0.0" }, @@ -2978,7 +3036,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'lint'", specifier = ">=0.6.9" }, { name = "testcontainers", marker = "extra == 'harness'" }, ] -provides-extras = ["adk", "client", "harness", "langchain", "lint", "openai", "pydantic-ai", "serde", "test", "tracing", "zstd"] +provides-extras = ["adk", "client", "client-httpx2", "harness", "langchain", "lint", "openai", "pydantic-ai", "serde", "test", "tracing", "zstd"] [[package]] name = "rpds-py" @@ -3324,8 +3382,8 @@ name = "taskgroup" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } wheels = [ From 6a14853b6c4fed02be17bd570fba46c27bf56c22 Mon Sep 17 00:00:00 2001 From: Maksim Zayats <49313691+maksimzayats@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:28:53 +0300 Subject: [PATCH 3/4] Move ingress error messages into local variables --- python/restate/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/restate/client.py b/python/restate/client.py index bdbe1def..6c6fd4cc 100644 --- a/python/restate/client.py +++ b/python/restate/client.py @@ -501,7 +501,8 @@ async def create_client( or restate-sdk[client-httpx2] for httpx2. The selected package must be installed. """ if backend not in ("httpx", "httpx2"): - raise ValueError(f"Unsupported HTTP backend: {backend!r}. Expected 'httpx' or 'httpx2'.") + msg = f"Unsupported HTTP backend: {backend!r}. Expected 'httpx' or 'httpx2'." + raise ValueError(msg) http_client_context: typing.AsyncContextManager[HttpClient] try: @@ -517,9 +518,8 @@ async def create_client( if exc.name != backend: raise extra = "client" if backend == "httpx" else "client-httpx2" - raise ImportError( - f'The {backend} backend requires {backend}. Install it with: python -m pip install "restate-sdk[{extra}]"' - ) from exc + msg = f'The {backend} backend requires {backend}. Install it with: python -m pip install "restate-sdk[{extra}]"' + raise ImportError(msg) from exc async with http_client_context as http_client: yield Client(http_client, headers, journal_codec=journal_value_codec) From c037bb7d11437f0ebb808b6806e0eccdecc41c31 Mon Sep 17 00:00:00 2001 From: Maksim Zayats <49313691+maksimzayats@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:00:21 +0300 Subject: [PATCH 4/4] Simplify ingress client tests --- tests/client.py | 312 +++++++++++++++++------------------------------- 1 file changed, 111 insertions(+), 201 deletions(-) diff --git a/tests/client.py b/tests/client.py index 3c1384ec..54cfc170 100644 --- a/tests/client.py +++ b/tests/client.py @@ -12,12 +12,10 @@ import builtins from dataclasses import dataclass -from datetime import timedelta import subprocess import sys -from types import ModuleType import typing -from unittest.mock import Mock +from unittest.mock import ANY, MagicMock, Mock import httpx import httpx2 @@ -55,38 +53,19 @@ async def post(self, url: str, *, headers: dict[str, str], content: bytes) -> Ht return self.response -class PrefixCodec(JournalValueCodec): - def encode(self, buf: bytes) -> bytes: - return b"codec:" + buf - - async def decode(self, buf: bytes) -> bytes: - assert buf.startswith(b"codec:") - return buf[len(b"codec:") :] - - -@pytest.mark.parametrize("blocked", [("httpx",), ("httpx", "httpx2")]) -def test_import_without_http_packages(blocked: tuple[str, ...]): - code = f""" +def test_import_without_http_packages(): + code = """ import sys -for name in {blocked!r}: - sys.modules[name] = None -from restate.client import Client, create_client -from restate import HttpClient, HttpResponse -import restate -assert restate.create_client is create_client -assert restate.HttpClient is HttpClient -assert restate.HttpResponse is HttpResponse -for name in {blocked!r}: - assert sys.modules[name] is None +sys.modules["httpx"] = sys.modules["httpx2"] = None +from restate.client import Client """ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) assert result.returncode == 0, result.stderr -async def test_supplied_client_request_and_codec(): - codec = PrefixCodec() - http_client = StubClient(StubResponse(content=codec.encode(b'"pong"'))) - client = Client(http_client, journal_codec=codec) +async def test_supplied_client(): + http_client = StubClient(StubResponse()) + client = Client(http_client) assert client.client is http_client headers = {"X-Test": "value"} @@ -108,28 +87,12 @@ async def test_supplied_client_request_and_codec(): ( "restate/scope/tenant/call/greeter/object/greet", {"X-Test": "value", "Idempotency-Key": "request-1", "x-restate-limit-key": "customer/user"}, - b'codec:"ping"', + b'"ping"', ) ] assert headers == {"X-Test": "value"} -async def test_supplied_client_send(): - http_client = StubClient(StubResponse(content=b'{"invocationId": "inv_123"}')) - client = Client(http_client, journal_codec=PrefixCodec()) - result = await client.do_raw_call( - service="greeter", - handler="greet", - input_param="ping", - input_serde=JsonSerde[str](), - output_serde=JsonSerde[dict](), - send=True, - send_delay=timedelta(seconds=2), - ) - assert result == {"invocationId": "inv_123"} - assert http_client.calls == [("greeter/greet/send?delay=2000", {}, b'codec:"ping"')] - - @pytest.mark.parametrize("status_code", [400, 503]) async def test_http_error_fields(status_code: int): response = StubResponse(status_code, "custom reason", "exact body: ü\n") @@ -141,191 +104,140 @@ async def test_http_error_fields(status_code: int): assert str(error.value) == f"HTTP {status_code}: custom reason" -@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) -@pytest.mark.parametrize("status_code", [200, 400, 503]) -async def test_real_http_clients(backend: typing.Literal["httpx", "httpx2"], status_code: int): - requests: list[tuple[str, dict[str, str], bytes]] = [] - - def respond_httpx(request: httpx.Request) -> httpx.Response: - requests.append((str(request.url), dict(request.headers), request.content)) - response = httpx.Response(status_code, content=b'"pong"') - typed_response: HttpResponse = response - assert typed_response.content == b'"pong"' - return response - - def respond_httpx2(request: httpx2.Request) -> httpx2.Response: - requests.append((str(request.url), dict(request.headers), request.content)) - response = httpx2.Response(status_code, content=b'"pong"') - typed_response: HttpResponse = response - assert typed_response.content == b'"pong"' - return response - - # These assignments check both real client types against the protocol in Mypy and Pyright. - context: typing.AsyncContextManager[HttpClient] - if backend == "httpx": - httpx_client: HttpClient = httpx.AsyncClient( - base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx.MockTransport(respond_httpx) - ) - assert isinstance(httpx_client, httpx.AsyncClient) - context = httpx_client - else: - httpx2_client: HttpClient = httpx2.AsyncClient( - base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx2.MockTransport(respond_httpx2) - ) - assert isinstance(httpx2_client, httpx2.AsyncClient) - context = httpx2_client - - async with context as supplied: - client = Client(supplied) - assert client.client is supplied - if status_code >= 400: - with pytest.raises(HttpError) as error: - await client.post("service", "handler", False, b"input") - assert error.value.status_code == status_code - assert error.value.message == ("Bad Request" if status_code == 400 else "Service Unavailable") - assert error.value.body == '"pong"' - else: - result = await client.do_raw_call( - "service", "handler", "ping", JsonSerde[str](), JsonSerde[str](), headers={"X-Call": "call"} - ) - assert result == "pong" - - url, headers, content = requests[0] - assert url == "http://ingress/service/handler" - assert headers["x-default"] == "default" - if status_code == 200: - assert headers["x-call"] == "call" - assert content == b'"ping"' - else: - assert content == b"input" +async def _check_real_client(http_client: HttpClient, respond: Mock): + client = Client(http_client) + assert client.client is http_client + result = await client.do_raw_call( + "service", "handler", "ping", JsonSerde[str](), JsonSerde[str](), headers={"X-Call": "call"} + ) + assert result == "pong" + (call,) = respond.call_args_list + request = call.args[0] + assert str(request.url) == "http://ingress/service/handler" + assert request.headers["x-default"] == "default" + assert request.headers["x-call"] == "call" + assert request.content == b'"ping"' -@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) -@pytest.mark.parametrize("failure", ["timeout", "transport"]) -async def test_transport_errors_propagate(backend: typing.Literal["httpx", "httpx2"], failure: str): - httpx_error = httpx.ReadTimeout("timeout") if failure == "timeout" else httpx.ConnectError("connection") - httpx2_error = httpx2.ReadTimeout("timeout") if failure == "timeout" else httpx2.ConnectError("connection") - - def fail_httpx(request: httpx.Request) -> httpx.Response: - raise httpx_error - - def fail_httpx2(request: httpx2.Request) -> httpx2.Response: - raise httpx2_error - - context: typing.AsyncContextManager[HttpClient] - if backend == "httpx": - context = httpx.AsyncClient(base_url="http://ingress/", transport=httpx.MockTransport(fail_httpx)) - expected: Exception = httpx_error - else: - context = httpx2.AsyncClient(base_url="http://ingress/", transport=httpx2.MockTransport(fail_httpx2)) - expected = httpx2_error - async with context as supplied: - with pytest.raises(type(expected)) as error: - await Client(supplied).post("service", "handler", False, b"input") - assert error.value is expected - - -class FactoryHttpClient(StubClient): - def __init__(self, *, base_url: str, headers: dict | None, http2: bool): - super().__init__(StubResponse()) - self.options = (base_url, headers, http2) - self.entered = False - self.closed = False - self.exit_exception: BaseException | None = None - - async def __aenter__(self): - self.entered = True - return self - - async def __aexit__(self, exc_type, exc, traceback): - self.closed = True - self.exit_exception = exc +async def test_httpx_client(): + respond = Mock(return_value=httpx.Response(200, content=b'"pong"')) + async with httpx.AsyncClient( + base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx.MockTransport(respond) + ) as client: + await _check_real_client(client, respond) + + +async def test_httpx2_client(): + respond = Mock(return_value=httpx2.Response(200, content=b'"pong"')) + async with httpx2.AsyncClient( + base_url="http://ingress/", headers={"X-Default": "default"}, transport=httpx2.MockTransport(respond) + ) as client: + await _check_real_client(client, respond) + + +@pytest.mark.parametrize( + "http, failure", + [ + (httpx, httpx.ReadTimeout("timeout")), + (httpx, httpx.ConnectError("connection")), + (httpx2, httpx2.ReadTimeout("timeout")), + (httpx2, httpx2.ConnectError("connection")), + ], +) +async def test_transport_errors_propagate(http, failure: Exception): + transport = http.MockTransport(Mock(side_effect=failure)) + async with http.AsyncClient(base_url="http://ingress/", transport=transport) as client: + with pytest.raises(type(failure)) as error: + await Client(client).post("service", "handler", False, b"input") + assert error.value is failure @pytest.fixture -def factory_backends(monkeypatch: pytest.MonkeyPatch) -> dict[str, Mock]: - constructors = {} - for backend in ("httpx", "httpx2"): - constructor = Mock(side_effect=FactoryHttpClient) - module = ModuleType(backend) - module.AsyncClient = constructor # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, backend, module) - constructors[backend] = constructor - return constructors +def factories(monkeypatch: pytest.MonkeyPatch) -> dict[str, Mock]: + result = {} + for http in (httpx, httpx2): + client = MagicMock() + client.__aenter__.return_value = client + factory = Mock(return_value=client) + monkeypatch.setattr(http, "AsyncClient", factory) + result[http.__name__] = factory + return result @pytest.mark.parametrize("backend", ["httpx", "httpx2"]) -@pytest.mark.parametrize("available", [(), ("httpx",), ("httpx2",), ("httpx", "httpx2")]) -async def test_factory_selection( +@pytest.mark.parametrize("other_installed", [False, True]) +async def test_factory_selects_backend( backend: typing.Literal["httpx", "httpx2"], - available: tuple[str, ...], - factory_backends: dict[str, Mock], + other_installed: bool, + factories: dict[str, Mock], monkeypatch: pytest.MonkeyPatch, ): - for name in ("httpx", "httpx2"): - if name not in available: - monkeypatch.setitem(sys.modules, name, None) # type: ignore[arg-type] - if backend not in available: - with pytest.raises(ImportError) as error: - async with create_client("http://ingress/", backend=backend): - pytest.fail("Missing backend was accepted") - extra = "client" if backend == "httpx" else "client-httpx2" - assert f'python -m pip install "restate-sdk[{extra}]"' in str(error.value) - assert isinstance(error.value.__cause__, ModuleNotFoundError) - assert error.value.__cause__.name == backend - assert not any(constructor.called for constructor in factory_backends.values()) - return - + other = "httpx2" if backend == "httpx" else "httpx" + if not other_installed: + monkeypatch.setitem(sys.modules, other, None) # type: ignore[arg-type] headers = {"X-Default": "default"} - codec = PrefixCodec() + codec = Mock(spec=JournalValueCodec) + http_client = factories[backend].return_value async with create_client("http://ingress/", headers, codec, backend=backend) as client: assert isinstance(client, Client) - supplied = client.client - assert isinstance(supplied, FactoryHttpClient) - assert supplied.entered and not supplied.closed - assert supplied.options == ("http://ingress/", headers, True) + assert client.client is http_client assert client.headers is headers assert client.journal_codec is codec - assert supplied.closed - factory_backends[backend].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + http_client.__aexit__.assert_not_awaited() + http_client.__aexit__.assert_awaited_once_with(None, None, None) + factories[backend].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + factories[other].assert_not_called() + + +@pytest.mark.parametrize("backend", ["httpx", "httpx2"]) +@pytest.mark.parametrize("other_installed", [False, True]) +async def test_missing_backend_does_not_fall_back( + backend: typing.Literal["httpx", "httpx2"], + other_installed: bool, + factories: dict[str, Mock], + monkeypatch: pytest.MonkeyPatch, +): other = "httpx2" if backend == "httpx" else "httpx" - factory_backends[other].assert_not_called() + monkeypatch.setitem(sys.modules, backend, None) # type: ignore[arg-type] + if not other_installed: + monkeypatch.setitem(sys.modules, other, None) # type: ignore[arg-type] + with pytest.raises(ImportError) as error: + async with create_client("http://ingress/", backend=backend): + pass + extra = "client" if backend == "httpx" else "client-httpx2" + assert f'python -m pip install "restate-sdk[{extra}]"' in str(error.value) + factories[other].assert_not_called() -async def test_default_factory_and_positional_arguments(factory_backends: dict[str, Mock]): +async def test_default_factory_and_positional_arguments(factories: dict[str, Mock]): headers = {"X-Default": "value"} - codec = PrefixCodec() - async with restate.create_client("http://ingress/", headers, codec) as client: - assert isinstance(client, Client) - assert client.headers is headers - assert client.journal_codec is codec - factory_backends["httpx"].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) - factory_backends["httpx2"].assert_not_called() + codec = Mock(spec=JournalValueCodec) + async with restate.create_client("http://ingress/", headers, codec): + pass + factories["httpx"].assert_called_once_with(base_url="http://ingress/", headers=headers, http2=True) + factories["httpx2"].assert_not_called() @pytest.mark.parametrize("backend", ["httpx", "httpx2"]) -async def test_factory_cleanup_on_error(backend: typing.Literal["httpx", "httpx2"], factory_backends: dict[str, Mock]): +async def test_factory_cleanup_on_error(backend: typing.Literal["httpx", "httpx2"], factories: dict[str, Mock]): failure = RuntimeError("body failed") - supplied: HttpClient | None = None + http_client = factories[backend].return_value with pytest.raises(RuntimeError) as error: - async with create_client("http://ingress/", backend=backend) as client: - assert isinstance(client, Client) - supplied = client.client - assert isinstance(supplied, FactoryHttpClient) + async with create_client("http://ingress/", backend=backend): raise failure assert error.value is failure - assert isinstance(supplied, FactoryHttpClient) - assert supplied.closed - assert supplied.exit_exception is failure + http_client.__aexit__.assert_awaited_once_with(RuntimeError, failure, ANY) -async def test_invalid_backend_and_keyword_only(factory_backends: dict[str, Mock]): +async def test_invalid_backend(): with pytest.raises(ValueError, match="Unsupported HTTP backend: 'unknown'"): async with create_client("http://ingress/", backend="unknown"): # type: ignore[arg-type] - pytest.fail("Invalid backend was accepted") + pass + + +def test_backend_is_keyword_only(): with pytest.raises(TypeError): create_client("http://ingress/", None, None, "httpx2") # type: ignore[misc] - assert not any(constructor.called for constructor in factory_backends.values()) @pytest.mark.parametrize("backend", ["httpx", "httpx2"]) @@ -335,7 +247,6 @@ async def test_invalid_backend_and_keyword_only(factory_backends: dict[str, Mock async def test_broken_backend_import_propagates( backend: typing.Literal["httpx", "httpx2"], failure: ImportError, - factory_backends: dict[str, Mock], monkeypatch: pytest.MonkeyPatch, ): original_import = builtins.__import__ @@ -348,6 +259,5 @@ def import_backend(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", import_backend) with pytest.raises(type(failure)) as error: async with create_client("http://ingress/", backend=backend): - pytest.fail("Broken import was accepted") + pass assert error.value is failure - assert not any(constructor.called for constructor in factory_backends.values())