From 8aa867a4bbc2b965b78ea7c71baf4fef7fd90992 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:49:34 +0300 Subject: [PATCH 1/9] feat(grpc-web): add Pyodide/WASM grpc-web transport Let the async client's gRPC data path run under Pyodide/WebAssembly (marimo, browser), where grpcio has no wheel and raw sockets are unavailable. Base changes (no-ops on normal platforms): - setup.cfg: mark grpcio with `; sys_platform != "emscripten"` so micropip skips it under Pyodide while CPython installs it unchanged. - weaviate/proto/v1/__init__.py: when grpcio distribution metadata is absent, fall back to version 1.72.1 so a working generated-proto variant is selected. Restricted to grpcio; a missing protobuf still surfaces. New companion package packages/grpc-web (weaviate-python-grpc-web): - A sys.modules `grpc` shim that satisfies the client's import-time grpc surface and the `grpc.aio.Channel` / awaitability contracts; installs itself only under Emscripten so it never clobbers a real grpcio. - GrpcWebChannel: frames unary RPCs as grpc-web and POSTs them via pyodide pyfetch (with an httpx sender for CPython tests); folds call metadata into fetch headers; maps grpc-web trailers/status to the client's error types. - Reuses the client's generated protobuf stubs (no codegen fork). Async-only; bidirectional BatchStream is intentionally unsupported over fetch. Tests (25 passing): grpc-web framing, transport round-trips and error mapping, a subprocess test that imports weaviate under the shim with a real-proto unary round trip, and base proto-guard regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/grpc-web/README.md | 57 ++++ packages/grpc-web/pyproject.toml | 30 ++ .../src/weaviate_grpc_web/__init__.py | 51 ++++ .../src/weaviate_grpc_web/_channel.py | 233 +++++++++++++++ .../src/weaviate_grpc_web/_framing.py | 68 +++++ .../grpc-web/src/weaviate_grpc_web/_sender.py | 59 ++++ .../grpc-web/src/weaviate_grpc_web/_shim.py | 265 ++++++++++++++++++ .../grpc-web/src/weaviate_grpc_web/py.typed | 0 packages/grpc-web/tests/conftest.py | 7 + packages/grpc-web/tests/test_framing.py | 59 ++++ packages/grpc-web/tests/test_shim_install.py | 111 ++++++++ packages/grpc-web/tests/test_transport.py | 158 +++++++++++ proto_test/test_proto.py | 31 +- setup.cfg | 2 +- weaviate/proto/v1/__init__.py | 20 +- 15 files changed, 1146 insertions(+), 5 deletions(-) create mode 100644 packages/grpc-web/README.md create mode 100644 packages/grpc-web/pyproject.toml create mode 100644 packages/grpc-web/src/weaviate_grpc_web/__init__.py create mode 100644 packages/grpc-web/src/weaviate_grpc_web/_channel.py create mode 100644 packages/grpc-web/src/weaviate_grpc_web/_framing.py create mode 100644 packages/grpc-web/src/weaviate_grpc_web/_sender.py create mode 100644 packages/grpc-web/src/weaviate_grpc_web/_shim.py create mode 100644 packages/grpc-web/src/weaviate_grpc_web/py.typed create mode 100644 packages/grpc-web/tests/conftest.py create mode 100644 packages/grpc-web/tests/test_framing.py create mode 100644 packages/grpc-web/tests/test_shim_install.py create mode 100644 packages/grpc-web/tests/test_transport.py diff --git a/packages/grpc-web/README.md b/packages/grpc-web/README.md new file mode 100644 index 000000000..5810e9f06 --- /dev/null +++ b/packages/grpc-web/README.md @@ -0,0 +1,57 @@ +# weaviate-python-grpc-web + +A grpc-web / WebAssembly (Pyodide) transport for the +[Weaviate Python client](https://github.com/weaviate/weaviate-python-client), so the +client's **async** gRPC data path can run inside a browser (marimo notebooks, Pyodide, +WASM workers) where there is no socket and no `grpcio` wheel. + +It is built from the same repository as `weaviate-client` and reuses its generated +protobuf stubs — it does **not** fork code generation. + +## How it works + +Under Pyodide there is no `grpcio` Emscripten wheel, and `import weaviate` hard-imports +`grpc` at module load. This package installs a small pure-Python `grpc` shim into +`sys.modules` **before** `import weaviate`, which: + +- satisfies every import-time `import grpc` / `from grpc(.aio) import ...` in the base + client and its generated `*_pb2_grpc` stubs; +- provides `grpc.aio.Channel` as a real base class, so the grpc-web channel + (`GrpcWebChannel`) subclasses it and the client's `isinstance(..., grpc.aio.Channel)` + assertions pass; +- satisfies the generated v6300 stub's version gate + (`grpc.__version__` / `grpc._utilities.first_version_is_lower`). + +The `GrpcWebChannel` frames unary RPCs as grpc-web (a 5-byte header + protobuf payload) +and POSTs them via `pyodide.http.pyfetch` to a server fronted by a grpc-web transcoder +(e.g. Envoy or [connectrpc/vanguard](https://github.com/connectrpc/vanguard-go)). Call +metadata (API key / OIDC bearer) is folded into `fetch` headers. + +## Usage + +```python +import weaviate_grpc_web # installs the grpc shim under Emscripten (no-op elsewhere) +import weaviate + +client = weaviate.use_async_with_local(skip_init_checks=True) +await client.connect() +collection = client.collections.get("Article") +await collection.query.near_text("hello", limit=3) +``` + +## Supported / unsupported + +| RPC | Kind | Status | +|----------------------------------------------------------|-----------------|--------| +| Search, Aggregate, TenantsGet, BatchObjects, BatchDelete | unary | ✅ works over grpc-web | +| Health check (`/grpc.health.v1.Health/Check`) | unary | ✅ (recommend `skip_init_checks=True` + REST `/.well-known/ready`) | +| References (`/batch/references`) | REST | ✅ via httpx-in-Pyodide | +| `batch.stream()` / `batch.experimental()` (BatchStream) | bidi streaming | ❌ not possible over grpc-web/fetch — use `insert_many()` / `batch.dynamic()` / `fixed_size()` / `rate_limit()` | +| Synchronous client | — | ❌ async-only under WASM | + +## Testing on CPython + +`weaviate_grpc_web.install(force=True)` installs the shim on a normal CPython +interpreter (run it in a fresh process, before importing `weaviate`). Inject a sender +with `weaviate_grpc_web.set_sender(...)` (e.g. `make_httpx_sender()`) to exercise the +transport against an Envoy/vanguard transcoder without a browser. diff --git a/packages/grpc-web/pyproject.toml b/packages/grpc-web/pyproject.toml new file mode 100644 index 000000000..5cadabf35 --- /dev/null +++ b/packages/grpc-web/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=65", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "weaviate-python-grpc-web" +description = "grpc-web / WASM (Pyodide) transport for the Weaviate Python client" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } +authors = [{ name = "Weaviate", email = "hello@weaviate.io" }] +keywords = ["weaviate", "grpc-web", "pyodide", "wasm", "emscripten"] +# Version is kept in lockstep with weaviate-client. TODO(lockstep): derive from the same +# git tag via setuptools_scm and assert the built versions match in CI before publishing. +version = "0.0.1.dev0" +# Deliberately depends on weaviate-client WITHOUT grpcio (grpcio is excluded under +# Emscripten by the `sys_platform != "emscripten"` marker in the base package's deps). +dependencies = [ + "weaviate-client", +] + +[project.urls] +Source = "https://github.com/weaviate/weaviate-python-client" +Tracker = "https://github.com/weaviate/weaviate-python-client/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +weaviate_grpc_web = ["py.typed"] diff --git a/packages/grpc-web/src/weaviate_grpc_web/__init__.py b/packages/grpc-web/src/weaviate_grpc_web/__init__.py new file mode 100644 index 000000000..79a6cb1d8 --- /dev/null +++ b/packages/grpc-web/src/weaviate_grpc_web/__init__.py @@ -0,0 +1,51 @@ +"""grpc-web / WASM transport for the Weaviate Python client. + +Under Pyodide/Emscripten there is no ``grpcio`` wheel. Importing this package installs a +pure-Python ``grpc`` shim into ``sys.modules`` (and forces the pure-Python protobuf +runtime) so that the subsequent ``import weaviate`` succeeds and its async gRPC data path +runs over grpc-web (``fetch``) instead of HTTP/2 sockets. + +Usage under Pyodide:: + + import weaviate_grpc_web # installs the grpc shim (no-op off Emscripten) + import weaviate + + client = weaviate.use_async_with_local(skip_init_checks=True) + await client.connect() + +The shim is installed automatically only under Emscripten, so importing this package on a +normal CPython install never clobbers a real, working ``grpcio``. Async clients only — +the synchronous client is not supported in the browser. +""" + +import os +import sys + +from ._shim import StatusCode, install, is_installed + +__all__ = [ + "install", + "is_installed", + "set_sender", + "make_httpx_sender", + "GrpcWebChannel", + "StatusCode", +] + + +def _bootstrap() -> None: + if sys.platform == "emscripten": + # The pure-Python protobuf runtime always works; the upb C-extension may not be + # present. Set before ``import weaviate`` (which imports protobuf) so it takes + # effect. ``setdefault`` lets a user override it explicitly. + os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") + install() + + +_bootstrap() + +# Imported after the bootstrap. These modules pull their grpc base classes directly from +# ``._shim`` (not via ``sys.modules['grpc']``), so importing them is safe regardless of +# whether the shim was installed. +from ._channel import GrpcWebChannel, set_sender # noqa: E402 +from ._sender import make_httpx_sender # noqa: E402 diff --git a/packages/grpc-web/src/weaviate_grpc_web/_channel.py b/packages/grpc-web/src/weaviate_grpc_web/_channel.py new file mode 100644 index 000000000..5e7b9bd55 --- /dev/null +++ b/packages/grpc-web/src/weaviate_grpc_web/_channel.py @@ -0,0 +1,233 @@ +"""The grpc-web channel and multicallables. + +:class:`GrpcWebChannel` implements the small slice of the ``grpc.aio`` channel interface +that ``weaviate``'s generated stub and ``ConnectionV4`` actually use — ``unary_unary``, +``stream_stream`` and ``close`` — by framing requests as grpc-web and POSTing them via a +pluggable async sender. It subclasses the shim's ``grpc.aio.Channel`` (:class:`AioChannel`) +so the ``isinstance(..., grpc.aio.Channel)`` assertions in ``connect/v4.py`` hold. + +Only unary RPCs are supported (Search, Aggregate, TenantsGet, BatchObjects, +BatchReferences, BatchDelete, and the unary health check). ``stream_stream`` (the bidi +``BatchStream`` used by opt-in server-side batching) cannot work over grpc-web/fetch and +raises a clear error. +""" + +import base64 +import urllib.parse +from typing import Any, Callable, Dict, Optional + +from ._framing import encode_message, split_response +from ._sender import Sender, pyfetch_sender +from ._shim import AioChannel, AioRpcError, StatusCode, status_from_int + +# Module-level default sender; overridable for tests / non-browser runtimes. +_default_sender: Sender = pyfetch_sender + + +def set_sender(sender: Sender) -> None: + """Override the default async sender used by new channels (tests/integration).""" + global _default_sender + _default_sender = sender + + +def get_sender() -> Sender: + return _default_sender + + +def _encode_timeout(seconds: float) -> str: + """Encode a timeout as a grpc-timeout header value (````).""" + millis = max(1, int(seconds * 1000)) + if millis < 100_000_000: + return f"{millis}m" + return f"{max(1, int(seconds))}S" + + +def _fold_metadata(headers: Dict[str, str], metadata: Any) -> None: + """Fold gRPC call metadata (``[(key, value), ...]``) into fetch headers. + + Binary ``-bin`` keys are base64-encoded as grpc-web requires. + """ + if not metadata: + return + for key, value in metadata: + name = key.lower() + if name.endswith("-bin"): + raw = value if isinstance(value, (bytes, bytearray)) else str(value).encode() + headers[name] = base64.b64encode(raw).decode("ascii") + else: + headers[name] = value if isinstance(value, str) else str(value) + + +def _header_lookup(headers: Dict[str, str], name: str) -> Optional[str]: + target = name.lower() + for key, value in headers.items(): + if key.lower() == target: + return value + return None + + +class _UnaryUnaryMultiCallable: + """Awaitable multicallable bound by ``WeaviateStub.__init__``. + + Called as ``await mc(request, metadata=..., timeout=...)`` (and, for the health + check, as ``mc(request, timeout=...)`` with no metadata). + """ + + def __init__( + self, + channel: "GrpcWebChannel", + path: str, + request_serializer: Callable[[Any], bytes], + response_deserializer: Callable[[bytes], Any], + ) -> None: + self._channel = channel + self._path = path + self._serialize = request_serializer + self._deserialize = response_deserializer + + async def __call__( + self, + request: Any, + *, + metadata: Any = None, + timeout: Optional[float] = None, + credentials: Any = None, + wait_for_ready: Any = None, + compression: Any = None, + ) -> Any: + payload = self._serialize(request) + return await self._channel._unary(self._path, payload, self._deserialize, metadata, timeout) + + +class _UnsupportedStreamMultiCallable: + """Placeholder for ``stream_stream`` (bidirectional streaming). + + Calling it raises immediately, before the ``async for`` in ``connect/v4.py:1243`` + begins iterating. + """ + + def __init__(self, path: str) -> None: + self._path = path + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + raise RuntimeError( + f"Bidirectional streaming RPC {self._path!r} (server-side batching / " + "BatchStream) is not supported over grpc-web/fetch. Use insert_many(), or " + "batch.dynamic() / fixed_size() / rate_limit(), instead of batch.stream()." + ) + + +class GrpcWebChannel(AioChannel): + """grpc-web/fetch implementation of the async grpc channel slice the client uses.""" + + def __init__( + self, + target: Optional[str], + secure: bool, + options: Any = None, + sender: Optional[Sender] = None, + ) -> None: + if not target: + raise ValueError("GrpcWebChannel requires a target (host:port)") + scheme = "https" if secure else "http" + self._base_url = f"{scheme}://{target}" + self._sender: Sender = sender or get_sender() + + def unary_unary( + self, + method: str, + request_serializer: Callable[[Any], bytes], + response_deserializer: Callable[[bytes], Any], + _registered_method: bool = False, + ) -> _UnaryUnaryMultiCallable: + return _UnaryUnaryMultiCallable(self, method, request_serializer, response_deserializer) + + def stream_stream( + self, + method: str, + request_serializer: Callable[[Any], bytes], + response_deserializer: Callable[[bytes], Any], + _registered_method: bool = False, + ) -> _UnsupportedStreamMultiCallable: + return _UnsupportedStreamMultiCallable(method) + + async def close(self, grace: Optional[float] = None) -> None: + # Nothing to tear down: each call is an independent fetch. + return None + + async def _unary( + self, + path: str, + payload: bytes, + deserialize: Callable[[bytes], Any], + metadata: Any, + timeout: Optional[float], + ) -> Any: + headers: Dict[str, str] = { + "content-type": "application/grpc-web+proto", + "accept": "application/grpc-web+proto", + "x-grpc-web": "1", + "x-user-agent": "weaviate-python-grpc-web", + } + _fold_metadata(headers, metadata) + if timeout is not None: + headers["grpc-timeout"] = _encode_timeout(timeout) + + url = self._base_url + path + status, resp_headers, body = await self._sender( + url, headers, encode_message(payload), timeout + ) + return self._handle_response(status, resp_headers, body, deserialize) + + @staticmethod + def _handle_response( + http_status: int, + resp_headers: Dict[str, str], + body: bytes, + deserialize: Callable[[bytes], Any], + ) -> Any: + messages, trailers = split_response(body) if body else ([], {}) + + raw_status = trailers.get("grpc-status") + if raw_status is None: + raw_status = _header_lookup(resp_headers, "grpc-status") + raw_message = ( + trailers.get("grpc-message") or _header_lookup(resp_headers, "grpc-message") or "" + ) + message = urllib.parse.unquote(raw_message) + + if raw_status is None: + if http_status != 200: + raise AioRpcError( + code=_status_from_http(http_status), + details=f"HTTP {http_status} from grpc-web endpoint", + ) + code = StatusCode.OK + else: + code = status_from_int(int(raw_status)) + + if code is not StatusCode.OK: + raise AioRpcError(code=code, details=message) + if not messages: + raise AioRpcError( + code=StatusCode.INTERNAL, + details="grpc-web response contained no message frame", + ) + return deserialize(messages[0]) + + +def _status_from_http(http_status: int) -> StatusCode: + """Map an HTTP status to a gRPC status when no grpc-status is present. + + Mirrors the grpc-web spec's HTTP-to-gRPC code mapping. + """ + return { + 400: StatusCode.INTERNAL, + 401: StatusCode.UNAUTHENTICATED, + 403: StatusCode.PERMISSION_DENIED, + 404: StatusCode.UNIMPLEMENTED, + 429: StatusCode.UNAVAILABLE, + 502: StatusCode.UNAVAILABLE, + 503: StatusCode.UNAVAILABLE, + 504: StatusCode.UNAVAILABLE, + }.get(http_status, StatusCode.UNKNOWN) diff --git a/packages/grpc-web/src/weaviate_grpc_web/_framing.py b/packages/grpc-web/src/weaviate_grpc_web/_framing.py new file mode 100644 index 000000000..85b6f6972 --- /dev/null +++ b/packages/grpc-web/src/weaviate_grpc_web/_framing.py @@ -0,0 +1,68 @@ +r"""grpc-web binary framing (``application/grpc-web+proto``). + +A grpc-web message frame is a 1-byte flag + 4-byte big-endian length + payload: + + +--------+----------------+----------------------+ + | flag | length (uint32)| payload (length bytes)| + +--------+----------------+----------------------+ + +The flag's high bit (``0x80``) marks a trailer frame whose payload is an +HTTP/1-style header block (``grpc-status: 0\\r\\ngrpc-message: ...``). The low bit +(``0x01``) marks a compressed message, which this transport neither sends nor +accepts. A unary grpc-web response body is one or more message frames followed by +exactly one trailer frame (or a "trailers-only" response carrying the status in +the HTTP headers, handled by the caller). +""" + +import struct +from typing import Dict, Iterator, List, Tuple + +_FLAG_TRAILER = 0x80 +_FLAG_COMPRESSED = 0x01 +_HEADER = struct.Struct(">BI") # 1 flag byte + 4-byte big-endian length + + +def encode_message(payload: bytes) -> bytes: + """Frame a single (uncompressed) protobuf payload for sending.""" + return _HEADER.pack(0x00, len(payload)) + payload + + +def iter_frames(buf: bytes) -> Iterator[Tuple[int, bytes]]: + """Yield ``(flag, payload)`` for each frame in a grpc-web response body.""" + off, n = 0, len(buf) + while off + 5 <= n: + flag, length = _HEADER.unpack_from(buf, off) + off += 5 + if off + length > n: + raise ValueError("truncated grpc-web frame") + yield flag, buf[off : off + length] + off += length + if off != n: + raise ValueError("trailing bytes after final grpc-web frame") + + +def parse_trailers(raw: bytes) -> Dict[str, str]: + """Parse a trailer frame payload into a lower-cased header dict.""" + out: Dict[str, str] = {} + for line in raw.split(b"\r\n"): + if not line: + continue + key, _, value = line.partition(b":") + out[key.strip().decode("ascii").lower()] = value.strip().decode("ascii") + return out + + +def split_response(body: bytes) -> Tuple[List[bytes], Dict[str, str]]: + """Split a grpc-web response body into message payloads and trailers.""" + messages: List[bytes] = [] + trailers: Dict[str, str] = {} + for flag, payload in iter_frames(body): + if flag & _FLAG_TRAILER: + trailers.update(parse_trailers(payload)) + elif flag & _FLAG_COMPRESSED: + raise ValueError( + "compressed grpc-web message frames are not supported by this transport" + ) + else: + messages.append(payload) + return messages, trailers diff --git a/packages/grpc-web/src/weaviate_grpc_web/_sender.py b/packages/grpc-web/src/weaviate_grpc_web/_sender.py new file mode 100644 index 000000000..0ac0088e7 --- /dev/null +++ b/packages/grpc-web/src/weaviate_grpc_web/_sender.py @@ -0,0 +1,59 @@ +"""HTTP senders for the grpc-web transport. + +A *sender* is ``async def sender(url, headers, body, timeout) -> (status, headers, body)``. +The default uses ``pyodide.http.pyfetch`` (browser fetch); a sender can be injected for +testing or for non-browser runtimes via :func:`weaviate_grpc_web.set_sender`. +""" + +from typing import Awaitable, Callable, Dict, Optional, Tuple + +Sender = Callable[ + [str, Dict[str, str], bytes, Optional[float]], + Awaitable[Tuple[int, Dict[str, str], bytes]], +] + + +async def pyfetch_sender( + url: str, headers: Dict[str, str], body: bytes, timeout: Optional[float] +) -> Tuple[int, Dict[str, str], bytes]: + """Default browser sender. + + Imports ``pyodide.http`` lazily so this module stays importable on CPython (where + ``pyodide`` does not exist). + """ + from pyodide.http import pyfetch # type: ignore[import-not-found] + + response = await pyfetch(url, method="POST", headers=headers, body=body) + data = await response.bytes() + try: + resp_headers = dict(response.headers) + except Exception: # pragma: no cover - header shape varies across Pyodide versions + resp_headers = {} + return int(response.status), resp_headers, data + + +def make_httpx_sender(client: Optional[object] = None) -> Sender: + """Build a sender backed by ``httpx.AsyncClient`` for CPython tests/integration. + + Targets a grpc-web transcoder (Envoy / connectrpc vanguard). + """ + import httpx + + async def _send( + url: str, headers: Dict[str, str], body: bytes, timeout: Optional[float] + ) -> Tuple[int, Dict[str, str], bytes]: + owns_client = client is None + active = client or httpx.AsyncClient() + assert isinstance(active, httpx.AsyncClient) + try: + response = await active.post(url, headers=headers, content=body, timeout=timeout) + return ( + response.status_code, + {k.lower(): v for k, v in response.headers.items()}, + response.content, + ) + finally: + if owns_client: + await active.aclose() + + return _send diff --git a/packages/grpc-web/src/weaviate_grpc_web/_shim.py b/packages/grpc-web/src/weaviate_grpc_web/_shim.py new file mode 100644 index 000000000..52b0ae635 --- /dev/null +++ b/packages/grpc-web/src/weaviate_grpc_web/_shim.py @@ -0,0 +1,265 @@ +"""A minimal pure-Python stand-in for the ``grpc`` API surface ``weaviate-client`` uses. + +It covers what ``weaviate-client`` touches at import time and on the async unary data +path. It is installed into ``sys.modules`` (as ``grpc``, ``grpc.aio``, ``grpc._utilities``, +``grpc.aio._typing``, ``grpc.experimental``) *before* ``import weaviate`` so the client +loads under Pyodide/Emscripten, where the real ``grpcio`` C-extension wheel does not +exist. The shim satisfies two contracts at once: + +1. **Import surface** — every ``import grpc`` / ``from grpc(.aio) import ...`` executed + while ``weaviate`` and its generated ``*_pb2_grpc`` stubs are imported + (``weaviate/config.py:4-5``, ``exceptions.py:7-8``, ``retry.py:5-6``, + ``connect/base.py:5-8``, ``connect/v4.py:24,29-32``, and the v6300 stub's + ``grpc.__version__`` / ``grpc._utilities.first_version_is_lower`` version gate). +2. **Runtime type contract** — :class:`AioChannel` becomes ``grpc.aio.Channel`` so the + real grpc-web channel (which subclasses it) passes the + ``isinstance(..., grpc.aio.Channel)`` assertions in ``connect/v4.py`` (lines 722, + 1241); :class:`AioRpcError` is the error the client catches and inspects via + ``.code()`` / ``.details()`` (``exceptions.py:62-76``, ``retry.py:30-31``). +""" + +import enum +import sys +import types +from typing import Any, Optional + +# grpcio reports 1.72.1 as the version that the v6300 generated stub requires; matching +# it makes the stub's import-time version gate pass. See weaviate/proto/v1/__init__.py. +FAKE_GRPC_VERSION = "1.72.1" + +_SHIM_MARKER = "__weaviate_grpc_web_shim__" + + +class StatusCode(enum.Enum): + """Mirror of ``grpc.StatusCode``. + + ``value`` is the canonical ``(int, str)`` tuple, matching grpcio so ``code.value[0]`` + / ``code.value[1]`` (``exceptions.py:63,66``) and ``code.name`` + (``connect/v4.py:1189``) behave identically. + """ + + OK = (0, "ok") + CANCELLED = (1, "cancelled") + UNKNOWN = (2, "unknown") + INVALID_ARGUMENT = (3, "invalid argument") + DEADLINE_EXCEEDED = (4, "deadline exceeded") + NOT_FOUND = (5, "not found") + ALREADY_EXISTS = (6, "already exists") + PERMISSION_DENIED = (7, "permission denied") + RESOURCE_EXHAUSTED = (8, "resource exhausted") + FAILED_PRECONDITION = (9, "failed precondition") + ABORTED = (10, "aborted") + OUT_OF_RANGE = (11, "out of range") + UNIMPLEMENTED = (12, "unimplemented") + INTERNAL = (13, "internal") + UNAVAILABLE = (14, "unavailable") + DATA_LOSS = (15, "data loss") + UNAUTHENTICATED = (16, "unauthenticated") + + +_BY_NUMBER = {member.value[0]: member for member in StatusCode} + + +def status_from_int(code: int) -> StatusCode: + """Map a numeric grpc-status to a :class:`StatusCode` (``UNKNOWN`` if unmapped).""" + return _BY_NUMBER.get(code, StatusCode.UNKNOWN) + + +class RpcError(Exception): + """Stand-in for ``grpc.RpcError`` (imported by ``retry.py``).""" + + +class Call: + """Stand-in for ``grpc.Call`` (imported by ``exceptions.py`` / ``retry.py``). + + Only used for ``isinstance``/type-import purposes; the async-only WASM path raises + :class:`AioRpcError`, never a sync ``Call``. + """ + + def code(self) -> StatusCode: # pragma: no cover - never instantiated under WASM + raise NotImplementedError + + def details(self) -> str: # pragma: no cover + raise NotImplementedError + + +class AioRpcError(RpcError): + """Stand-in for ``grpc.aio.AioRpcError``. + + Raised by the grpc-web multicallable on a non-OK status; exposes the same + ``code()`` / ``details()`` surface the client uses. + """ + + def __init__( + self, + code: StatusCode, + initial_metadata: Any = None, + trailing_metadata: Any = None, + details: str = "", + debug_error_string: Optional[str] = None, + ) -> None: + self._code = code + self._details = details + self._initial_metadata = initial_metadata + self._trailing_metadata = trailing_metadata + self._debug_error_string = debug_error_string + super().__init__(f"") + + def code(self) -> StatusCode: + return self._code + + def details(self) -> str: + return self._details + + def initial_metadata(self) -> Any: + return self._initial_metadata + + def trailing_metadata(self) -> Any: + return self._trailing_metadata + + def debug_error_string(self) -> Optional[str]: + return self._debug_error_string + + +class StreamStreamCall: + """Stand-in for ``grpc.aio.StreamStreamCall`` (imported as a type at ``v4.py:31``).""" + + +class ChannelCredentials: + """Stand-in for ``grpc.ChannelCredentials`` (imported by ``config.py:4``).""" + + +def ssl_channel_credentials(*_args: Any, **_kwargs: Any) -> ChannelCredentials: + return ChannelCredentials() + + +class SyncChannel: + """Stand-in for ``grpc.Channel`` (sync). + + Never instantiated under WASM — the sync channel factory raises (the WASM transport + is async-only). + """ + + +class AioChannel: + """Become ``grpc.aio.Channel``. + + The grpc-web channel subclasses this so the ``isinstance(..., grpc.aio.Channel)`` + assertions in ``connect/v4.py`` hold. + """ + + +def first_version_is_lower(_version: str, _other: str) -> bool: + """Stand-in for ``grpc._utilities.first_version_is_lower``. + + Returning ``False`` makes the v6300 stub's version gate + (``weaviate_pb2_grpc.py:17-29``) pass. + """ + return False + + +_ASYNC_ONLY_MESSAGE = ( + "weaviate-python-grpc-web provides an asynchronous-only gRPC transport under " + "WebAssembly/Pyodide. Use an async client (weaviate.use_async_with_local / " + "use_async_with_weaviate_cloud / use_async_with_custom, or WeaviateAsyncClient); " + "the synchronous client is not supported in the browser." +) + + +def _sync_channel_unsupported(*_args: Any, **_kwargs: Any) -> "AioChannel": + raise RuntimeError(_ASYNC_ONLY_MESSAGE) + + +def _aio_secure_channel( + target: Optional[str] = None, credentials: Any = None, options: Any = None, **_kw: Any +) -> AioChannel: + from ._channel import GrpcWebChannel + + return GrpcWebChannel(target=target, secure=True, options=options) + + +def _aio_insecure_channel( + target: Optional[str] = None, options: Any = None, **_kw: Any +) -> AioChannel: + from ._channel import GrpcWebChannel + + return GrpcWebChannel(target=target, secure=False, options=options) + + +def _noop(*_args: Any, **_kwargs: Any) -> None: + """Inert stand-in for imported-but-unused server-side stub-registration helpers. + + e.g. ``grpc.unary_unary_rpc_method_handler``: imported by generated ``*_pb2_grpc`` + code, never called by the client. + """ + return None + + +def is_installed() -> bool: + return getattr(sys.modules.get("grpc"), _SHIM_MARKER, False) is True + + +def install(force: bool = False) -> bool: + """Install the shim into ``sys.modules`` as ``grpc`` and submodules. + + On normal platforms this is a no-op unless ``force=True`` — we must never clobber a + real, working ``grpcio``. Under Emscripten the bootstrap calls this automatically. + Returns ``True`` if the shim is in place afterwards. + """ + if not force and sys.platform != "emscripten": + return False + if is_installed(): + return True + + # Modules are populated via __dict__.update — dynamic module synthesis, so static + # type checkers do not flag each attribute assignment. + utilities = types.ModuleType("grpc._utilities") + utilities.__dict__["first_version_is_lower"] = first_version_is_lower + + experimental = types.ModuleType("grpc.experimental") + experimental.__dict__.update(unary_unary=_noop, stream_stream=_noop) + + aio_typing = types.ModuleType("grpc.aio._typing") + aio_typing.__dict__["ChannelArgumentType"] = Any + + aio = types.ModuleType("grpc.aio") + aio.__dict__.update( + Channel=AioChannel, + AioRpcError=AioRpcError, + StreamStreamCall=StreamStreamCall, + secure_channel=_aio_secure_channel, + insecure_channel=_aio_insecure_channel, + _typing=aio_typing, + ) + + grpc_mod = types.ModuleType("grpc") + grpc_mod.__dict__.update( + { + "__version__": FAKE_GRPC_VERSION, + _SHIM_MARKER: True, + "StatusCode": StatusCode, + "RpcError": RpcError, + "Call": Call, + "Channel": SyncChannel, + "ChannelCredentials": ChannelCredentials, + "ssl_channel_credentials": ssl_channel_credentials, + "secure_channel": _sync_channel_unsupported, + "insecure_channel": _sync_channel_unsupported, + # Imported (never called) by generated *_pb2_grpc servicer/registration code. + "unary_unary_rpc_method_handler": _noop, + "stream_stream_rpc_method_handler": _noop, + "unary_stream_rpc_method_handler": _noop, + "stream_unary_rpc_method_handler": _noop, + "method_handlers_generic_handler": _noop, + "_utilities": utilities, + "experimental": experimental, + "aio": aio, + } + ) + + sys.modules["grpc"] = grpc_mod + sys.modules["grpc._utilities"] = utilities + sys.modules["grpc.experimental"] = experimental + sys.modules["grpc.aio"] = aio + sys.modules["grpc.aio._typing"] = aio_typing + return True diff --git a/packages/grpc-web/src/weaviate_grpc_web/py.typed b/packages/grpc-web/src/weaviate_grpc_web/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/grpc-web/tests/conftest.py b/packages/grpc-web/tests/conftest.py new file mode 100644 index 000000000..fe4afb09d --- /dev/null +++ b/packages/grpc-web/tests/conftest.py @@ -0,0 +1,7 @@ +import pathlib +import sys + +# Make the package importable without an editable install. +_SRC = pathlib.Path(__file__).resolve().parents[1] / "src" +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) diff --git a/packages/grpc-web/tests/test_framing.py b/packages/grpc-web/tests/test_framing.py new file mode 100644 index 000000000..320aff7f6 --- /dev/null +++ b/packages/grpc-web/tests/test_framing.py @@ -0,0 +1,59 @@ +import struct + +import pytest + +from weaviate_grpc_web._framing import ( + encode_message, + iter_frames, + parse_trailers, + split_response, +) + + +def _frame(payload: bytes, flag: int = 0x00) -> bytes: + return struct.pack(">BI", flag, len(payload)) + payload + + +def test_encode_message_round_trip(): + framed = encode_message(b"hello") + frames = list(iter_frames(framed)) + assert frames == [(0x00, b"hello")] + + +def test_split_response_message_and_trailer(): + body = _frame(b"payload") + _frame(b"grpc-status:0\r\ngrpc-message:\r\n", 0x80) + messages, trailers = split_response(body) + assert messages == [b"payload"] + assert trailers["grpc-status"] == "0" + assert trailers["grpc-message"] == "" + + +def test_split_response_multiple_messages(): + body = _frame(b"a") + _frame(b"bb") + _frame(b"grpc-status:0\r\n", 0x80) + messages, trailers = split_response(body) + assert messages == [b"a", b"bb"] + assert trailers["grpc-status"] == "0" + + +def test_split_response_trailers_only(): + body = _frame(b"grpc-status:7\r\ngrpc-message:denied\r\n", 0x80) + messages, trailers = split_response(body) + assert messages == [] + assert trailers == {"grpc-status": "7", "grpc-message": "denied"} + + +def test_parse_trailers_lowercases_keys(): + parsed = parse_trailers(b"Grpc-Status:0\r\nGrpc-Message:ok\r\n") + assert parsed == {"grpc-status": "0", "grpc-message": "ok"} + + +def test_truncated_frame_raises(): + framed = encode_message(b"hello")[:-2] + with pytest.raises(ValueError): + list(iter_frames(framed)) + + +def test_compressed_message_frame_rejected(): + body = _frame(b"x", 0x01) + with pytest.raises(ValueError): + split_response(body) diff --git a/packages/grpc-web/tests/test_shim_install.py b/packages/grpc-web/tests/test_shim_install.py new file mode 100644 index 000000000..c950da902 --- /dev/null +++ b/packages/grpc-web/tests/test_shim_install.py @@ -0,0 +1,111 @@ +"""Shim/import tests. + +Installing the shim replaces ``sys.modules['grpc']`` process-wide, so each scenario runs +in a fresh subprocess to avoid clobbering the real ``grpc`` used by the rest of the suite. +""" + +import pathlib +import subprocess +import sys +import textwrap + +_SRC = str(pathlib.Path(__file__).resolve().parents[1] / "src") + + +def _run(body: str) -> subprocess.CompletedProcess: + script = f"import sys\nsys.path.insert(0, {_SRC!r})\n" + textwrap.dedent(body) + return subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + + +def test_import_weaviate_under_shim(): + result = _run( + """ + import weaviate_grpc_web + assert weaviate_grpc_web.install(force=True) is True + assert weaviate_grpc_web.is_installed() + + import grpc + assert getattr(grpc, "__weaviate_grpc_web_shim__", False) is True + assert grpc.__version__ == "1.72.1" + assert grpc._utilities.first_version_is_lower("1.0.0", "2.0.0") is False + from grpc.aio._typing import ChannelArgumentType # noqa: F401 + + import weaviate # must not raise even though grpcio is shimmed + from weaviate.proto.v1 import weaviate_pb2_grpc + from weaviate_grpc_web import GrpcWebChannel + + ch = GrpcWebChannel("localhost:50051", secure=False) + stub = weaviate_pb2_grpc.WeaviateStub(ch) + assert stub.Search is not None + assert stub.BatchObjects is not None + assert stub.BatchDelete is not None + assert isinstance(ch, grpc.aio.Channel) + print("OK") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_sync_channel_factory_raises_async_only(): + result = _run( + """ + import weaviate_grpc_web + weaviate_grpc_web.install(force=True) + import grpc + try: + grpc.insecure_channel("localhost:50051") + except RuntimeError as exc: + assert "async" in str(exc).lower() + print("OK") + else: + raise AssertionError("expected sync channel factory to raise") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_real_proto_unary_round_trip_under_shim(): + result = _run( + """ + import asyncio + import struct + import weaviate_grpc_web + weaviate_grpc_web.install(force=True) + + import weaviate # noqa: F401 + from weaviate.proto.v1 import tenants_pb2, weaviate_pb2_grpc + + reply = tenants_pb2.TenantsGetReply() + payload = reply.SerializeToString() + + def frame(p, flag=0x00): + return struct.pack(">BI", flag, len(p)) + p + + body = frame(payload) + frame(b"grpc-status:0\\r\\n", 0x80) + + async def sender(url, headers, body_in, timeout): + assert headers["authorization"] == "Bearer k" + assert url.endswith("/weaviate.v1.Weaviate/TenantsGet") + return 200, {}, body + + weaviate_grpc_web.set_sender(sender) + from weaviate_grpc_web import GrpcWebChannel + ch = GrpcWebChannel("localhost:50051", secure=False) + stub = weaviate_pb2_grpc.WeaviateStub(ch) + + async def main(): + res = await stub.TenantsGet( + tenants_pb2.TenantsGetRequest(), + metadata=[("authorization", "Bearer k")], + timeout=5, + ) + assert isinstance(res, tenants_pb2.TenantsGetReply) + print("OK") + + asyncio.run(main()) + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout diff --git a/packages/grpc-web/tests/test_transport.py b/packages/grpc-web/tests/test_transport.py new file mode 100644 index 000000000..c137c36b4 --- /dev/null +++ b/packages/grpc-web/tests/test_transport.py @@ -0,0 +1,158 @@ +"""In-process tests for the grpc-web channel/multicallable. + +These exercise the transport classes directly (they import their grpc base classes from +``weaviate_grpc_web._shim``, not from ``sys.modules['grpc']``), so no shim install is +needed and the real ``grpc`` in the dev environment is left untouched. +""" + +import asyncio +import struct +from typing import Dict, List, Optional, Tuple + +import pytest + +from weaviate_grpc_web._channel import GrpcWebChannel, set_sender +from weaviate_grpc_web._shim import AioChannel, AioRpcError, StatusCode + + +def _frame(payload: bytes, flag: int = 0x00) -> bytes: + return struct.pack(">BI", flag, len(payload)) + payload + + +def _ok_response(payload: bytes) -> bytes: + return _frame(payload) + _frame(b"grpc-status:0\r\n", 0x80) + + +class FakeSender: + def __init__( + self, status: int = 200, headers: Optional[Dict[str, str]] = None, body: bytes = b"" + ): + self.status = status + self.headers = headers or {} + self.body = body + self.calls: List[Tuple[str, Dict[str, str], bytes, Optional[float]]] = [] + + async def __call__(self, url, headers, body, timeout): + self.calls.append((url, headers, body, timeout)) + return self.status, self.headers, self.body + + +def _channel(sender: FakeSender, secure: bool = False) -> GrpcWebChannel: + return GrpcWebChannel("example.com:443", secure=secure, sender=sender) + + +def test_grpcwebchannel_is_grpc_aio_channel(): + assert issubclass(GrpcWebChannel, AioChannel) + assert isinstance(_channel(FakeSender()), AioChannel) + + +def test_unary_success_round_trip(): + sender = FakeSender(body=_ok_response(b"reply-bytes")) + channel = _channel(sender) + mc = channel.unary_unary( + "/weaviate.v1.Weaviate/Search", + request_serializer=lambda x: x, + response_deserializer=lambda b: b, + _registered_method=True, + ) + + result = asyncio.run(mc(b"request-bytes", metadata=[("authorization", "Bearer k")], timeout=5)) + + assert result == b"reply-bytes" + url, headers, body, timeout = sender.calls[0] + assert url == "http://example.com:443/weaviate.v1.Weaviate/Search" + assert body == _frame(b"request-bytes") + assert headers["content-type"] == "application/grpc-web+proto" + assert headers["authorization"] == "Bearer k" + assert headers["grpc-timeout"] == "5000m" + assert timeout == 5 + + +def test_secure_channel_uses_https(): + sender = FakeSender(body=_ok_response(b"x")) + channel = _channel(sender, secure=True) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + asyncio.run(mc(b"q")) + assert sender.calls[0][0].startswith("https://example.com:443/") + + +def test_health_call_without_metadata(): + sender = FakeSender(body=_ok_response(b"pong")) + channel = _channel(sender) + mc = channel.unary_unary("/grpc.health.v1.Health/Check", lambda x: x, lambda b: b) + # mirrors connect/v4.py:316 — request + timeout, no metadata + assert asyncio.run(mc(b"ping", timeout=2)) == b"pong" + + +def test_error_trailer_raises_aiorpcerror(): + body = _frame(b"grpc-status:7\r\ngrpc-message:nope\r\n", 0x80) + channel = _channel(FakeSender(body=body)) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.PERMISSION_DENIED + assert excinfo.value.code().name == "PERMISSION_DENIED" + assert excinfo.value.details() == "nope" + + +def test_percent_encoded_grpc_message_decoded(): + body = _frame(b"grpc-status:5\r\ngrpc-message:not%20found\r\n", 0x80) + channel = _channel(FakeSender(body=body)) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.details() == "not found" + + +def test_trailers_only_status_in_http_headers(): + channel = _channel( + FakeSender(status=200, headers={"grpc-status": "16", "grpc-message": "auth"}, body=b"") + ) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.UNAUTHENTICATED + + +def test_http_error_without_grpc_status_maps_to_code(): + channel = _channel(FakeSender(status=403, headers={}, body=b"")) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.PERMISSION_DENIED + + +def test_binary_metadata_base64_encoded(): + sender = FakeSender(body=_ok_response(b"x")) + channel = _channel(sender) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + asyncio.run(mc(b"q", metadata=[("trace-bin", b"\x00\x01\x02")])) + assert sender.calls[0][1]["trace-bin"] == "AAEC" + + +def test_stream_stream_raises_clear_error(): + channel = _channel(FakeSender()) + mc = channel.stream_stream("/weaviate.v1.Weaviate/BatchStream", lambda x: x, lambda b: b) + with pytest.raises(RuntimeError) as excinfo: + mc(request_iterator=iter([]), timeout=5, metadata=None) + assert "not supported over grpc-web" in str(excinfo.value) + + +def test_close_is_awaitable_noop(): + channel = _channel(FakeSender()) + assert asyncio.run(channel.close()) is None + + +def test_set_sender_overrides_default(): + sender = FakeSender(body=_ok_response(b"y")) + set_sender(sender) + try: + channel = GrpcWebChannel("h:1", secure=False) # no explicit sender + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + assert asyncio.run(mc(b"q")) == b"y" + finally: + # restore the real default so other tests/processes are unaffected + from weaviate_grpc_web._sender import pyfetch_sender + + set_sender(pyfetch_sender) diff --git a/proto_test/test_proto.py b/proto_test/test_proto.py index bedbf10c3..a964393ec 100644 --- a/proto_test/test_proto.py +++ b/proto_test/test_proto.py @@ -1,5 +1,7 @@ +import importlib +from importlib.metadata import PackageNotFoundError, version as metadata_version + import pytest -from importlib.metadata import version as metadata_version from packaging import version @@ -17,3 +19,30 @@ def test_proto_import(): import weaviate assert weaviate.version is not None + + +def test_grpcio_metadata_fallback_under_emscripten(monkeypatch): + """Fall back for grpcio when its metadata is absent; protobuf still surfaces. + + Under Pyodide/Emscripten grpcio is excluded via an environment marker, so its + distribution metadata is missing and ``get_version`` must fall back to a working + proto variant; a genuinely missing protobuf is still surfaced, not masked. + """ + mod = importlib.import_module("weaviate.proto.v1") + + def raises(pkg: str) -> str: + raise PackageNotFoundError(pkg) + + monkeypatch.setattr(mod, "metadata_version", raises) + + assert str(mod.get_version("grpcio")) == "1.72.1" + with pytest.raises(PackageNotFoundError): + mod.get_version("protobuf") + + +def test_get_version_passthrough_when_installed(monkeypatch): + """On a normal install the real version is returned unchanged (no fallback).""" + mod = importlib.import_module("weaviate.proto.v1") + monkeypatch.setattr(mod, "metadata_version", lambda pkg: "1.2.3") + assert str(mod.get_version("grpcio")) == "1.2.3" + assert str(mod.get_version("protobuf")) == "1.2.3" diff --git a/setup.cfg b/setup.cfg index 0b5ba855a..7343116a5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,7 +40,7 @@ install_requires = # When bumping authlib to >=2.0.0, remove the `authlib.jose` deprecation # warning filter implemented in `weaviate/_authlib_compat.py`. pydantic>=2.12.0,<3.0.0 - grpcio>=1.59.5,<1.80.0 + grpcio>=1.59.5,<1.80.0; sys_platform != "emscripten" protobuf>=4.21.6,<7.0.0 packaging>=21.0 python_requires = >=3.10 diff --git a/weaviate/proto/v1/__init__.py b/weaviate/proto/v1/__init__.py index 09171e683..a3821fed5 100644 --- a/weaviate/proto/v1/__init__.py +++ b/weaviate/proto/v1/__init__.py @@ -11,12 +11,26 @@ from packaging import version -from importlib.metadata import version as metadata_version +from importlib.metadata import PackageNotFoundError, version as metadata_version from weaviate.exceptions import WeaviateProtobufIncompatibility -def get_version(pkg: str)-> version.Version: - return version.parse(metadata_version(pkg)) +# Fallback grpcio version used only when grpcio is not installed as a distribution. +# This happens under Pyodide/Emscripten, where grpcio has no wheel and is excluded +# via the `sys_platform != "emscripten"` marker in setup.cfg; the grpc module itself +# is provided there by a pure-Python shim (see the weaviate-python-grpc-web package). +# On every normal install grpcio's metadata is present and the real version is used, +# so this branch is not taken. Restricted to grpcio so that a genuinely missing +# protobuf (which is required and pure-Python under Pyodide) is never masked. +_GRPCIO_FALLBACK_VERSION = "1.72.1" + +def get_version(pkg: str) -> version.Version: + try: + return version.parse(metadata_version(pkg)) + except PackageNotFoundError: + if pkg == "grpcio": + return version.parse(_GRPCIO_FALLBACK_VERSION) + raise pb_version, grpc_version = get_version("protobuf"), get_version("grpcio") if pb_version >= version.parse("6.30.0"): From 99f5356e80823562f032ce56be258efd9f0d8d67 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:30:53 +0300 Subject: [PATCH 2/9] feat(grpc-web): support grpc-web on the REST host:port via a base-path prefix Add a configurable gRPC base-path prefix so the client can talk to a grpc-web endpoint multiplexed onto the REST host:port (the production wire contract: grpc-web served under "/grpc-web/" via an in-process transcoder). - ConnectionParams gains `grpc_path_prefix`, threaded through `from_params` / `from_url` and the `connect_to_custom` / `use_async_with_custom` helpers. Normalized to a single leading slash, no trailing slash; None/"" == native gRPC. - `_check_port_collision` no longer rejects the same host:port when a grpc-web prefix is set; native gRPC (no prefix) still raises, unchanged. - `_grpc_channel` forwards the prefix to the transport as a ("grpc-web.path_prefix", prefix) channel option only in grpc-web mode, so the native channel options stay byte-for-byte unchanged. - weaviate-python-grpc-web: the shim's channel factories read that option and GrpcWebChannel prepends the prefix, so requests go to ://:/weaviate.v1.Weaviate/. Tested: new ConnectionParams unit tests (collision relaxed only with a prefix; native same-port still raises; option forwarded/omitted) and grpc-web transport tests for the prefixed/normalized URL. End-to-end verified against a vanguard transcoder on a shared host:port (insert_many/fetch_objects/aggregate over grpc-web with grpc_host == http_host == localhost:8090). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/weaviate_grpc_web/_channel.py | 6 +- .../grpc-web/src/weaviate_grpc_web/_shim.py | 22 ++- packages/grpc-web/tests/test_transport.py | 42 ++++++ test/test_connection_params.py | 128 ++++++++++++++++++ weaviate/connect/base.py | 36 ++++- weaviate/connect/helpers.py | 14 ++ 6 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 test/test_connection_params.py diff --git a/packages/grpc-web/src/weaviate_grpc_web/_channel.py b/packages/grpc-web/src/weaviate_grpc_web/_channel.py index 5e7b9bd55..2402bec02 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_channel.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_channel.py @@ -125,12 +125,16 @@ def __init__( target: Optional[str], secure: bool, options: Any = None, + path_prefix: str = "", sender: Optional[Sender] = None, ) -> None: if not target: raise ValueError("GrpcWebChannel requires a target (host:port)") scheme = "https" if secure else "http" self._base_url = f"{scheme}://{target}" + # Normalize to a single leading slash and no trailing slash; "" == native path. + cleaned = (path_prefix or "").strip("/") + self._path_prefix = f"/{cleaned}" if cleaned else "" self._sender: Sender = sender or get_sender() def unary_unary( @@ -173,7 +177,7 @@ async def _unary( if timeout is not None: headers["grpc-timeout"] = _encode_timeout(timeout) - url = self._base_url + path + url = self._base_url + self._path_prefix + path status, resp_headers, body = await self._sender( url, headers, encode_message(payload), timeout ) diff --git a/packages/grpc-web/src/weaviate_grpc_web/_shim.py b/packages/grpc-web/src/weaviate_grpc_web/_shim.py index 52b0ae635..c8226cecc 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_shim.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_shim.py @@ -170,12 +170,25 @@ def _sync_channel_unsupported(*_args: Any, **_kwargs: Any) -> "AioChannel": raise RuntimeError(_ASYNC_ONLY_MESSAGE) +def _path_prefix_from_options(options: Any) -> str: + """Extract the ``("grpc-web.path_prefix", prefix)`` channel option, or "" if absent.""" + for item in options or (): + if isinstance(item, (tuple, list)) and len(item) == 2 and item[0] == "grpc-web.path_prefix": + return item[1] or "" + return "" + + def _aio_secure_channel( target: Optional[str] = None, credentials: Any = None, options: Any = None, **_kw: Any ) -> AioChannel: from ._channel import GrpcWebChannel - return GrpcWebChannel(target=target, secure=True, options=options) + return GrpcWebChannel( + target=target, + secure=True, + options=options, + path_prefix=_path_prefix_from_options(options), + ) def _aio_insecure_channel( @@ -183,7 +196,12 @@ def _aio_insecure_channel( ) -> AioChannel: from ._channel import GrpcWebChannel - return GrpcWebChannel(target=target, secure=False, options=options) + return GrpcWebChannel( + target=target, + secure=False, + options=options, + path_prefix=_path_prefix_from_options(options), + ) def _noop(*_args: Any, **_kwargs: Any) -> None: diff --git a/packages/grpc-web/tests/test_transport.py b/packages/grpc-web/tests/test_transport.py index c137c36b4..33a8eafbf 100644 --- a/packages/grpc-web/tests/test_transport.py +++ b/packages/grpc-web/tests/test_transport.py @@ -144,6 +144,48 @@ def test_close_is_awaitable_noop(): assert asyncio.run(channel.close()) is None +def test_path_prefix_prepended_to_url(): + sender = FakeSender(body=_ok_response(b"r")) + channel = GrpcWebChannel( + "example.com:8090", secure=False, sender=sender, path_prefix="/grpc-web" + ) + mc = channel.unary_unary("/weaviate.v1.Weaviate/Search", lambda x: x, lambda b: b) + asyncio.run(mc(b"q")) + assert sender.calls[0][0] == "http://example.com:8090/grpc-web/weaviate.v1.Weaviate/Search" + + +@pytest.mark.parametrize( + "raw,expected_url", + [ + ("grpc-web", "http://h:1/grpc-web/svc/M"), + ("/grpc-web/", "http://h:1/grpc-web/svc/M"), + ("/a/b", "http://h:1/a/b/svc/M"), + ("", "http://h:1/svc/M"), + ], +) +def test_path_prefix_normalized_in_url(raw, expected_url): + sender = FakeSender(body=_ok_response(b"r")) + channel = GrpcWebChannel("h:1", secure=False, sender=sender, path_prefix=raw) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + asyncio.run(mc(b"q")) + assert sender.calls[0][0] == expected_url + + +def test_shim_factory_extracts_path_prefix_option(): + from weaviate_grpc_web._shim import _aio_insecure_channel + + with_prefix = _aio_insecure_channel( + target="h:1", + options=[("grpc.max_send_message_length", 1), ("grpc-web.path_prefix", "/grpc-web")], + ) + assert with_prefix._path_prefix == "/grpc-web" + + without_prefix = _aio_insecure_channel( + target="h:1", options=[("grpc.max_send_message_length", 1)] + ) + assert without_prefix._path_prefix == "" + + def test_set_sender_overrides_default(): sender = FakeSender(body=_ok_response(b"y")) set_sender(sender) diff --git a/test/test_connection_params.py b/test/test_connection_params.py new file mode 100644 index 000000000..69b07f5a4 --- /dev/null +++ b/test/test_connection_params.py @@ -0,0 +1,128 @@ +import pytest +from pydantic import ValidationError + +import weaviate.connect.base as base_mod +from weaviate.connect.base import ConnectionParams + + +def test_same_host_port_raises_without_prefix() -> None: + with pytest.raises(ValidationError, match="must be different"): + ConnectionParams.from_params( + http_host="localhost", + http_port=8090, + http_secure=False, + grpc_host="localhost", + grpc_port=8090, + grpc_secure=False, + ) + + +def test_from_url_same_host_port_raises_without_prefix() -> None: + with pytest.raises(ValidationError, match="must be different"): + ConnectionParams.from_url("http://localhost:8090", grpc_port=8090) + + +def test_same_host_port_allowed_with_grpc_web_prefix() -> None: + params = ConnectionParams.from_params( + http_host="localhost", + http_port=8090, + http_secure=False, + grpc_host="localhost", + grpc_port=8090, + grpc_secure=False, + grpc_path_prefix="/grpc-web", + ) + assert params._grpc_web_path_prefix == "/grpc-web" + + +def test_from_url_same_host_port_allowed_with_prefix() -> None: + params = ConnectionParams.from_url( + "http://localhost:8090", grpc_port=8090, grpc_path_prefix="/grpc-web" + ) + assert params._grpc_web_path_prefix == "/grpc-web" + + +def test_different_ports_still_ok_without_prefix() -> None: + params = ConnectionParams.from_params( + http_host="localhost", + http_port=8080, + http_secure=False, + grpc_host="localhost", + grpc_port=50051, + grpc_secure=False, + ) + assert params._grpc_web_path_prefix == "" + + +@pytest.mark.parametrize( + "raw,expected", + [ + (None, ""), + ("", ""), + ("/", ""), + ("grpc-web", "/grpc-web"), + ("/grpc-web", "/grpc-web"), + ("grpc-web/", "/grpc-web"), + ("/a/b/", "/a/b"), + ], +) +def test_path_prefix_normalization(raw, expected) -> None: + params = ConnectionParams.from_params( + http_host="h", + http_port=8080, + http_secure=False, + grpc_host="g", + grpc_port=50051, + grpc_secure=False, + grpc_path_prefix=raw, + ) + assert params._grpc_web_path_prefix == expected + + +def test_grpc_channel_forwards_path_prefix_option(monkeypatch) -> None: + captured: dict = {} + + def fake_insecure_channel(target, options=None, **kwargs): + captured["target"] = target + captured["options"] = options + return "CHANNEL" + + monkeypatch.setattr(base_mod.grpc.aio, "insecure_channel", fake_insecure_channel) + + params = ConnectionParams.from_params( + http_host="localhost", + http_port=8090, + http_secure=False, + grpc_host="localhost", + grpc_port=8090, + grpc_secure=False, + grpc_path_prefix="/grpc-web", + ) + channel = params._grpc_channel(proxies={}, grpc_msg_size=None, is_async=True) + + assert channel == "CHANNEL" + assert captured["target"] == "localhost:8090" + assert ("grpc-web.path_prefix", "/grpc-web") in captured["options"] + + +def test_grpc_channel_omits_option_without_prefix(monkeypatch) -> None: + captured: dict = {} + + def fake_insecure_channel(target, options=None, **kwargs): + captured["options"] = options + return "CHANNEL" + + monkeypatch.setattr(base_mod.grpc.aio, "insecure_channel", fake_insecure_channel) + + params = ConnectionParams.from_params( + http_host="localhost", + http_port=8080, + http_secure=False, + grpc_host="localhost", + grpc_port=50051, + grpc_secure=False, + ) + params._grpc_channel(proxies={}, grpc_msg_size=None, is_async=True) + + option_keys = [key for key, _ in captured["options"]] + assert "grpc-web.path_prefix" not in option_keys diff --git a/weaviate/connect/base.py b/weaviate/connect/base.py index 99607e3ae..df2c79a28 100644 --- a/weaviate/connect/base.py +++ b/weaviate/connect/base.py @@ -47,9 +47,19 @@ def is_gcp(self) -> bool: class ConnectionParams(BaseModel): http: ProtocolParams grpc: ProtocolParams + # Optional base-path prefix for a grpc-web endpoint served on the REST host:port + # (e.g. "/grpc-web"). None/"" means native gRPC. When set, sharing the REST + # host:port is permitted and the prefix is forwarded to the grpc-web transport. + grpc_path_prefix: Optional[str] = None @classmethod - def from_url(cls, url: str, grpc_port: int, grpc_secure: bool = False) -> "ConnectionParams": + def from_url( + cls, + url: str, + grpc_port: int, + grpc_secure: bool = False, + grpc_path_prefix: Optional[str] = None, + ) -> "ConnectionParams": parsed_url = urlparse(url) if parsed_url.scheme not in ["http", "https"]: raise ValueError(f"Unsupported scheme: {parsed_url.scheme}") @@ -69,6 +79,7 @@ def from_url(cls, url: str, grpc_port: int, grpc_secure: bool = False) -> "Conne port=grpc_port, secure=grpc_secure or parsed_url.scheme == "https", ), + grpc_path_prefix=grpc_path_prefix, ) @classmethod @@ -80,6 +91,7 @@ def from_params( grpc_host: str, grpc_port: int, grpc_secure: bool, + grpc_path_prefix: Optional[str] = None, ) -> "ConnectionParams": return cls( http=ProtocolParams( @@ -92,6 +104,7 @@ def from_params( port=grpc_port, secure=grpc_secure, ), + grpc_path_prefix=grpc_path_prefix, ) def is_gcp_on_wcd(self) -> bool: @@ -99,7 +112,10 @@ def is_gcp_on_wcd(self) -> bool: @model_validator(mode="after") def _check_port_collision(self: T) -> T: - if self.http.host == self.grpc.host and self.http.port == self.grpc.port: + same_endpoint = self.http.host == self.grpc.host and self.http.port == self.grpc.port + # grpc-web can be multiplexed onto the REST port under a base-path prefix, so a + # shared host:port is only a conflict for native gRPC (no prefix configured). + if same_endpoint and self._grpc_web_path_prefix == "": raise ValueError("http.port and grpc.port must be different if using the same host") return self @@ -111,6 +127,16 @@ def _grpc_address(self) -> Tuple[str, int]: def _grpc_target(self) -> str: return f"{self.grpc.host}:{self.grpc.port}" + @property + def _grpc_web_path_prefix(self) -> str: + """Return the normalized grpc-web base-path prefix; "" means native gRPC. + + A configured prefix is returned with a single leading slash and no trailing + slash (e.g. "grpc-web/" -> "/grpc-web"); empty/None -> "" (native gRPC). + """ + cleaned = (self.grpc_path_prefix or "").strip("/") + return f"/{cleaned}" if cleaned else "" + def _grpc_channel( self, proxies: Dict[str, str], @@ -134,6 +160,12 @@ def _grpc_channel( if grpc_config is not None and grpc_config.channel_options is not None: options.extend(grpc_config.channel_options) + # In grpc-web mode, forward the base-path prefix to the transport via channel + # options (consumed by the weaviate-python-grpc-web shim). Not added for native + # gRPC, so the native channel options stay byte-for-byte unchanged. + if (prefix := self._grpc_web_path_prefix) != "": + options.append(("grpc-web.path_prefix", prefix)) + if is_async: mod = grpc.aio else: diff --git a/weaviate/connect/helpers.py b/weaviate/connect/helpers.py index 29faaa3c2..c9aed194b 100644 --- a/weaviate/connect/helpers.py +++ b/weaviate/connect/helpers.py @@ -290,6 +290,7 @@ def connect_to_custom( additional_config: Optional[AdditionalConfig] = None, auth_credentials: Optional[AuthCredentials] = None, skip_init_checks: bool = False, + grpc_path_prefix: Optional[str] = None, ) -> WeaviateClient: """Connect to a Weaviate instance with custom connection parameters. @@ -312,6 +313,11 @@ def connect_to_custom( a bearer token, in which case use `weaviate.classes.init.Auth.bearer_token()`, a client secret, in which case use `weaviate.classes.init.Auth.client_credentials()` or a username and password, in which case use `weaviate.classes.init.Auth.client_password()`. skip_init_checks: Whether to skip the initialization checks when connecting to Weaviate. + grpc_path_prefix: Optional base-path prefix for a grpc-web endpoint served on the + same host:port as REST (e.g. "/grpc-web"). When set, gRPC requests are sent + over grpc-web to ``://:/...`` and sharing + the REST host:port is allowed. Requires the ``weaviate-python-grpc-web`` + package. Defaults to None (native gRPC). Returns: The client connected to the instance with the required parameters set appropriately. @@ -353,6 +359,7 @@ def connect_to_custom( grpc_host=grpc_host, grpc_port=grpc_port, grpc_secure=grpc_secure, + grpc_path_prefix=grpc_path_prefix, ), auth_client_secret=__parse_auth_credentials(auth_credentials), additional_headers=headers, @@ -587,6 +594,7 @@ def use_async_with_custom( additional_config: Optional[AdditionalConfig] = None, auth_credentials: Optional[AuthCredentials] = None, skip_init_checks: bool = False, + grpc_path_prefix: Optional[str] = None, ) -> WeaviateAsyncClient: """Create an async client object ready to connect to a Weaviate instance with custom connection parameters. @@ -609,6 +617,11 @@ def use_async_with_custom( a bearer token, in which case use `weaviate.classes.init.Auth.bearer_token()`, a client secret, in which case use `weaviate.classes.init.Auth.client_credentials()` or a username and password, in which case use `weaviate.classes.init.Auth.client_password()`. skip_init_checks: Whether to skip the initialization checks when connecting to Weaviate. + grpc_path_prefix: Optional base-path prefix for a grpc-web endpoint served on the + same host:port as REST (e.g. "/grpc-web"). When set, gRPC requests are sent + over grpc-web to ``://:/...`` and sharing + the REST host:port is allowed. Requires the ``weaviate-python-grpc-web`` + package. Defaults to None (native gRPC). Returns: The client connected to the instance with the required parameters set appropriately. @@ -652,6 +665,7 @@ def use_async_with_custom( grpc_host=grpc_host, grpc_port=grpc_port, grpc_secure=grpc_secure, + grpc_path_prefix=grpc_path_prefix, ), auth_client_secret=__parse_auth_credentials(auth_credentials), additional_headers=headers, From fe208c09a8e899b136323f84404d6c9fd6790489 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:51:38 +0300 Subject: [PATCH 3/9] fix(grpc-web): surface transport/parse failures as AioRpcError, enforce client deadline Addresses Copilot review feedback on #2056. The grpc-web transport boundary now only ever raises grpc.aio.AioRpcError, and call timeouts are enforced client-side: - GrpcWebChannel._unary wraps the send in asyncio.wait_for(timeout) so a stalled browser request can't hang forever (pyfetch has no timeout argument of its own); a timeout maps to AioRpcError(DEADLINE_EXCEEDED). - Transport/network errors raised by the sender map to AioRpcError(UNAVAILABLE), which the client's existing exponential backoff retries. - Malformed/truncated/compressed framing and a non-integer grpc-status (which previously escaped as a bare ValueError) map to AioRpcError(INTERNAL). New transport tests cover each path (deadline, unavailable, malformed frame, malformed grpc-status). Existing transport/framing/shim tests and the same-host:port end-to-end check against a vanguard transcoder still pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/weaviate_grpc_web/_channel.py | 39 ++++++++++++++++-- .../grpc-web/src/weaviate_grpc_web/_sender.py | 3 +- packages/grpc-web/tests/test_transport.py | 41 +++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/grpc-web/src/weaviate_grpc_web/_channel.py b/packages/grpc-web/src/weaviate_grpc_web/_channel.py index 2402bec02..853e4fc0b 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_channel.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_channel.py @@ -12,6 +12,7 @@ raises a clear error. """ +import asyncio import base64 import urllib.parse from typing import Any, Callable, Dict, Optional @@ -178,10 +179,40 @@ async def _unary( headers["grpc-timeout"] = _encode_timeout(timeout) url = self._base_url + self._path_prefix + path - status, resp_headers, body = await self._sender( - url, headers, encode_message(payload), timeout - ) - return self._handle_response(status, resp_headers, body, deserialize) + framed = encode_message(payload) + + # Send. Enforce a client-side deadline (the grpc-timeout header is server-side + # only; pyfetch ignores its timeout arg, so without this a stalled request could + # hang forever). Any transport/parse failure is surfaced as AioRpcError so the + # client only ever sees gRPC error types (never a bare ValueError/TimeoutError). + try: + send = self._sender(url, headers, framed, timeout) + if timeout is not None: + status, resp_headers, body = await asyncio.wait_for(send, timeout) + else: + status, resp_headers, body = await send + except AioRpcError: + raise + except asyncio.TimeoutError as exc: + raise AioRpcError( + code=StatusCode.DEADLINE_EXCEEDED, + details=f"grpc-web request to {path} timed out after {timeout}s", + ) from exc + except Exception as exc: # network/transport failure -> retryable UNAVAILABLE + raise AioRpcError( + code=StatusCode.UNAVAILABLE, + details=f"grpc-web transport error for {path}: {exc}", + ) from exc + + try: + return self._handle_response(status, resp_headers, body, deserialize) + except AioRpcError: + raise + except Exception as exc: # malformed framing / status / payload + raise AioRpcError( + code=StatusCode.INTERNAL, + details=f"malformed grpc-web response for {path}: {exc}", + ) from exc @staticmethod def _handle_response( diff --git a/packages/grpc-web/src/weaviate_grpc_web/_sender.py b/packages/grpc-web/src/weaviate_grpc_web/_sender.py index 0ac0088e7..2a879ec49 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_sender.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_sender.py @@ -19,7 +19,8 @@ async def pyfetch_sender( """Default browser sender. Imports ``pyodide.http`` lazily so this module stays importable on CPython (where - ``pyodide`` does not exist). + ``pyodide`` does not exist). ``pyfetch`` has no timeout parameter of its own; the + call deadline is enforced by ``GrpcWebChannel._unary`` via ``asyncio.wait_for``. """ from pyodide.http import pyfetch # type: ignore[import-not-found] diff --git a/packages/grpc-web/tests/test_transport.py b/packages/grpc-web/tests/test_transport.py index 33a8eafbf..753f227de 100644 --- a/packages/grpc-web/tests/test_transport.py +++ b/packages/grpc-web/tests/test_transport.py @@ -139,6 +139,47 @@ def test_stream_stream_raises_clear_error(): assert "not supported over grpc-web" in str(excinfo.value) +def test_timeout_maps_to_deadline_exceeded(): + async def slow_sender(url, headers, body, timeout): + await asyncio.sleep(0.5) + return 200, {}, _ok_response(b"x") + + channel = GrpcWebChannel("h:1", secure=False, sender=slow_sender) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q", timeout=0.01)) + assert excinfo.value.code() is StatusCode.DEADLINE_EXCEEDED + + +def test_transport_exception_maps_to_unavailable(): + async def boom(url, headers, body, timeout): + raise ConnectionError("connection refused") + + channel = GrpcWebChannel("h:1", secure=False, sender=boom) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.UNAVAILABLE + + +def test_malformed_frame_maps_to_internal(): + # A 3-byte body cannot contain even a 5-byte frame header -> framing ValueError. + channel = _channel(FakeSender(body=b"\x00\x00\x00")) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.INTERNAL + + +def test_malformed_grpc_status_maps_to_internal(): + body = _frame(b"grpc-status:notanint\r\n", 0x80) + channel = _channel(FakeSender(body=body)) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.INTERNAL + + def test_close_is_awaitable_noop(): channel = _channel(FakeSender()) assert asyncio.run(channel.close()) is None From 833bb59b6174f40a61a9080d92048ad12c006726 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:58:03 +0300 Subject: [PATCH 4/9] test(grpc-web): skip get_version unit tests when grpcio/protobuf are incompatible The proto version-gate CI matrix installs deliberately-incompatible grpcio/protobuf pairs so importing weaviate.proto.v1 raises WeaviateProtobufIncompatibility (by design, covered by test_proto_import). The two get_version fallback tests added in this PR import weaviate.proto.v1, which re-runs that gate and errors in those cells. Skip them when the installed pair is incompatible (computed without importing weaviate); the fallback is still exercised in every compatible cell. Co-Authored-By: Claude Opus 4.8 (1M context) --- proto_test/test_proto.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/proto_test/test_proto.py b/proto_test/test_proto.py index a964393ec..7b089d875 100644 --- a/proto_test/test_proto.py +++ b/proto_test/test_proto.py @@ -5,6 +5,26 @@ from packaging import version +# The CI matrix deliberately installs incompatible grpcio/protobuf pairs to exercise the +# version gate in weaviate/proto/v1/__init__.py. In those cells the package raises on +# import (covered by test_proto_import), so the get_version unit tests below are skipped; +# the fallback they test still runs in every compatible cell. This check imports nothing +# from weaviate, so the test module always loads. +def _versions_incompatible() -> bool: + """Whether the installed grpcio/protobuf pair makes ``import weaviate.proto.v1`` raise.""" + try: + grpc_ver = version.parse(metadata_version("grpcio")) + pb_ver = version.parse(metadata_version("protobuf")) + except PackageNotFoundError: + return False + return (pb_ver >= version.parse("6.30.0") and grpc_ver < version.parse("1.72.0")) or ( + pb_ver >= version.parse("5.26.1") and grpc_ver < version.parse("1.63.0") + ) + + +_INCOMPATIBLE_GRPC_PB = _versions_incompatible() + + def test_proto_import(): grpc_ver = version.parse(metadata_version("grpcio")) pb_ver = version.parse(metadata_version("protobuf")) @@ -21,6 +41,12 @@ def test_proto_import(): assert weaviate.version is not None +@pytest.mark.skipif( + _INCOMPATIBLE_GRPC_PB, + reason="weaviate.proto.v1 cannot be imported with an incompatible grpcio/protobuf " + "pair (CI version-gate matrix); the gate is covered by test_proto_import and the " + "fallback is exercised in every compatible cell", +) def test_grpcio_metadata_fallback_under_emscripten(monkeypatch): """Fall back for grpcio when its metadata is absent; protobuf still surfaces. @@ -40,6 +66,12 @@ def raises(pkg: str) -> str: mod.get_version("protobuf") +@pytest.mark.skipif( + _INCOMPATIBLE_GRPC_PB, + reason="weaviate.proto.v1 cannot be imported with an incompatible grpcio/protobuf " + "pair (CI version-gate matrix); the gate is covered by test_proto_import and the " + "fallback is exercised in every compatible cell", +) def test_get_version_passthrough_when_installed(monkeypatch): """On a normal install the real version is returned unchanged (no fallback).""" mod = importlib.import_module("weaviate.proto.v1") From 0f2f6da1e5e1788809d7871e427451d3127440ce Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:24:34 +0300 Subject: [PATCH 5/9] fix(grpc-web): guard grpc-web mode (shim+async required), tighten timeout/fallback Addresses the second Copilot review on #2056: - base.py: grpc_path_prefix now fails fast in _grpc_channel when used on a sync client or when the weaviate-python-grpc-web shim is not active, instead of silently building a native grpcio channel that ignores the prefix. - helpers.py: connect_to_custom (sync) rejects a non-empty grpc_path_prefix and points to use_async_with_custom; the docstring is clarified that grpc-web is async-only. - _channel.py: _encode_timeout rounds the grpc-timeout up (math.ceil) so we never advertise a shorter deadline than requested. - proto/v1/__init__.py: the grpcio metadata fallback is restricted to Emscripten, so a broken/partial grpcio install on a normal platform surfaces as PackageNotFoundError instead of being masked by a fallback stub version. Tests added/updated (sync/no-shim rejection, off-emscripten raise, grpc-timeout round-up). End-to-end against a vanguard transcoder on a shared host:port still passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/weaviate_grpc_web/_channel.py | 7 ++- packages/grpc-web/tests/test_transport.py | 9 ++++ proto_test/test_proto.py | 20 +++++++++ test/test_connection_params.py | 45 +++++++++++++++++++ weaviate/connect/base.py | 21 +++++++-- weaviate/connect/helpers.py | 16 ++++--- weaviate/proto/v1/__init__.py | 11 +++-- 7 files changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/grpc-web/src/weaviate_grpc_web/_channel.py b/packages/grpc-web/src/weaviate_grpc_web/_channel.py index 853e4fc0b..cb16f7bc2 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_channel.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_channel.py @@ -14,6 +14,7 @@ import asyncio import base64 +import math import urllib.parse from typing import Any, Callable, Dict, Optional @@ -37,10 +38,12 @@ def get_sender() -> Sender: def _encode_timeout(seconds: float) -> str: """Encode a timeout as a grpc-timeout header value (````).""" - millis = max(1, int(seconds * 1000)) + # Round up so we never advertise a shorter deadline than requested (which would risk + # premature server-side cancellation); grpc-timeout takes a positive integer + unit. + millis = max(1, math.ceil(seconds * 1000)) if millis < 100_000_000: return f"{millis}m" - return f"{max(1, int(seconds))}S" + return f"{max(1, math.ceil(seconds))}S" def _fold_metadata(headers: Dict[str, str], metadata: Any) -> None: diff --git a/packages/grpc-web/tests/test_transport.py b/packages/grpc-web/tests/test_transport.py index 753f227de..f931fa8e1 100644 --- a/packages/grpc-web/tests/test_transport.py +++ b/packages/grpc-web/tests/test_transport.py @@ -180,6 +180,15 @@ def test_malformed_grpc_status_maps_to_internal(): assert excinfo.value.code() is StatusCode.INTERNAL +def test_grpc_timeout_header_rounds_up(): + sender = FakeSender(body=_ok_response(b"x")) + channel = _channel(sender) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + # 123.4ms must round UP to 124ms (never advertise a shorter deadline than requested). + asyncio.run(mc(b"q", timeout=0.1234)) + assert sender.calls[0][1]["grpc-timeout"] == "124m" + + def test_close_is_awaitable_noop(): channel = _channel(FakeSender()) assert asyncio.run(channel.close()) is None diff --git a/proto_test/test_proto.py b/proto_test/test_proto.py index 7b089d875..54dd89a15 100644 --- a/proto_test/test_proto.py +++ b/proto_test/test_proto.py @@ -60,12 +60,32 @@ def raises(pkg: str) -> str: raise PackageNotFoundError(pkg) monkeypatch.setattr(mod, "metadata_version", raises) + monkeypatch.setattr("sys.platform", "emscripten") assert str(mod.get_version("grpcio")) == "1.72.1" with pytest.raises(PackageNotFoundError): mod.get_version("protobuf") +@pytest.mark.skipif( + _INCOMPATIBLE_GRPC_PB, + reason="weaviate.proto.v1 cannot be imported with an incompatible grpcio/protobuf " + "pair (CI version-gate matrix); the gate is covered by test_proto_import and the " + "fallback is exercised in every compatible cell", +) +def test_grpcio_missing_metadata_raises_off_emscripten(monkeypatch): + """Off Emscripten, missing grpcio metadata surfaces instead of being masked.""" + mod = importlib.import_module("weaviate.proto.v1") + + def raises(pkg: str) -> str: + raise PackageNotFoundError(pkg) + + monkeypatch.setattr(mod, "metadata_version", raises) + monkeypatch.setattr("sys.platform", "linux") + with pytest.raises(PackageNotFoundError): + mod.get_version("grpcio") + + @pytest.mark.skipif( _INCOMPATIBLE_GRPC_PB, reason="weaviate.proto.v1 cannot be imported with an incompatible grpcio/protobuf " diff --git a/test/test_connection_params.py b/test/test_connection_params.py index 69b07f5a4..041079cfc 100644 --- a/test/test_connection_params.py +++ b/test/test_connection_params.py @@ -3,6 +3,7 @@ import weaviate.connect.base as base_mod from weaviate.connect.base import ConnectionParams +from weaviate.exceptions import WeaviateInvalidInputError def test_same_host_port_raises_without_prefix() -> None: @@ -88,6 +89,8 @@ def fake_insecure_channel(target, options=None, **kwargs): return "CHANNEL" monkeypatch.setattr(base_mod.grpc.aio, "insecure_channel", fake_insecure_channel) + # grpc-web mode requires the shim to be active; simulate it being installed. + monkeypatch.setattr(base_mod.grpc, "__weaviate_grpc_web_shim__", True, raising=False) params = ConnectionParams.from_params( http_host="localhost", @@ -105,6 +108,48 @@ def fake_insecure_channel(target, options=None, **kwargs): assert ("grpc-web.path_prefix", "/grpc-web") in captured["options"] +def _grpc_web_params() -> ConnectionParams: + return ConnectionParams.from_params( + http_host="localhost", + http_port=8090, + http_secure=False, + grpc_host="localhost", + grpc_port=8090, + grpc_secure=False, + grpc_path_prefix="/grpc-web", + ) + + +def test_grpc_channel_rejects_prefix_without_shim(monkeypatch) -> None: + # No grpc-web shim active -> must fail fast instead of silently building a native + # grpcio channel that ignores the prefix. + monkeypatch.delattr(base_mod.grpc, "__weaviate_grpc_web_shim__", raising=False) + with pytest.raises(WeaviateInvalidInputError, match="weaviate-python-grpc-web"): + _grpc_web_params()._grpc_channel(proxies={}, grpc_msg_size=None, is_async=True) + + +def test_grpc_channel_rejects_prefix_for_sync_client() -> None: + # grpc-web is async-only; a sync channel with a prefix must be rejected. + with pytest.raises(WeaviateInvalidInputError, match="async"): + _grpc_web_params()._grpc_channel(proxies={}, grpc_msg_size=None, is_async=False) + + +def test_connect_to_custom_rejects_grpc_web_prefix() -> None: + # The synchronous helper must reject grpc-web up front (before connecting). + import weaviate + + with pytest.raises(WeaviateInvalidInputError, match="async-only"): + weaviate.connect_to_custom( + http_host="localhost", + http_port=8080, + http_secure=False, + grpc_host="localhost", + grpc_port=8080, + grpc_secure=False, + grpc_path_prefix="/grpc-web", + ) + + def test_grpc_channel_omits_option_without_prefix(monkeypatch) -> None: captured: dict = {} diff --git a/weaviate/connect/base.py b/weaviate/connect/base.py index df2c79a28..eaf72f73e 100644 --- a/weaviate/connect/base.py +++ b/weaviate/connect/base.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, field_validator, model_validator from weaviate.config import GrpcConfig, Proxies +from weaviate.exceptions import WeaviateInvalidInputError from weaviate.types import NUMBER from weaviate.util import is_weaviate_domain @@ -160,10 +161,24 @@ def _grpc_channel( if grpc_config is not None and grpc_config.channel_options is not None: options.extend(grpc_config.channel_options) - # In grpc-web mode, forward the base-path prefix to the transport via channel - # options (consumed by the weaviate-python-grpc-web shim). Not added for native - # gRPC, so the native channel options stay byte-for-byte unchanged. + # grpc-web mode (prefix set): only valid for an async client, and only when the + # weaviate-python-grpc-web shim has replaced the grpc module (it consumes the + # grpc-web.path_prefix option). Fail fast otherwise — a native grpcio channel + # would silently ignore the option and route over native gRPC, a confusing + # misconfiguration. Nothing is added for native gRPC, so its channel options stay + # byte-for-byte unchanged. if (prefix := self._grpc_web_path_prefix) != "": + if not is_async: + raise WeaviateInvalidInputError( + "grpc_path_prefix (grpc-web) is only supported for async clients; " + "use use_async_with_custom(...) / WeaviateAsyncClient" + ) + if not getattr(grpc, "__weaviate_grpc_web_shim__", False): + raise WeaviateInvalidInputError( + "grpc_path_prefix enables grpc-web, which requires the " + "'weaviate-python-grpc-web' package (it installs a grpc shim before " + "'import weaviate'); it is not active in this environment" + ) options.append(("grpc-web.path_prefix", prefix)) if is_async: diff --git a/weaviate/connect/helpers.py b/weaviate/connect/helpers.py index c9aed194b..bb9e3d771 100644 --- a/weaviate/connect/helpers.py +++ b/weaviate/connect/helpers.py @@ -17,6 +17,7 @@ from weaviate.config import AdditionalConfig from weaviate.connect.base import ConnectionParams, ProtocolParams from weaviate.embedded import WEAVIATE_VERSION, EmbeddedOptions +from weaviate.exceptions import WeaviateInvalidInputError from weaviate.util import docstring_deprecated from weaviate.validator import _validate_input, _ValidateArgument from weaviate.warnings import _Warnings @@ -313,11 +314,11 @@ def connect_to_custom( a bearer token, in which case use `weaviate.classes.init.Auth.bearer_token()`, a client secret, in which case use `weaviate.classes.init.Auth.client_credentials()` or a username and password, in which case use `weaviate.classes.init.Auth.client_password()`. skip_init_checks: Whether to skip the initialization checks when connecting to Weaviate. - grpc_path_prefix: Optional base-path prefix for a grpc-web endpoint served on the - same host:port as REST (e.g. "/grpc-web"). When set, gRPC requests are sent - over grpc-web to ``://:/...`` and sharing - the REST host:port is allowed. Requires the ``weaviate-python-grpc-web`` - package. Defaults to None (native gRPC). + grpc_path_prefix: grpc-web base-path prefix. grpc-web is async-only, so it is NOT + supported by the synchronous ``connect_to_custom`` — passing a non-empty value + raises ``WeaviateInvalidInputError``. Use + ``use_async_with_custom(..., grpc_path_prefix=...)`` instead. Defaults to None + (native gRPC). Returns: The client connected to the instance with the required parameters set appropriately. @@ -350,6 +351,11 @@ def connect_to_custom( True >>> # The connection is automatically closed when the context is exited. """ + if grpc_path_prefix: + raise WeaviateInvalidInputError( + "grpc_path_prefix enables grpc-web, which is async-only; use " + "use_async_with_custom(...) instead of connect_to_custom(...)" + ) return __connect( WeaviateClient( ConnectionParams.from_params( diff --git a/weaviate/proto/v1/__init__.py b/weaviate/proto/v1/__init__.py index a3821fed5..f20e52e05 100644 --- a/weaviate/proto/v1/__init__.py +++ b/weaviate/proto/v1/__init__.py @@ -1,3 +1,4 @@ +import sys import warnings @@ -19,16 +20,18 @@ # This happens under Pyodide/Emscripten, where grpcio has no wheel and is excluded # via the `sys_platform != "emscripten"` marker in setup.cfg; the grpc module itself # is provided there by a pure-Python shim (see the weaviate-python-grpc-web package). -# On every normal install grpcio's metadata is present and the real version is used, -# so this branch is not taken. Restricted to grpcio so that a genuinely missing -# protobuf (which is required and pure-Python under Pyodide) is never masked. +# On every normal install grpcio's metadata is present and the real version is used, so +# this branch is not taken. Restricted to grpcio AND to Emscripten, so that a broken or +# partial grpcio install on a normal platform (metadata missing) still surfaces as +# PackageNotFoundError instead of silently selecting a fallback stub, and so a genuinely +# missing protobuf (required and pure-Python under Pyodide) is never masked. _GRPCIO_FALLBACK_VERSION = "1.72.1" def get_version(pkg: str) -> version.Version: try: return version.parse(metadata_version(pkg)) except PackageNotFoundError: - if pkg == "grpcio": + if pkg == "grpcio" and sys.platform == "emscripten": return version.parse(_GRPCIO_FALLBACK_VERSION) raise From 3669363f74c1b3f039d1f54923f6330931f6fd31 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:05:11 +0300 Subject: [PATCH 6/9] feat(grpc-web): route httpx REST calls over fetch under Emscripten First live Pyodide run showed the gRPC path working while every REST call (is_ready, schema ops, batch references) failed: httpx/httpcore open raw sockets, which do not exist under WASM, surfacing only as "Connection to Weaviate failed. Details: " with empty details. Adds _httpx_fetch.py: patches httpx.AsyncHTTPTransport.handle_async_request to send requests via pyodide.http.pyfetch (buffered responses, fetch-managed headers stripped, best-effort AbortSignal timeout). Installed by the package bootstrap under Emscripten only, with install_fetch_transport(force=True) for CPython testing. Verified live from Pyodide-in-Node against a WCD dev cluster behind a grpc-web transcoder: is_ready, collection create/delete, insert_many with server-side vectorization, aggregate, and near_text all pass. Co-Authored-By: Claude Fable 5 --- .../src/weaviate_grpc_web/__init__.py | 11 +++ .../src/weaviate_grpc_web/_httpx_fetch.py | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py diff --git a/packages/grpc-web/src/weaviate_grpc_web/__init__.py b/packages/grpc-web/src/weaviate_grpc_web/__init__.py index 79a6cb1d8..542d075bd 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/__init__.py +++ b/packages/grpc-web/src/weaviate_grpc_web/__init__.py @@ -26,6 +26,8 @@ __all__ = [ "install", "is_installed", + "install_fetch_transport", + "is_fetch_transport_installed", "set_sender", "make_httpx_sender", "GrpcWebChannel", @@ -40,6 +42,11 @@ def _bootstrap() -> None: # effect. ``setdefault`` lets a user override it explicitly. os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") install() + # The REST path needs fetch too: httpx/httpcore open raw sockets, which do + # not exist under WASM. Imported lazily so CPython imports stay light. + from ._httpx_fetch import install_fetch_transport + + install_fetch_transport() _bootstrap() @@ -48,4 +55,8 @@ def _bootstrap() -> None: # ``._shim`` (not via ``sys.modules['grpc']``), so importing them is safe regardless of # whether the shim was installed. from ._channel import GrpcWebChannel, set_sender # noqa: E402 +from ._httpx_fetch import ( # noqa: E402 + install_fetch_transport, + is_fetch_transport_installed, +) from ._sender import make_httpx_sender # noqa: E402 diff --git a/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py b/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py new file mode 100644 index 000000000..8a84e5d11 --- /dev/null +++ b/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py @@ -0,0 +1,95 @@ +"""fetch-based httpx transport for Pyodide/Emscripten. + +The base client's REST path uses ``httpx.AsyncClient`` with explicit +``httpx.AsyncHTTPTransport`` mounts (``weaviate/connect/v4.py``). httpcore opens raw +sockets, which do not exist under WASM, so without this module every REST call +(``is_ready``, collection config, batch references, …) fails with an empty connection +error even though the grpc-web data path works. + +Installing reroutes ``AsyncHTTPTransport.handle_async_request`` through the browser's +``fetch`` via ``pyodide.http.pyfetch`` — the same install-globally-under-Emscripten +philosophy as the grpc shim in ``_shim.py``. Responses are fully buffered, which matches +how the base client consumes them (JSON bodies, no streaming). +""" + +import sys +from typing import Dict + +import httpx + +_installed = False + +# Hop-by-hop / connection-managed headers that the browser's fetch controls itself. +# Browsers silently drop forbidden headers, but Node's undici (used by the CPython/Node +# test path) rejects some of them outright, so strip them before handing off. +_FETCH_MANAGED_HEADERS = { + "host", + "connection", + "accept-encoding", + "content-length", + "transfer-encoding", +} + + +async def _read_request_body(request: httpx.Request) -> bytes: + try: + return request.content + except httpx.RequestNotRead: + return await request.aread() + + +async def _fetch_handle_async_request( + self: httpx.AsyncHTTPTransport, request: httpx.Request +) -> httpx.Response: + from pyodide.http import pyfetch # type: ignore[import-not-found] + + headers: Dict[str, str] = { + k: v for k, v in request.headers.items() if k.lower() not in _FETCH_MANAGED_HEADERS + } + kwargs: Dict[str, object] = {} + body = await _read_request_body(request) + if body: + # fetch rejects GET/HEAD requests that carry a body + kwargs["body"] = body + + timeouts = request.extensions.get("timeout") or {} + timeout = timeouts.get("read") or timeouts.get("connect") or timeouts.get("pool") + if timeout: + try: + from js import AbortSignal # type: ignore[import-not-found] + + kwargs["signal"] = AbortSignal.timeout(int(timeout * 1000)) + except Exception: # pragma: no cover - AbortSignal.timeout availability varies + pass + + response = await pyfetch(str(request.url), method=request.method, headers=headers, **kwargs) + data = await response.bytes() + try: + resp_headers = dict(response.headers) + except Exception: # pragma: no cover - header shape varies across Pyodide versions + resp_headers = {} + return httpx.Response( + status_code=int(response.status), + headers=resp_headers, + content=data, + request=request, + ) + + +def install_fetch_transport(force: bool = False) -> None: + """Patch ``httpx.AsyncHTTPTransport`` to send requests through ``fetch``. + + Installs only under Emscripten unless ``force=True`` (CPython testing, where a + ``pyodide`` stub must be importable). Idempotent. + """ + global _installed + if _installed: + return + if not force and sys.platform != "emscripten": + return + httpx.AsyncHTTPTransport.handle_async_request = _fetch_handle_async_request # type: ignore[method-assign] + _installed = True + + +def is_fetch_transport_installed() -> bool: + return _installed From b8ac294dd1df6ed885904b51458bd19a1581bd24 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:18:38 +0300 Subject: [PATCH 7/9] fix: make the async client safe under WASM/Pyodide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads, subprocesses, and blocking sleeps do not exist under Emscripten, and transport errors often stringify to '' — audited every async runtime path and fixed the failures: - OIDC: refresh tokens with an asyncio task instead of the TokenRefresh daemon thread + event-loop sidecar thread (both crash connect() under WASM with "can't start new thread" after the token fetch already succeeded). close() cancels the task; a failed or retried connect() cancels the previous refresher instead of orphaning it against the IdP. The sync client keeps the thread-based path unchanged. - batch: batch.stream() fails fast with a typed error over grpc-web (bidi streaming impossible) instead of silently dropping objects; flush() raises the background failure instead of spinning forever; _wait() preserves partial results before raising. - embedded: raise an explicit "not supported under WebAssembly/Pyodide" error instead of the false "processes are already listening" produced by Emscripten's lazy socket emulation. - error surfacing: include the exception type where str(e) can be empty (the live "Connection to Weaviate failed. Details: " bug); stop rewriting unrelated RuntimeErrors as "client is closed"; chain OIDC discovery errors; log is_ready/is_live failures via logger instead of print(); tolerate OSError in the best-effort PyPI version check (CSP blocks pypi.org in browsers and used to fail connect()). - wait_for_weaviate (async): asyncio.sleep instead of time.sleep, which stalls the event loop. - dedupe the grpc-web shim marker sniff into _grpc_web_shim_active(). Co-Authored-By: Claude Fable 5 --- mock_tests/test_auth.py | 85 +++++++++ test/test_batch_async.py | 76 ++++++++ test/test_wasm_compat.py | 73 +++++++ weaviate/client_executor.py | 7 +- weaviate/collections/batch/async_.py | 22 ++- weaviate/connect/base.py | 13 +- weaviate/connect/v4.py | 273 ++++++++++++++++++--------- weaviate/embedded.py | 9 + 8 files changed, 464 insertions(+), 94 deletions(-) create mode 100644 test/test_batch_async.py create mode 100644 test/test_wasm_compat.py diff --git a/mock_tests/test_auth.py b/mock_tests/test_auth.py index 192f0eb6d..fc07c904e 100644 --- a/mock_tests/test_auth.py +++ b/mock_tests/test_auth.py @@ -84,6 +84,44 @@ def test_client_credentials(weaviate_auth_mock: HTTPServer, start_grpc_server: g weaviate_auth_mock.check_assertions() +@pytest.mark.asyncio +async def test_client_credentials_refresh_async( + weaviate_auth_mock: HTTPServer, start_grpc_server: grpc.Server +) -> None: + """Test the refresh_session branch of the async token refresher. + + Client-credentials tokens carry no refresh token, so the refresher must get a whole + new token from the saved credentials. + """ + token_requests = 0 + + def handler(request: Request) -> Response: + nonlocal token_requests + token_requests += 1 + return Response( + json.dumps({"access_token": ACCESS_TOKEN, "expires_in": 1}), + content_type="application/json", + ) + + weaviate_auth_mock.expect_request("/auth").respond_with_handler(handler) + weaviate_auth_mock.expect_request( + "/v1/schema", headers={"Authorization": "Bearer " + ACCESS_TOKEN} + ).respond_with_json({"classes": []}) + + async with weaviate.use_async_with_local( + host=MOCK_IP, + port=MOCK_PORT, + grpc_port=MOCK_PORT_GRPC, + auth_credentials=weaviate.auth.AuthClientCredentials( + client_secret=CLIENT_SECRET, scope=SCOPE + ), + ) as client: + await client.collections.list_all() + first = token_requests + await asyncio.sleep(3) # refresh interval is max(expires_in - 30, 1) -> 1s + assert token_requests > first # a fresh token was fetched with the credentials + + @pytest.mark.parametrize("header_name", ["Authorization", "authorization"]) def test_auth_header_priority( recwarn, weaviate_auth_mock: HTTPServer, start_grpc_server: grpc.Server, header_name: str @@ -183,6 +221,53 @@ async def test_refresh_async( weaviate_auth_mock.check_assertions() +@pytest.mark.asyncio +async def test_async_auth_starts_no_threads( + weaviate_auth_mock: HTTPServer, start_grpc_server: grpc.Server +) -> None: + """The async client must refresh tokens with an asyncio task, not threads. + + Under WASM/Pyodide threads cannot start at all, so the TokenRefresh daemon thread + and the event-loop sidecar thread would make every async OIDC flow crash connect(). + """ + import threading + + weaviate_auth_mock.expect_request( + "/v1/schema", headers={"Authorization": "Bearer " + ACCESS_TOKEN} + ).respond_with_json({"classes": []}) + weaviate_auth_mock.expect_request("/auth").respond_with_json( + { + "access_token": ACCESS_TOKEN, + "expires_in": 500, + "refresh_token": REFRESH_TOKEN, + } + ) + + # compare thread OBJECTS, not names: earlier sync tests leave stale TokenRefresh + # daemon threads alive, which would mask a regression in a name-set comparison + threads_before = set(threading.enumerate()) + tasks_before = asyncio.all_tasks() + async with weaviate.use_async_with_local( + host=MOCK_IP, + port=MOCK_PORT, + grpc_port=MOCK_PORT_GRPC, + auth_credentials=weaviate.auth.AuthBearerToken( + ACCESS_TOKEN, refresh_token=REFRESH_TOKEN, expires_in=500 + ), + ) as client: + await client.collections.list_all() + new_thread_names = {t.name for t in set(threading.enumerate()) - threads_before} + assert "TokenRefresh" not in new_thread_names + assert "eventLoop" not in new_thread_names + refresh_tasks = [ + t for t in asyncio.all_tasks() - tasks_before if "token_refresh" in repr(t.get_coro()) + ] + assert len(refresh_tasks) == 1 # the refresher runs as an asyncio task instead + # ... and close() must cancel it, not leak it (one wait for the cancellation to land) + await asyncio.wait(refresh_tasks, timeout=1) + assert refresh_tasks[0].done() + + def test_refresh_of_refresh(weaviate_auth_mock: HTTPServer, start_grpc_server: grpc.Server) -> None: """Test that refresh tokens are used to get a new refresh token token.""" weaviate_auth_mock.expect_request( diff --git a/test/test_batch_async.py b/test/test_batch_async.py new file mode 100644 index 000000000..4c0d59726 --- /dev/null +++ b/test/test_batch_async.py @@ -0,0 +1,76 @@ +"""Unit tests for the async batch-stream failure handling. + +These pin three behaviors added for WASM/background-failure robustness without needing +a cluster: the grpc-web fail-fast in _start, flush() raising instead of spinning +forever, and _wait() preserving partial results while still raising. +""" + +import asyncio + +import grpc +import pytest + +from weaviate.collections.batch.async_ import _BatchBaseAsync +from weaviate.collections.batch.base import _BatchDataWrapper +from weaviate.exceptions import WeaviateBatchStreamError + + +def _bare_batch(**mangled) -> _BatchBaseAsync: + batch = object.__new__(_BatchBaseAsync) + for name, value in mangled.items(): + setattr(batch, f"_BatchBaseAsync__{name}", value) + return batch + + +def test_start_fails_fast_when_grpc_web_shim_active(monkeypatch) -> None: + # over grpc-web the BatchStream RPC would die inside the background tasks (silent + # drop / endless flush); _start must raise before any task is created + monkeypatch.setattr(grpc, "__weaviate_grpc_web_shim__", True, raising=False) + batch = _bare_batch() # the guard runs before any attribute access + with pytest.raises(WeaviateBatchStreamError, match="insert_many"): + asyncio.run(batch._start()) + + +def test_flush_raises_background_exception_instead_of_hanging() -> None: + # with dead background tasks nothing drains the queues; flush used to spin on + # asyncio.sleep(0.01) forever + batch = _bare_batch( + bg_exception=RuntimeError("boom"), + batch_objects=[object()], + batch_references=[], + ) + + async def flush_with_deadline() -> None: + await asyncio.wait_for(batch.flush(), timeout=2) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run(flush_with_deadline()) + + +def test_wait_copies_partial_results_before_raising() -> None: + # a user catching the background failure must still see what failed + class FakeBgTasks: + async def gather(self, timeout=None) -> None: + return None + + class FakeTimeouts: + insert = 1 + + class FakeConnection: + timeout_config = FakeTimeouts() + + partial = _BatchDataWrapper() + partial.failed_objects = ["sentinel-failure"] # type: ignore[list-item] + backup = _BatchDataWrapper() + + batch = _bare_batch( + bg_exception=RuntimeError("boom"), + bg_tasks=FakeBgTasks(), + connection=FakeConnection(), + results_for_wrapper=partial, + results_for_wrapper_backup=backup, + ) + + with pytest.raises(RuntimeError, match="boom"): + asyncio.run(batch._wait()) + assert backup.failed_objects == ["sentinel-failure"] diff --git a/test/test_wasm_compat.py b/test/test_wasm_compat.py new file mode 100644 index 000000000..dc3355bc9 --- /dev/null +++ b/test/test_wasm_compat.py @@ -0,0 +1,73 @@ +"""Unit tests for WASM/Pyodide-compatibility behavior that runs on CPython too. + +Under Emscripten there are no subprocesses and no threads, and transport errors often +stringify to '' — these tests pin the guards and error-surfacing added for that +environment without needing a browser. +""" + +import sys + +import pytest +from httpx import ConnectError, ReadTimeout + +from weaviate.connect.v4 import _ConnectionBase, _exc_detail +from weaviate.embedded import _EmbeddedBase +from weaviate.exceptions import ( + WeaviateClosedClientError, + WeaviateConnectionError, + WeaviateStartUpError, + WeaviateTimeoutError, +) + + +def test_embedded_raises_explicit_error_under_emscripten(monkeypatch) -> None: + # without the guard, the Emscripten socket emulation makes the port probe + # "succeed" and embedded misreports that Weaviate is already listening + monkeypatch.setattr(sys, "platform", "emscripten") + with pytest.raises(WeaviateStartUpError, match="WebAssembly/Pyodide"): + _EmbeddedBase.check_supported_platform() + + +def test_embedded_platform_check_passes_on_supported_platforms() -> None: + assert sys.platform != "emscripten" + _EmbeddedBase.check_supported_platform() # must not raise on this dev platform + + +def _handle_exceptions(e: Exception, error_msg: str = "") -> None: + conn = object.__new__(_ConnectionBase) + # keep the bare instance's __del__ quiet (it checks these for unclosed connections) + conn._client = None + conn._grpc_channel = None + getattr(conn, "_ConnectionBase__handle_exceptions")(e, error_msg) # noqa: B009 + + +def test_httpx_closed_client_runtime_error_maps_to_closed_client() -> None: + # the exact message httpx raises for a closed AsyncClient/Client + with pytest.raises(WeaviateClosedClientError): + _handle_exceptions(RuntimeError("Cannot send a request, as the client has been closed.")) + + +def test_unrelated_runtime_error_is_not_rewritten_as_closed_client() -> None: + # Emscripten's canonical thread failure must propagate as-is, not as a misleading + # 'client is closed - run client.connect()' + with pytest.raises(RuntimeError, match="can't start new thread"): + _handle_exceptions(RuntimeError("can't start new thread")) + + +def test_connect_error_message_includes_exception_type() -> None: + # str(httpx.ConnectError('')) == '' — the type name must still surface + with pytest.raises(WeaviateConnectionError) as excinfo: + _handle_exceptions(ConnectError("")) + assert "ConnectError" in str(excinfo.value) + + +def test_read_timeout_message_includes_context_and_detail() -> None: + with pytest.raises(WeaviateTimeoutError) as excinfo: + _handle_exceptions(ReadTimeout(""), error_msg="Meta endpoint") + assert "Meta endpoint" in str(excinfo.value) + assert "ReadTimeout" in str(excinfo.value) + + +def test_exc_detail_formats_empty_and_nonempty_strs() -> None: + assert _exc_detail(ValueError("boom")) == "ValueError: boom" + assert _exc_detail(ConnectError("")) == "ConnectError('')" diff --git a/weaviate/client_executor.py b/weaviate/client_executor.py index 3125fd9cd..217f0d7a5 100644 --- a/weaviate/client_executor.py +++ b/weaviate/client_executor.py @@ -16,6 +16,7 @@ from weaviate.collections.classes.internal import _GQLEntryReturnType, _RawGQLReturn from weaviate.integrations import _Integrations +from weaviate.logger import logger from .auth import AuthCredentials from .config import AdditionalConfig @@ -164,7 +165,7 @@ def resp(_: None) -> bool: return True def exc(e: Exception) -> bool: - print(e) + logger.warning(f"gRPC health check failed: {e!r}") return False return executor.execute( @@ -196,7 +197,7 @@ async def await_grpc_result() -> bool: return grpc_result def exc(e: Exception) -> bool: - print(e) + logger.warning(f"is_live check failed: {e!r}") return False return cast( @@ -214,7 +215,7 @@ def resp(res: Response) -> bool: return res.status_code == 200 def exc(e: Exception) -> bool: - print(e) + logger.warning(f"is_ready check failed: {e!r}") return False return executor.execute( diff --git a/weaviate/collections/batch/async_.py b/weaviate/collections/batch/async_.py index 8b997586c..fe2dff546 100644 --- a/weaviate/collections/batch/async_.py +++ b/weaviate/collections/batch/async_.py @@ -37,6 +37,7 @@ ReferenceToMulti, ) from weaviate.collections.classes.types import WeaviateProperties +from weaviate.connect.base import _grpc_web_shim_active from weaviate.connect.executor import aresult from weaviate.connect.v4 import ConnectionAsync from weaviate.exceptions import ( @@ -133,6 +134,15 @@ def __all_tasks_alive(self) -> bool: return self.__bg_tasks is not None and self.__bg_tasks.all_alive() async def _start(self): + if _grpc_web_shim_active(): + # fail fast and loud: over grpc-web the BatchStream RPC raises inside the + # background tasks, where it would otherwise surface as a silent drop or a + # never-ending flush() + raise WeaviateBatchStreamError( + "batch.stream() requires bidirectional gRPC streaming, which is not " + "possible over grpc-web/fetch (WebAssembly/Pyodide). Use " + "collection.data.insert_many() instead." + ) self.__number_of_nodes = await self.__cluster.get_number_of_nodes() async def loop_wrapper() -> None: @@ -192,7 +202,8 @@ async def _wait(self) -> None: "Background batch tasks did not terminate after forced shutdown." ) from e - # copy the results to the public results + # copy the results to the public results — even on failure, so the user can + # still inspect batch.results / batch.failed_objects after catching self.__results_for_wrapper_backup.results = self.__results_for_wrapper.results self.__results_for_wrapper_backup.failed_objects = self.__results_for_wrapper.failed_objects self.__results_for_wrapper_backup.failed_references = ( @@ -202,6 +213,11 @@ async def _wait(self) -> None: self.__results_for_wrapper.imported_shards ) + if self.__bg_exception is not None: + # surface background-task failures instead of returning partial results + # as if the batch had succeeded + raise self.__bg_exception + async def _shutdown(self) -> None: self.__is_stopped.set() @@ -531,6 +547,10 @@ async def flush(self) -> None: """Flush the batch queue and wait for all requests to be finished.""" # bg thread is sending objs+refs automatically, so simply wait for everything to be done while len(self.__batch_objects) > 0 or len(self.__batch_references) > 0: + if self.__bg_exception is not None: + # the background tasks died; nothing will drain the queues, so waiting + # any longer would hang forever + raise self.__bg_exception await asyncio.sleep(0.01) async def _add_object( diff --git a/weaviate/connect/base.py b/weaviate/connect/base.py index eaf72f73e..f64c026c5 100644 --- a/weaviate/connect/base.py +++ b/weaviate/connect/base.py @@ -21,6 +21,17 @@ MAX_GRPC_MESSAGE_LENGTH = 104858000 # 10mb, needs to be synchronized with GRPC server +def _grpc_web_shim_active() -> bool: + """Whether the 'weaviate-python-grpc-web' shim has replaced the grpc module. + + The shim (used under WASM/Pyodide, where there is no grpcio wheel) routes unary RPCs + over grpc-web/fetch and cannot do bidirectional streaming. The marker attribute is + the documented contract between the two packages — keep all sniffs going through + this helper. + """ + return getattr(grpc, "__weaviate_grpc_web_shim__", False) is True + + class ProtocolParams(BaseModel): host: str port: int @@ -173,7 +184,7 @@ def _grpc_channel( "grpc_path_prefix (grpc-web) is only supported for async clients; " "use use_async_with_custom(...) / WeaviateAsyncClient" ) - if not getattr(grpc, "__weaviate_grpc_web_shim__", False): + if not _grpc_web_shim_active(): raise WeaviateInvalidInputError( "grpc_path_prefix enables grpc-web, which requires the " "'weaviate-python-grpc-web' package (it installs a grpc shim before " diff --git a/weaviate/connect/v4.py b/weaviate/connect/v4.py index 56ece8ca2..f674ff74a 100644 --- a/weaviate/connect/v4.py +++ b/weaviate/connect/v4.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import time from copy import copy from dataclasses import dataclass, field @@ -109,6 +110,16 @@ PERMISSION_DENIED = "PERMISSION_DENIED" +def _exc_detail(e: BaseException) -> str: + """Format an exception for user-facing messages. + + ``str()`` of transport errors is frequently empty (e.g. ``httpx.ConnectError``), + which produces messages like 'Details: ' with nothing after them — always include + the exception type. + """ + return f"{type(e).__name__}: {e}" if str(e) else repr(e) + + @dataclass class _ExpectedStatusCodes: ok_in: Union[List[int], int] @@ -153,6 +164,8 @@ def __init__( self._connected = False self._skip_init_checks = skip_init_checks self._grpc_config = grpc_config + self._shutdown_background_event: Optional[Event] = None + self.__token_refresh_task: Optional["asyncio.Task[None]"] = None client_type = "sync" if isinstance(self, ConnectionSync) else "async" embedded_suffix = "-embedded" if self.embedded_db is not None else "" @@ -408,8 +421,8 @@ async def get_oidc() -> None: response = await client.get(oidc_url, timeout=self.timeout_config.init) except Exception as e: raise WeaviateConnectionError( - f"Error: {e}. \nIs Weaviate running and reachable at {self.url}?" - ) + f"Error: {_exc_detail(e)}. \nIs Weaviate running and reachable at {self.url}?" + ) from e res = self.__process_oidc_response(response, auth_client_secret, oidc_url, colour) if isinstance(res, Awaitable): return await res @@ -423,8 +436,8 @@ async def get_oidc() -> None: response = client.get(oidc_url, timeout=self.timeout_config.init) except Exception as e: raise WeaviateConnectionError( - f"Error: {e}. \nIs Weaviate running and reachable at {self.url}?" - ) + f"Error: {_exc_detail(e)}. \nIs Weaviate running and reachable at {self.url}?" + ) from e res = self.__process_oidc_response(response, auth_client_secret, oidc_url, colour) assert not isinstance(res, Awaitable) return res @@ -530,18 +543,37 @@ def _create_background_token_refresh(self, _auth: Optional[_Auth] = None) -> Non if "refresh_token" not in self._client.token and _auth is None: return - # make an event loop sidecar thread for running async token refreshing - event_loop = ( - _EventLoopSingleton.get_instance() - if isinstance(self._client, AsyncOAuth2Client) - else None - ) + # a previous connect() may have left a refresher behind (e.g. a retry after a + # partially failed connect); stop it before replacing the shutdown event, or it + # would keep refreshing concurrently forever + self._cancel_background_token_refresh() expires_in: int = self._client.token.get( "expires_in", 60 ) # use 1minute as token lifetime if not supplied self._shutdown_background_event = Event() + if isinstance(self._client, AsyncOAuth2Client): + try: + loop: Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop is not None: + # The async colour refreshes on its own already-running loop: threads + # cannot start under WASM/Pyodide and are unnecessary here anyway. + self.__token_refresh_task = loop.create_task( + self.__periodic_token_refresh_async(expires_in, _auth) + ) + return + + # sync colour (or async without a running loop): refresh on a daemon thread, + # with an event loop sidecar thread for running async token refreshing + event_loop = ( + _EventLoopSingleton.get_instance() + if isinstance(self._client, AsyncOAuth2Client) + else None + ) + def refresh_token() -> None: if isinstance(self._client, AsyncOAuth2Client): assert event_loop is not None @@ -603,6 +635,49 @@ def periodic_refresh_token(refresh_time: int, _auth: Optional[_Auth]) -> None: ) demon.start() + def _cancel_background_token_refresh(self) -> None: + """Stop the token refresher, whichever form it took. + + Sets the shutdown event (observed by the sync colour's daemon thread at its next + wake-up) and cancels the async colour's refresh task immediately. + """ + if self._shutdown_background_event is not None: + self._shutdown_background_event.set() + if self.__token_refresh_task is not None: + self.__token_refresh_task.cancel() + self.__token_refresh_task = None + + async def __periodic_token_refresh_async( + self, refresh_time: int, _auth: Optional[_Auth] + ) -> None: + """Thread-free equivalent of ``periodic_refresh_token`` for the async colour. + + Cancelled by ``close('async')``. + """ + while ( + self._shutdown_background_event is not None + and not self._shutdown_background_event.is_set() + ): + # use refresh token when available + await asyncio.sleep(max(refresh_time, 1)) + try: + client = self._client + if not isinstance(client, AsyncOAuth2Client): + continue + if "refresh_token" in client.token: + client.token = await client.refresh_token(url=client.metadata["token_endpoint"]) + else: + # client credentials usually does not contain a refresh token => get a + # new token using the saved credentials + assert _auth is not None + new_session = await _Auth.aresult(_auth.get_auth_session()) + client.token = await new_session.fetch_token() + refresh_time = client.token.get("expires_in", 60) - 30 + except HTTPError as exc: + # retry again after one second, might be an unstable connection + refresh_time = 1 + _Warnings.token_refresh_failed(exc) + def __get_latest_headers(self) -> Dict[str, str]: if "authorization" in self._headers: return self._headers @@ -648,14 +723,23 @@ def __get_timeout( ) def __handle_exceptions(self, e: Exception, error_msg: str) -> None: - if isinstance(e, RuntimeError): + # httpx raises a bare RuntimeError('Cannot send a request, as the client has been + # closed.'); match its message so unrelated RuntimeErrors (e.g. Emscripten's + # "can't start new thread") are not rewritten into a misleading 'client is + # closed' error. + if isinstance(e, RuntimeError) and "client has been closed" in str(e): raise WeaviateClosedClientError() from e if isinstance(e, ConnectError): - raise WeaviateConnectionError(error_msg) from e + raise WeaviateConnectionError(self.__error_msg_with_detail(error_msg, e)) from e if isinstance(e, ReadTimeout): - raise WeaviateTimeoutError(error_msg) from e + raise WeaviateTimeoutError(self.__error_msg_with_detail(error_msg, e)) from e raise e + @staticmethod + def __error_msg_with_detail(error_msg: str, e: Exception) -> str: + detail = _exc_detail(e) + return f"{error_msg} ({detail})" if error_msg else detail + def __handle_response( self, response: Response, @@ -710,6 +794,7 @@ def exc(e: Exception) -> None: def close(self, colour: executor.Colour) -> executor.Result[None]: if self.embedded_db is not None: self.embedded_db.stop() + self._cancel_background_token_refresh() if colour == "async": async def execute() -> None: @@ -752,8 +837,11 @@ async def _execute() -> None: async with AsyncClient() as client: res = await client.get(PYPI_PACKAGE_URL, timeout=self.timeout_config.init) return resp(res) - except RequestError: - pass # ignore any errors related to requests, it is a best-effort warning + except (RequestError, OSError): + # ignore any errors related to requests, it is a best-effort warning. + # OSError covers fetch failures under Pyodide/WASM, where a page CSP + # commonly blocks pypi.org — that must not fail connect(). + pass return _execute() @@ -761,7 +849,7 @@ async def _execute() -> None: with Client() as client: res = client.get(PYPI_PACKAGE_URL, timeout=self.timeout_config.init) return resp(res) - except RequestError: + except (RequestError, OSError): pass # ignore any errors related to requests, it is a best-effort warning def delete( @@ -903,47 +991,49 @@ def connect(self, force: bool = False) -> None: self._open_connections_rest(self._auth, "sync") - # need this to get the version of weaviate for version checks and proper GRPC configuration try: - meta = executor.result(self.get_meta(False)) - self._weaviate_version = _ServerVersion.from_string(meta["version"]) - if "grpcMaxMessageSize" in meta: - self._grpc_max_msg_size = int(meta["grpcMaxMessageSize"]) - # Add warning later, when weaviate supported it for a while - # else: - # _Warnings.grpc_max_msg_size_not_found() - except ( - WeaviateConnectionError, - ReadError, - RemoteProtocolError, - SSLZeroReturnError, # required for async 3.8,3.9 due to ssl.SSLZeroReturnError: TLS/SSL connection has been closed (EOF) (_ssl.c:1131) - ) as e: - self._connected = False - raise WeaviateStartUpError(f"Could not connect to Weaviate:{e}.") from e - - self.open_connection_grpc("sync") - if self.embedded_db is not None: + # need this to get the version of weaviate for version checks and proper GRPC configuration try: - self.wait_for_weaviate(10) - except WeaviateStartUpError as e: - self.embedded_db.stop() - self._connected = False - raise e - - # do it after all other init checks so as not to break all the tests - if self._weaviate_version.is_lower_than(1, 27, patch=0): - self._connected = False - raise WeaviateStartUpError( - f"Weaviate version {self._weaviate_version} is not supported. Please use Weaviate version 1.27.0 or higher." - ) + meta = executor.result(self.get_meta(False)) + self._weaviate_version = _ServerVersion.from_string(meta["version"]) + if "grpcMaxMessageSize" in meta: + self._grpc_max_msg_size = int(meta["grpcMaxMessageSize"]) + # Add warning later, when weaviate supported it for a while + # else: + # _Warnings.grpc_max_msg_size_not_found() + except ( + WeaviateConnectionError, + ReadError, + RemoteProtocolError, + SSLZeroReturnError, # required for async 3.8,3.9 due to ssl.SSLZeroReturnError: TLS/SSL connection has been closed (EOF) (_ssl.c:1131) + ) as e: + raise WeaviateStartUpError( + f"Could not connect to Weaviate: {_exc_detail(e)}." + ) from e + + self.open_connection_grpc("sync") + if self.embedded_db is not None: + try: + self.wait_for_weaviate(10) + except WeaviateStartUpError: + self.embedded_db.stop() + raise + + # do it after all other init checks so as not to break all the tests + if self._weaviate_version.is_lower_than(1, 27, patch=0): + raise WeaviateStartUpError( + f"Weaviate version {self._weaviate_version} is not supported. Please use Weaviate version 1.27.0 or higher." + ) - if not self._skip_init_checks: - try: + if not self._skip_init_checks: executor.result(self._ping_grpc("sync")) executor.result(self._check_package_version("sync")) - except Exception as e: - self._connected = False - raise e + except BaseException: + # the OIDC step above may already have started the background token + # refresher; a failed connect must not leave it running + self._connected = False + self._cancel_background_token_refresh() + raise self._connected = True @@ -1107,47 +1197,50 @@ async def connect(self, force: bool = False) -> None: await executor.aresult(self._open_connections_rest(self._auth, "async")) - # need this to get the version of weaviate for version checks and proper GRPC configuration try: - meta = await self.get_meta(False) - self._weaviate_version = _ServerVersion.from_string(meta["version"]) - if "grpcMaxMessageSize" in meta: - self._grpc_max_msg_size = int(meta["grpcMaxMessageSize"]) - # Add warning later, when weaviate supported it for a while - # else: - # _Warnings.grpc_max_msg_size_not_found() - except ( - WeaviateConnectionError, - ReadError, - RemoteProtocolError, - SSLZeroReturnError, # required for async 3.8,3.9 due to ssl.SSLZeroReturnError: TLS/SSL connection has been closed (EOF) (_ssl.c:1131) - ) as e: - self._connected = False - raise WeaviateStartUpError(f"Could not connect to Weaviate:{e}.") from e - - self.open_connection_grpc("async") - if self.embedded_db is not None: + # need this to get the version of weaviate for version checks and proper GRPC configuration try: - await self.wait_for_weaviate(10) - except WeaviateStartUpError as e: - self.embedded_db.stop() - self._connected = False - raise e - - # do it after all other init checks so as not to break all the tests - if self._weaviate_version.is_lower_than(1, 27, 0): - self._connected = False - raise WeaviateStartUpError( - f"Weaviate version {self._weaviate_version} is not supported. Please use Weaviate version 1.27.0 or higher." - ) + meta = await self.get_meta(False) + self._weaviate_version = _ServerVersion.from_string(meta["version"]) + if "grpcMaxMessageSize" in meta: + self._grpc_max_msg_size = int(meta["grpcMaxMessageSize"]) + # Add warning later, when weaviate supported it for a while + # else: + # _Warnings.grpc_max_msg_size_not_found() + except ( + WeaviateConnectionError, + ReadError, + RemoteProtocolError, + SSLZeroReturnError, # required for async 3.8,3.9 due to ssl.SSLZeroReturnError: TLS/SSL connection has been closed (EOF) (_ssl.c:1131) + ) as e: + raise WeaviateStartUpError( + f"Could not connect to Weaviate: {_exc_detail(e)}." + ) from e + + self.open_connection_grpc("async") + if self.embedded_db is not None: + try: + await self.wait_for_weaviate(10) + except WeaviateStartUpError: + self.embedded_db.stop() + raise + + # do it after all other init checks so as not to break all the tests + if self._weaviate_version.is_lower_than(1, 27, 0): + raise WeaviateStartUpError( + f"Weaviate version {self._weaviate_version} is not supported. Please use Weaviate version 1.27.0 or higher." + ) - if not self._skip_init_checks: - try: + if not self._skip_init_checks: await executor.aresult(self._ping_grpc("async")) await executor.aresult(self._check_package_version("async")) - except Exception as e: - self._connected = False - raise e + except BaseException: + # the OIDC step above may already have started the background token + # refresher; a failed connect must not leave it running (it would keep + # hitting the IdP with no way for the user to stop it) + self._connected = False + self._cancel_background_token_refresh() + raise self._connected = True @@ -1159,7 +1252,9 @@ async def wait_for_weaviate(self, startup_period: int) -> None: ).raise_for_status() return except (ConnectError, ReadError, TimeoutError, HTTPStatusError): - time.sleep(1) + # asyncio.sleep, not time.sleep: a blocking sleep inside a coroutine + # stalls the event loop (and deadlocks single-threaded WASM runtimes) + await asyncio.sleep(1) try: ( diff --git a/weaviate/embedded.py b/weaviate/embedded.py index a511665cc..fb5a19a15 100644 --- a/weaviate/embedded.py +++ b/weaviate/embedded.py @@ -5,6 +5,7 @@ import socket import stat import subprocess +import sys import tarfile import time import urllib.request @@ -175,6 +176,14 @@ def wait_till_listening(self) -> None: @staticmethod def check_supported_platform() -> None: + if sys.platform == "emscripten": + # without this guard the port probe below "succeeds" under Emscripten's lazy + # socket emulation and misreports that Weaviate is already listening + raise WeaviateStartUpError( + "Embedded Weaviate is not supported under WebAssembly/Pyodide: it spawns a " + "local Weaviate subprocess, and processes are unavailable in the browser. " + "Connect to a remote Weaviate instance instead." + ) if platform.system() in ["Windows"]: raise WeaviateStartUpError( f"""{platform.system()} is not supported with EmbeddedDB. Please upvote this feature request if you want From 0d685a4cac7d5dbddd0b3d8b08dab2ed23feae38 Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:20:09 +0300 Subject: [PATCH 8/9] fix(grpc-web): harden the fetch transport and error reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map pyfetch failures into httpx's exception taxonomy (ConnectError / ReadTimeout) so the base client classifies them and best-effort callers that swallow RequestError keep working — previously every network/CORS/CSP/abort failure surfaced as a raw OSError - defer to Pyodide's distributed httpx when its native jsfetch transport is present (>= 0.27 dist builds): it streams, splits connect/read timeouts, and raises real httpx errors, so overwriting it made things worse; the pyfetch transport remains the fallback for PyPI httpx - strip content-encoding/content-length from responses: fetch hands back already-decompressed bytes and httpx would gunzip them a second time and raise DecodingError (hidden live only because CORS masks the header cross-origin) - pick the first non-None timeout instead of an or-chain (an explicit read=0 no longer falls through to the 5s connect value), fail fast at install when pyodide is missing, add uninstall_fetch_transport() and a sentinel on the patched method, and restore CRLF header validation that bypassing h11 had lost - channel: name exception types in transport errors (str() of httpx errors can be empty), hint at Access-Control-Expose-Headers when a trailers-only response lost its grpc-status to CORS, and stop recommending batch.dynamic()/fixed_size()/rate_limit(), which do not exist on the async client - README: correct the support table and document CORS requirements, browser-ignored configuration, OIDC, embedded, and agents support - add a dedicated test suite for the fetch transport (in-process fakes + subprocess installs) Co-Authored-By: Claude Fable 5 --- packages/grpc-web/README.md | 51 +- .../src/weaviate_grpc_web/__init__.py | 2 + .../src/weaviate_grpc_web/_channel.py | 28 +- .../src/weaviate_grpc_web/_httpx_fetch.py | 143 ++++- packages/grpc-web/tests/test_httpx_fetch.py | 503 ++++++++++++++++++ packages/grpc-web/tests/test_transport.py | 47 ++ 6 files changed, 748 insertions(+), 26 deletions(-) create mode 100644 packages/grpc-web/tests/test_httpx_fetch.py diff --git a/packages/grpc-web/README.md b/packages/grpc-web/README.md index 5810e9f06..c63e10bd1 100644 --- a/packages/grpc-web/README.md +++ b/packages/grpc-web/README.md @@ -27,6 +27,12 @@ and POSTs them via `pyodide.http.pyfetch` to a server fronted by a grpc-web tran (e.g. Envoy or [connectrpc/vanguard](https://github.com/connectrpc/vanguard-go)). Call metadata (API key / OIDC bearer) is folded into `fetch` headers. +For REST, Pyodide ≥ 0.27 distributes a patched httpx that already routes through the +browser's `fetch` natively — when that build is detected the package leaves it alone. +Only when httpx resolved from PyPI (httpcore + raw sockets, which cannot work under +WASM) does the package patch `httpx.AsyncHTTPTransport` with its own pyfetch-based +transport. + ## Usage ```python @@ -41,13 +47,48 @@ await collection.query.near_text("hello", limit=3) ## Supported / unsupported -| RPC | Kind | Status | +| Feature | Kind | Status | |----------------------------------------------------------|-----------------|--------| -| Search, Aggregate, TenantsGet, BatchObjects, BatchDelete | unary | ✅ works over grpc-web | -| Health check (`/grpc.health.v1.Health/Check`) | unary | ✅ (recommend `skip_init_checks=True` + REST `/.well-known/ready`) | -| References (`/batch/references`) | REST | ✅ via httpx-in-Pyodide | -| `batch.stream()` / `batch.experimental()` (BatchStream) | bidi streaming | ❌ not possible over grpc-web/fetch — use `insert_many()` / `batch.dynamic()` / `fixed_size()` / `rate_limit()` | +| Search, Aggregate, TenantsGet, BatchObjects, BatchDelete | unary gRPC | ✅ works over grpc-web | +| Health check (`/grpc.health.v1.Health/Check`) | unary gRPC | ✅ (recommend `skip_init_checks=True` + REST `/.well-known/ready`) | +| REST (`is_ready`, config, `/batch/references`, …) | REST | ✅ via fetch (Pyodide's httpx build, or this package's fallback transport) | +| API-key auth (`Auth.api_key`) | header | ✅ | +| OIDC auth (`client_credentials` / `client_password` / `bearer_token`) | REST | ✅ token fetch + asyncio-task refresh (no threads) | +| Bulk insert: `collection.data.insert_many()` | unary gRPC | ✅ the supported bulk path under WASM | +| `batch.stream()` / `batch.experimental()` (BatchStream) | bidi streaming | ❌ not possible over grpc-web/fetch — raises immediately; use `insert_many()` | +| `batch.dynamic()` / `fixed_size()` / `rate_limit()` | sync-client API | ❌ these only exist on the sync client, which is unsupported under WASM | +| Embedded Weaviate (`use_async_with_embedded`) | subprocess | ❌ raises "not supported under WebAssembly/Pyodide" | | Synchronous client | — | ❌ async-only under WASM | +| Weaviate Agents: `AsyncQueryAgent` `run/ask/search` | REST | ✅ via fetch | +| Weaviate Agents: `ask_stream` / `research_stream` (SSE) | REST streaming | ⚠️ degraded under the fallback transport: fully buffered, events arrive only when the run completes (and long runs can hit the request timeout) | +| Weaviate Agents: sync `QueryAgent`, `TransformationAgent`, `PersonalizationAgent` | REST sync | ❌ no async flavour exists | + +## Configuration not honored in the browser + +`fetch` manages connections itself, so several knobs are accepted but have no effect +under WASM: + +- `AdditionalConfig.proxies` / `trust_env` proxy environment variables (the browser + cannot proxy fetch requests per-client), +- connection-pool sizing and `session_pool_max_retries`, +- `GrpcConfig.credentials` (custom CA bundles — the browser's trust store decides TLS), +- `GrpcConfig.channel_options`, including `grpc.max_send/receive_message_length` + (only `grpc-web.path_prefix` is consumed), +- `Proxies.grpc` / `GRPC_PROXY`. + +## CORS requirements (browsers) + +Cross-origin browser deployments must configure the grpc-web transcoder / REST endpoint +with CORS, or failures become hard to diagnose: + +- allow the request headers the client sends: `authorization`, `content-type`, + `x-grpc-web`, and any custom headers; +- expose the grpc-web status headers on responses: + `Access-Control-Expose-Headers: grpc-status, grpc-message` — without this, + trailers-only error responses (e.g. a bad API key) are reported as + `INTERNAL: grpc-web response contained no message frame` instead of the real error; +- note that a CORS-blocked request is indistinguishable from a network failure in the + browser (`TypeError: Failed to fetch`), and is retried as UNAVAILABLE. ## Testing on CPython diff --git a/packages/grpc-web/src/weaviate_grpc_web/__init__.py b/packages/grpc-web/src/weaviate_grpc_web/__init__.py index 542d075bd..ba9fa14b3 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/__init__.py +++ b/packages/grpc-web/src/weaviate_grpc_web/__init__.py @@ -27,6 +27,7 @@ "install", "is_installed", "install_fetch_transport", + "uninstall_fetch_transport", "is_fetch_transport_installed", "set_sender", "make_httpx_sender", @@ -58,5 +59,6 @@ def _bootstrap() -> None: from ._httpx_fetch import ( # noqa: E402 install_fetch_transport, is_fetch_transport_installed, + uninstall_fetch_transport, ) from ._sender import make_httpx_sender # noqa: E402 diff --git a/packages/grpc-web/src/weaviate_grpc_web/_channel.py b/packages/grpc-web/src/weaviate_grpc_web/_channel.py index cb16f7bc2..e2dcb4dd7 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_channel.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_channel.py @@ -114,10 +114,13 @@ def __init__(self, path: str) -> None: self._path = path def __call__(self, *args: Any, **kwargs: Any) -> Any: + # NOTE: do not recommend batch.dynamic()/fixed_size()/rate_limit() here — those + # are sync-client-only APIs and do not exist on the async client, which is the + # only client supported under WASM. raise RuntimeError( f"Bidirectional streaming RPC {self._path!r} (server-side batching / " - "BatchStream) is not supported over grpc-web/fetch. Use insert_many(), or " - "batch.dynamic() / fixed_size() / rate_limit(), instead of batch.stream()." + "BatchStream) is not supported over grpc-web/fetch. Use " + "collection.data.insert_many() instead of batch.stream()." ) @@ -202,9 +205,12 @@ async def _unary( details=f"grpc-web request to {path} timed out after {timeout}s", ) from exc except Exception as exc: # network/transport failure -> retryable UNAVAILABLE + # str() of transport errors can be empty (e.g. httpx.ConnectError) — always + # include the exception type so failures stay diagnosable + detail = f"{type(exc).__name__}: {exc}" if str(exc) else repr(exc) raise AioRpcError( code=StatusCode.UNAVAILABLE, - details=f"grpc-web transport error for {path}: {exc}", + details=f"grpc-web transport error for {path}: {detail}", ) from exc try: @@ -247,10 +253,18 @@ def _handle_response( if code is not StatusCode.OK: raise AioRpcError(code=code, details=message) if not messages: - raise AioRpcError( - code=StatusCode.INTERNAL, - details="grpc-web response contained no message frame", - ) + details = "grpc-web response contained no message frame" + if raw_status is None: + # HTTP 200, no body frames, and no grpc-status anywhere: the classic + # signature of a trailers-only error response whose grpc-status / + # grpc-message headers were stripped by CORS in the browser. + details += ( + " and no grpc-status was visible. If this is a cross-origin browser " + "request, configure the grpc-web proxy to send " + "'Access-Control-Expose-Headers: grpc-status, grpc-message' so " + "trailers-only error responses are readable." + ) + raise AioRpcError(code=StatusCode.INTERNAL, details=details) return deserialize(messages[0]) diff --git a/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py b/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py index 8a84e5d11..f09d6bfbe 100644 --- a/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py +++ b/packages/grpc-web/src/weaviate_grpc_web/_httpx_fetch.py @@ -10,14 +10,27 @@ ``fetch`` via ``pyodide.http.pyfetch`` — the same install-globally-under-Emscripten philosophy as the grpc shim in ``_shim.py``. Responses are fully buffered, which matches how the base client consumes them (JSON bodies, no streaming). + +NOTE: Pyodide >= 0.27 distributes a patched httpx whose ``AsyncHTTPTransport`` already +routes through JS ``fetch`` natively (``httpx/_transports/jsfetch.py``) with streaming +support and a proper connect/read timeout split. When that build is detected, installing +is skipped — overwriting it would replace a better implementation. This transport is the +fallback for environments where httpx resolved from PyPI (httpcore + raw sockets). + +Known divergences from native httpx (acceptable for the weaviate client's usage): +- the browser's fetch follows redirects internally, so httpx never sees a 3xx; +- multi-value response headers (e.g. Set-Cookie) are folded into one value; +- responses are fully buffered (no streaming). """ +import importlib.util import sys -from typing import Dict +from typing import Callable, Dict, Optional import httpx _installed = False +_original_handle_async_request: Optional[Callable] = None # Hop-by-hop / connection-managed headers that the browser's fetch controls itself. # Browsers silently drop forbidden headers, but Node's undici (used by the CPython/Node @@ -30,6 +43,19 @@ "transfer-encoding", } +# Response headers describing the wire encoding of the body. fetch decompresses +# responses transparently, so the bytes handed to httpx are already plain; passing the +# original content-encoding through makes httpx run its decoders over them again and +# raise DecodingError, and the original content-length no longer matches the body. +# (Browsers usually hide content-encoding on CORS responses, which is why this never +# fired live — same-origin and Node fetch do expose it.) +_FETCH_DECODED_RESPONSE_HEADERS = { + "content-encoding", + "content-length", +} + +_TIMEOUT_HINTS = ("timeout", "timed out", "abort") + async def _read_request_body(request: httpx.Request) -> bytes: try: @@ -38,34 +64,86 @@ async def _read_request_body(request: httpx.Request) -> bytes: return await request.aread() +def _pick_timeout(request: httpx.Request) -> Optional[float]: + """Pick the effective deadline from httpx's timeout extension. + + httpx populates ``extensions['timeout']`` with connect/read/write/pool values; the + read timeout is what the weaviate client configures per request. ``get(...) or`` + chains would silently skip an explicit 0, so check for None instead. + """ + timeouts = request.extensions.get("timeout") or {} + for key in ("read", "connect", "pool"): + value = timeouts.get(key) + if value is not None: + return value + return None + + +def _map_fetch_error( + e: BaseException, request: httpx.Request, deadline_set: bool +) -> httpx.TransportError: + """Translate a pyfetch failure into httpx's exception taxonomy. + + Pyodide surfaces every JS fetch rejection (network down, DNS, CORS, CSP, an + AbortSignal firing) as OSError — or pyodide.http.AbortError, an OSError subclass — + never as an httpx exception. Without this mapping the base client cannot classify + failures (WeaviateConnectionError/WeaviateTimeoutError) and best-effort callers that + swallow httpx.RequestError break. + """ + msg = str(e) or repr(e) + if deadline_set and any(hint in msg.lower() for hint in _TIMEOUT_HINTS): + return httpx.ReadTimeout(msg, request=request) + return httpx.ConnectError(msg, request=request) + + +def _validate_header(name: str, value: str) -> None: + # httpx.Request accepts CR/LF in header values and relies on h11 to reject them at + # send time; this transport bypasses h11, so mirror that defence here rather than + # delegating it entirely to the JS runtime's fetch. + if any(c in name or c in value for c in ("\r", "\n", "\0")): + raise httpx.LocalProtocolError(f"Illegal character in header {name!r}") + + async def _fetch_handle_async_request( self: httpx.AsyncHTTPTransport, request: httpx.Request ) -> httpx.Response: from pyodide.http import pyfetch # type: ignore[import-not-found] - headers: Dict[str, str] = { - k: v for k, v in request.headers.items() if k.lower() not in _FETCH_MANAGED_HEADERS - } + headers: Dict[str, str] = {} + for k, v in request.headers.items(): + if k.lower() in _FETCH_MANAGED_HEADERS: + continue + _validate_header(k, v) + headers[k] = v kwargs: Dict[str, object] = {} body = await _read_request_body(request) if body: # fetch rejects GET/HEAD requests that carry a body kwargs["body"] = body - timeouts = request.extensions.get("timeout") or {} - timeout = timeouts.get("read") or timeouts.get("connect") or timeouts.get("pool") - if timeout: + timeout = _pick_timeout(request) + deadline_set = False + if timeout is not None and timeout > 0: try: from js import AbortSignal # type: ignore[import-not-found] kwargs["signal"] = AbortSignal.timeout(int(timeout * 1000)) + deadline_set = True except Exception: # pragma: no cover - AbortSignal.timeout availability varies pass - response = await pyfetch(str(request.url), method=request.method, headers=headers, **kwargs) - data = await response.bytes() try: - resp_headers = dict(response.headers) + response = await pyfetch(str(request.url), method=request.method, headers=headers, **kwargs) + data = await response.bytes() + except OSError as e: # incl. pyodide.http.AbortError + raise _map_fetch_error(e, request, deadline_set) from e + + try: + resp_headers = { + k: v + for k, v in dict(response.headers).items() + if k.lower() not in _FETCH_DECODED_RESPONSE_HEADERS + } except Exception: # pragma: no cover - header shape varies across Pyodide versions resp_headers = {} return httpx.Response( @@ -76,20 +154,57 @@ async def _fetch_handle_async_request( ) +# sentinel so other packages (and uninstall) can recognise the patched method +_fetch_handle_async_request.__weaviate_fetch_shim__ = True # type: ignore[attr-defined] + + +def _platform_httpx_has_fetch_support() -> bool: + """True when the running httpx is Pyodide's distributed build. + + That build replaces the httpcore transport with a native JS-fetch one + (httpx/_transports/jsfetch.py), so the weaviate REST path already works without + this shim — and works better (streaming, connect/read timeout split). + """ + try: + return importlib.util.find_spec("httpx._transports.jsfetch") is not None + except (ImportError, ValueError): # pragma: no cover - exotic import states + return False + + def install_fetch_transport(force: bool = False) -> None: """Patch ``httpx.AsyncHTTPTransport`` to send requests through ``fetch``. Installs only under Emscripten unless ``force=True`` (CPython testing, where a - ``pyodide`` stub must be importable). Idempotent. + ``pyodide`` stub must be importable), and is skipped when httpx itself already has + fetch support (Pyodide's distributed build). Idempotent. """ - global _installed + global _installed, _original_handle_async_request if _installed: return - if not force and sys.platform != "emscripten": - return + if not force: + if sys.platform != "emscripten": + return + if _platform_httpx_has_fetch_support(): + return + # Fail fast: the handler imports pyfetch per request, so a missing pyodide module + # would otherwise surface as a confusing ModuleNotFoundError on the first request. + from pyodide.http import pyfetch # type: ignore[import-not-found] # noqa: F401 + + _original_handle_async_request = httpx.AsyncHTTPTransport.handle_async_request httpx.AsyncHTTPTransport.handle_async_request = _fetch_handle_async_request # type: ignore[method-assign] _installed = True +def uninstall_fetch_transport() -> None: + """Restore the original ``httpx.AsyncHTTPTransport`` behaviour. No-op if not installed.""" + global _installed, _original_handle_async_request + if not _installed: + return + assert _original_handle_async_request is not None + httpx.AsyncHTTPTransport.handle_async_request = _original_handle_async_request # type: ignore[method-assign] + _original_handle_async_request = None + _installed = False + + def is_fetch_transport_installed() -> bool: return _installed diff --git a/packages/grpc-web/tests/test_httpx_fetch.py b/packages/grpc-web/tests/test_httpx_fetch.py new file mode 100644 index 000000000..f6c866100 --- /dev/null +++ b/packages/grpc-web/tests/test_httpx_fetch.py @@ -0,0 +1,503 @@ +"""Tests for the fetch-based httpx transport (_httpx_fetch.py). + +In-process tests call ``_fetch_handle_async_request`` directly with a fake +``pyodide.http`` module injected into ``sys.modules`` — no global monkeypatch of +``httpx.AsyncHTTPTransport`` is needed, so the real httpx in the dev environment is left +untouched. Install semantics (which DO patch the class globally) run in fresh +subprocesses, mirroring test_shim_install.py. +""" + +import asyncio +import pathlib +import subprocess +import sys +import textwrap +import types +from typing import Any, Dict, List, Optional + +import httpx +import pytest + +from weaviate_grpc_web._httpx_fetch import _fetch_handle_async_request + +_SRC = str(pathlib.Path(__file__).resolve().parents[1] / "src") + + +class FakeFetchResponse: + def __init__(self, status: int = 200, headers: Optional[Any] = None, body: bytes = b""): + self.status = status + self.headers: Any = headers or {} + self._body = body + + async def bytes(self) -> bytes: # noqa: A003 - mirrors pyodide's FetchResponse API + return self._body + + +class FakePyfetch: + def __init__(self, response: Optional[FakeFetchResponse] = None): + self.response = response or FakeFetchResponse() + self.calls: List[Dict[str, Any]] = [] + + async def __call__(self, url: str, **kwargs: Any) -> FakeFetchResponse: + self.calls.append({"url": url, **kwargs}) + return self.response + + +@pytest.fixture +def fake_pyfetch(monkeypatch) -> FakePyfetch: + fetch = FakePyfetch() + pyodide_mod = types.ModuleType("pyodide") + http_mod = types.ModuleType("pyodide.http") + http_mod.pyfetch = fetch # type: ignore[attr-defined] + pyodide_mod.http = http_mod # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "pyodide", pyodide_mod) + monkeypatch.setitem(sys.modules, "pyodide.http", http_mod) + return fetch + + +def _handle(request: httpx.Request) -> httpx.Response: + # self is unused by the handler implementation; a bare transport instance suffices + transport = httpx.AsyncHTTPTransport.__new__(httpx.AsyncHTTPTransport) + return asyncio.run(_fetch_handle_async_request(transport, request)) + + +def test_basic_get_round_trip(fake_pyfetch): + fake_pyfetch.response = FakeFetchResponse( + status=200, headers={"content-type": "application/json"}, body=b'{"version": "1.30.0"}' + ) + response = _handle(httpx.Request("GET", "http://h:8080/v1/meta")) + + assert response.status_code == 200 + assert response.json() == {"version": "1.30.0"} + assert response.headers["content-type"] == "application/json" + call = fake_pyfetch.calls[0] + assert call["url"] == "http://h:8080/v1/meta" + assert call["method"] == "GET" + + +def test_response_has_request_attached_for_raise_for_status(fake_pyfetch): + fake_pyfetch.response = FakeFetchResponse(status=404, body=b"") + response = _handle(httpx.Request("GET", "http://h:8080/v1/schema/Nope")) + with pytest.raises(httpx.HTTPStatusError): + response.raise_for_status() + + +def test_fetch_managed_request_headers_stripped(fake_pyfetch): + request = httpx.Request( + "POST", + "http://h:8080/v1/objects", + headers={ + "authorization": "Bearer k", + "content-type": "application/json", + "host": "h:8080", + "connection": "keep-alive", + "accept-encoding": "gzip", + "transfer-encoding": "chunked", + }, + content=b"{}", + ) + _handle(request) + sent = fake_pyfetch.calls[0]["headers"] + assert sent["authorization"] == "Bearer k" + assert sent["content-type"] == "application/json" + for managed in ("host", "connection", "accept-encoding", "content-length", "transfer-encoding"): + assert managed not in sent + + +def test_get_without_body_omits_body_kwarg(fake_pyfetch): + # fetch rejects GET/HEAD requests that carry a body, so the kwarg must be absent + _handle(httpx.Request("GET", "http://h:8080/v1/.well-known/ready")) + assert "body" not in fake_pyfetch.calls[0] + + +def test_post_body_passed(fake_pyfetch): + _handle(httpx.Request("POST", "http://h:8080/v1/graphql", content=b'{"query": "x"}')) + assert fake_pyfetch.calls[0]["body"] == b'{"query": "x"}' + + +def test_delete_with_body_passed(fake_pyfetch): + # the REST batch-delete path sends DELETE with a JSON body + _handle(httpx.Request("DELETE", "http://h:8080/v1/batch/objects", content=b'{"match": {}}')) + assert fake_pyfetch.calls[0]["body"] == b'{"match": {}}' + + +def test_query_string_preserved_in_url(fake_pyfetch): + _handle(httpx.Request("GET", "http://h:8080/v1/objects?class=A&limit=10&after=a%20b")) + assert fake_pyfetch.calls[0]["url"] == "http://h:8080/v1/objects?class=A&limit=10&after=a%20b" + + +def test_content_encoding_stripped_from_response(fake_pyfetch): + # fetch hands back ALREADY-decompressed bytes; if the original content-encoding + # header were passed through, httpx.Response would gunzip a second time and raise + # DecodingError. content-length is stale for the same reason. + fake_pyfetch.response = FakeFetchResponse( + status=200, + headers={"content-encoding": "gzip", "content-length": "23", "x-other": "kept"}, + body=b'{"version": "1.30.0"}', + ) + response = _handle(httpx.Request("GET", "http://h:8080/v1/meta")) + assert response.json() == {"version": "1.30.0"} + assert "content-encoding" not in response.headers + assert response.headers["x-other"] == "kept" + + +def test_unreadable_response_headers_tolerated(fake_pyfetch): + class BadHeaders: + def keys(self): + raise TypeError("header shape varies across Pyodide versions") + + fake_pyfetch.response = FakeFetchResponse(status=200, body=b"ok") + fake_pyfetch.response.headers = BadHeaders() + response = _handle(httpx.Request("GET", "http://h:8080/v1/meta")) + assert response.status_code == 200 + assert response.content == b"ok" + + +class _AbortSignalRecorder: + def __init__(self): + self.timeouts: List[int] = [] + + def timeout(self, ms: int): + self.timeouts.append(ms) + return f"signal-{ms}" + + +@pytest.fixture +def fake_abort_signal(monkeypatch) -> _AbortSignalRecorder: + recorder = _AbortSignalRecorder() + js_mod = types.ModuleType("js") + js_mod.AbortSignal = recorder # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "js", js_mod) + return recorder + + +def _request_with_timeout(timeouts: Dict[str, Optional[float]]) -> httpx.Request: + request = httpx.Request("GET", "http://h:8080/v1/meta") + request.extensions["timeout"] = timeouts + return request + + +def test_read_timeout_maps_to_abort_signal_ms(fake_pyfetch, fake_abort_signal): + # mirrors what weaviate's AsyncClient puts in extensions: connect/read/write/pool + _handle(_request_with_timeout({"connect": 2.0, "read": 30.0, "write": 5.0, "pool": 9.0})) + assert fake_abort_signal.timeouts == [30000] + assert fake_pyfetch.calls[0]["signal"] == "signal-30000" + + +def test_timeout_falls_back_to_connect_then_pool(fake_pyfetch, fake_abort_signal): + _handle(_request_with_timeout({"connect": 2.0, "read": None, "write": None, "pool": 9.0})) + _handle(_request_with_timeout({"connect": None, "read": None, "write": None, "pool": 9.0})) + assert fake_abort_signal.timeouts == [2000, 9000] + assert [c["signal"] for c in fake_pyfetch.calls] == ["signal-2000", "signal-9000"] + + +def test_no_timeout_extension_sends_no_signal(fake_pyfetch, fake_abort_signal): + _handle(httpx.Request("GET", "http://h:8080/v1/meta")) + assert fake_abort_signal.timeouts == [] + assert "signal" not in fake_pyfetch.calls[0] + + +def test_missing_js_module_degrades_to_no_signal(fake_pyfetch): + # off-browser (no js module) the AbortSignal import fails; the request must still go out + assert "js" not in sys.modules + response = _handle( + _request_with_timeout({"connect": 2.0, "read": 30.0, "write": None, "pool": None}) + ) + assert response.status_code == 200 + assert "signal" not in fake_pyfetch.calls[0] + + +def test_zero_timeout_means_no_deadline(fake_pyfetch, fake_abort_signal): + # an explicit read=0 must not fall through to the 5s connect timeout, nor become an + # immediate AbortSignal.timeout(0) + _handle(_request_with_timeout({"connect": 5.0, "read": 0, "write": None, "pool": None})) + assert fake_abort_signal.timeouts == [] + assert "signal" not in fake_pyfetch.calls[0] + + +class RaisingPyfetch: + def __init__(self, exc: BaseException): + self.exc = exc + + async def __call__(self, url: str, **kwargs: Any): + raise self.exc + + +def _install_raising_pyfetch(monkeypatch, exc: BaseException) -> None: + pyodide_mod = types.ModuleType("pyodide") + http_mod = types.ModuleType("pyodide.http") + http_mod.pyfetch = RaisingPyfetch(exc) # type: ignore[attr-defined] + pyodide_mod.http = http_mod # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "pyodide", pyodide_mod) + monkeypatch.setitem(sys.modules, "pyodide.http", http_mod) + + +def test_fetch_failure_maps_to_httpx_connect_error(monkeypatch): + # pyodide surfaces JS fetch rejections as OSError; the base client can only classify + # httpx exceptions (WeaviateConnectionError etc.), so the shim must translate + _install_raising_pyfetch(monkeypatch, OSError("TypeError: Failed to fetch")) + with pytest.raises(httpx.ConnectError, match="Failed to fetch") as excinfo: + _handle(httpx.Request("GET", "http://h:8080/v1/meta")) + assert isinstance(excinfo.value.__cause__, OSError) + + +def test_fetch_abort_with_deadline_maps_to_read_timeout(monkeypatch, fake_abort_signal): + # AbortSignal.timeout firing surfaces as an OSError subclass mentioning the abort; + # with a deadline set this must classify as a timeout, not a connection error + _install_raising_pyfetch(monkeypatch, OSError("AbortError: signal timed out")) + with pytest.raises(httpx.ReadTimeout, match="signal timed out"): + _handle(_request_with_timeout({"connect": None, "read": 0.5, "write": None, "pool": None})) + + +def test_fetch_failure_with_deadline_but_no_timeout_message_stays_connect_error( + monkeypatch, fake_abort_signal +): + # nearly every weaviate request sets a read deadline; a plain network failure on + # such a request must remain a connection error, not become a timeout + _install_raising_pyfetch(monkeypatch, OSError("TypeError: Failed to fetch")) + with pytest.raises(httpx.ConnectError, match="Failed to fetch"): + _handle(_request_with_timeout({"connect": None, "read": 30.0, "write": None, "pool": None})) + + +def test_fetch_abort_without_deadline_stays_connect_error(monkeypatch): + # the same message without a deadline set (no js module -> no signal) is not OUR + # timeout, so it must stay a connection error + _install_raising_pyfetch(monkeypatch, OSError("AbortError: signal timed out")) + assert "js" not in sys.modules + with pytest.raises(httpx.ConnectError): + _handle(_request_with_timeout({"connect": None, "read": 0.5, "write": None, "pool": None})) + + +def test_empty_oserror_str_keeps_repr_detail(monkeypatch): + _install_raising_pyfetch(monkeypatch, OSError()) + with pytest.raises(httpx.ConnectError) as excinfo: + _handle(httpx.Request("GET", "http://h:8080/v1/meta")) + assert "OSError" in str(excinfo.value) + + +def test_crlf_in_header_value_rejected(fake_pyfetch): + # httpx.Request accepts CR/LF in header values and relies on h11 to reject them at + # send time; this transport bypasses h11 and must keep that defence + request = httpx.Request( + "GET", "http://h:8080/v1/meta", headers={"x-key": "val\r\nx-injected: evil"} + ) + with pytest.raises(httpx.LocalProtocolError): + _handle(request) + assert fake_pyfetch.calls == [] + + +def test_platform_jsfetch_detection(monkeypatch): + import importlib.machinery + + from weaviate_grpc_web._httpx_fetch import _platform_httpx_has_fetch_support + + # the dev environment runs PyPI httpx (httpcore-based): no jsfetch transport + assert _platform_httpx_has_fetch_support() is False + + fake = types.ModuleType("httpx._transports.jsfetch") + fake.__spec__ = importlib.machinery.ModuleSpec("httpx._transports.jsfetch", loader=None) + monkeypatch.setitem(sys.modules, "httpx._transports.jsfetch", fake) + assert _platform_httpx_has_fetch_support() is True + + +# --------------------------------------------------------------------------- +# Install semantics: these patch httpx.AsyncHTTPTransport globally, so each +# scenario runs in a fresh subprocess (same pattern as test_shim_install.py). +# --------------------------------------------------------------------------- + +_FAKE_PYODIDE_PRELUDE = """ +import sys, types + +class _FakeResponse: + status = 200 + headers = {"content-type": "application/json"} + async def bytes(self): + return b'{"ok": true}' + +CALLS = [] +async def pyfetch(url, **kwargs): + CALLS.append((url, kwargs)) + return _FakeResponse() + +_pyodide = types.ModuleType("pyodide") +_http = types.ModuleType("pyodide.http") +_http.pyfetch = pyfetch +_pyodide.http = _http +sys.modules["pyodide"] = _pyodide +sys.modules["pyodide.http"] = _http +""" + + +def _run(body: str, prelude: str = "") -> subprocess.CompletedProcess: + script = f"import sys\nsys.path.insert(0, {_SRC!r})\n" + prelude + textwrap.dedent(body) + return subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + + +def test_force_install_routes_async_client_through_pyfetch(): + result = _run( + prelude=_FAKE_PYODIDE_PRELUDE, + body=""" + import asyncio, httpx + from weaviate_grpc_web import install_fetch_transport, is_fetch_transport_installed + + install_fetch_transport(force=True) + assert is_fetch_transport_installed() + + async def main(): + async with httpx.AsyncClient() as client: + return await client.get("http://h:8080/v1/meta") + + resp = asyncio.run(main()) + assert resp.status_code == 200, resp.status_code + assert resp.json() == {"ok": True} + assert CALLS and CALLS[0][0] == "http://h:8080/v1/meta" + print("OK") + """, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_install_without_force_is_noop_off_emscripten(): + result = _run( + """ + import sys + assert sys.platform != "emscripten" + import httpx + before = httpx.AsyncHTTPTransport.handle_async_request + from weaviate_grpc_web import install_fetch_transport, is_fetch_transport_installed + install_fetch_transport() + assert not is_fetch_transport_installed() + assert httpx.AsyncHTTPTransport.handle_async_request is before + print("OK") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_force_install_is_idempotent(): + result = _run( + prelude=_FAKE_PYODIDE_PRELUDE, + body=""" + import httpx + from weaviate_grpc_web import install_fetch_transport + install_fetch_transport(force=True) + patched = httpx.AsyncHTTPTransport.handle_async_request + install_fetch_transport(force=True) + assert httpx.AsyncHTTPTransport.handle_async_request is patched + print("OK") + """, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_sync_transport_left_untouched(): + result = _run( + prelude=_FAKE_PYODIDE_PRELUDE, + body=""" + import httpx + sync_before = httpx.HTTPTransport.handle_request + from weaviate_grpc_web import install_fetch_transport + install_fetch_transport(force=True) + assert httpx.HTTPTransport.handle_request is sync_before + print("OK") + """, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_uninstall_restores_original_transport(): + result = _run( + prelude=_FAKE_PYODIDE_PRELUDE, + body=""" + import httpx + before = httpx.AsyncHTTPTransport.handle_async_request + from weaviate_grpc_web import ( + install_fetch_transport, + is_fetch_transport_installed, + uninstall_fetch_transport, + ) + uninstall_fetch_transport() # no-op when not installed + install_fetch_transport(force=True) + assert is_fetch_transport_installed() + assert httpx.AsyncHTTPTransport.handle_async_request is not before + uninstall_fetch_transport() + assert not is_fetch_transport_installed() + assert httpx.AsyncHTTPTransport.handle_async_request is before + print("OK") + """, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_patched_method_carries_sentinel(): + result = _run( + prelude=_FAKE_PYODIDE_PRELUDE, + body=""" + import httpx + from weaviate_grpc_web import install_fetch_transport + assert not getattr( + httpx.AsyncHTTPTransport.handle_async_request, "__weaviate_fetch_shim__", False + ) + install_fetch_transport(force=True) + assert getattr( + httpx.AsyncHTTPTransport.handle_async_request, "__weaviate_fetch_shim__", False + ) is True + print("OK") + """, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_force_install_without_pyodide_fails_fast(): + # without a pyodide module the install must raise immediately, not let every later + # request die with a lazy ModuleNotFoundError + result = _run( + """ + import httpx + before = httpx.AsyncHTTPTransport.handle_async_request + from weaviate_grpc_web import install_fetch_transport, is_fetch_transport_installed + try: + install_fetch_transport(force=True) + except ModuleNotFoundError: + assert not is_fetch_transport_installed() + assert httpx.AsyncHTTPTransport.handle_async_request is before + print("OK") + else: + raise AssertionError("expected install to fail fast without pyodide") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_emscripten_with_platform_jsfetch_skips_install(): + # on Pyodide's distributed httpx (jsfetch transport built in), the shim must NOT + # overwrite the platform implementation + result = _run( + """ + import importlib.machinery, sys, types + + sys.platform = "emscripten" + fake = types.ModuleType("httpx._transports.jsfetch") + fake.__spec__ = importlib.machinery.ModuleSpec( + "httpx._transports.jsfetch", loader=None + ) + sys.modules["httpx._transports.jsfetch"] = fake + + import httpx + before = httpx.AsyncHTTPTransport.handle_async_request + from weaviate_grpc_web import install_fetch_transport, is_fetch_transport_installed + install_fetch_transport() # no force: platform transport must win + assert not is_fetch_transport_installed() + assert httpx.AsyncHTTPTransport.handle_async_request is before + print("OK") + """ + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout diff --git a/packages/grpc-web/tests/test_transport.py b/packages/grpc-web/tests/test_transport.py index f931fa8e1..bcbee6ed7 100644 --- a/packages/grpc-web/tests/test_transport.py +++ b/packages/grpc-web/tests/test_transport.py @@ -160,6 +160,53 @@ async def boom(url, headers, body, timeout): with pytest.raises(AioRpcError) as excinfo: asyncio.run(mc(b"q")) assert excinfo.value.code() is StatusCode.UNAVAILABLE + assert "ConnectionError: connection refused" in str(excinfo.value.details()) + + +def test_transport_exception_with_empty_str_keeps_type(): + # httpx transport errors commonly stringify to '' — the detail must still name them + async def boom(url, headers, body, timeout): + raise ConnectionError() + + channel = GrpcWebChannel("h:1", secure=False, sender=boom) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert "ConnectionError" in str(excinfo.value.details()) + + +def test_empty_ok_response_hints_at_cors_expose_headers(): + # HTTP 200, empty body, no grpc-status anywhere: the shape of a trailers-only error + # whose grpc-status/grpc-message headers were stripped by CORS + channel = _channel(FakeSender(status=200, headers={}, body=b"")) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.INTERNAL + assert "Access-Control-Expose-Headers" in str(excinfo.value.details()) + + +def test_empty_ok_response_with_grpc_status_has_no_cors_hint(): + # when grpc-status WAS visible (status 0, no frames), it is a malformed response, + # not a CORS problem — the hint must not appear + channel = _channel(FakeSender(status=200, headers={"grpc-status": "0"}, body=b"")) + mc = channel.unary_unary("/svc/M", lambda x: x, lambda b: b) + with pytest.raises(AioRpcError) as excinfo: + asyncio.run(mc(b"q")) + assert excinfo.value.code() is StatusCode.INTERNAL + assert "Access-Control-Expose-Headers" not in str(excinfo.value.details()) + + +def test_stream_stream_error_recommends_insert_many_only(): + # batch.dynamic()/fixed_size()/rate_limit() do not exist on the async client (the + # only one supported under WASM), so the error must not recommend them + channel = _channel(FakeSender()) + mc = channel.stream_stream("/weaviate.v1.Weaviate/BatchStream", lambda x: x, lambda b: b) + with pytest.raises(RuntimeError) as excinfo: + mc(request_iterator=iter([]), timeout=5, metadata=None) + assert "insert_many" in str(excinfo.value) + for sync_only in ("dynamic", "fixed_size", "rate_limit"): + assert sync_only not in str(excinfo.value) def test_malformed_frame_maps_to_internal(): From c8f321ac4fb888dfb6e6a88c523516694e7dabed Mon Sep 17 00:00:00 2001 From: Ivan Despot <66276597+g-despot@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:20:27 +0300 Subject: [PATCH 9/9] chore: gitignore all egg-info dirs The literal weaviate_client.egg-info pattern missed the new packages/grpc-web build artifact; generalize it. Co-Authored-By: Claude Fable 5 --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b4ba50e1b..bc3a48c66 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ venv .idea dist/ -weaviate_client.egg-info +*.egg-info/ **/__pycache__ tmp build/