feat(grpc-web): Pyodide/WASM grpc-web transport for the async client - #2056
feat(grpc-web): Pyodide/WASM grpc-web transport for the async client#2056g-despot wants to merge 31 commits into
Conversation
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Orca Security Scan Summary
| Status | Check | Issues by priority | |
|---|---|---|---|
| Infrastructure as Code | View in Orca | ||
| SAST | View in Orca | ||
| Secrets | View in Orca | ||
| Vulnerabilities | View in Orca |
There was a problem hiding this comment.
Pull request overview
This PR enables the async Weaviate Python client’s gRPC data path to run under Pyodide/WebAssembly by routing RPCs over grpc-web (fetch) and providing a companion weaviate-python-grpc-web package that supplies a pure-Python grpc shim plus a grpc-web grpc.aio.Channel implementation. It also makes base-package dependency/version selection behave correctly when grpcio cannot be installed on Emscripten.
Changes:
- Mark
grpcioas not required onsys_platform == "emscripten"and add a narrowgrpciometadata fallback for proto variant selection. - Add
packages/grpc-web/with agrpcshim (sys.modulesinstall) and a unary-only grpc-web channel that maps grpc-web responses toAioRpcError. - Add unit/integration-style tests for framing, transport behavior, and shim-based
import weaviatein a subprocess.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| weaviate/proto/v1/init.py | Adds grpcio metadata fallback to select a compatible generated-proto variant when grpcio dist metadata is absent (Pyodide). |
| setup.cfg | Excludes grpcio dependency under Emscripten via environment marker. |
| proto_test/test_proto.py | Adds regression tests for the grpcio metadata fallback behavior. |
| packages/grpc-web/src/weaviate_grpc_web/init.py | Bootstraps shim install under Emscripten and forces pure-Python protobuf runtime. |
| packages/grpc-web/src/weaviate_grpc_web/_shim.py | Implements minimal pure-Python grpc API surface + grpc.aio.Channel base class for client imports/runtime. |
| packages/grpc-web/src/weaviate_grpc_web/_channel.py | Implements unary grpc-web channel/multicallables and response/status mapping. |
| packages/grpc-web/src/weaviate_grpc_web/_framing.py | Implements grpc-web frame encoding/splitting and trailer parsing. |
| packages/grpc-web/src/weaviate_grpc_web/_sender.py | Provides default pyfetch sender and an httpx sender for CPython tests/integration. |
| packages/grpc-web/src/weaviate_grpc_web/py.typed | Marks the package as typed. |
| packages/grpc-web/tests/conftest.py | Makes the grpc-web package importable in tests without editable install. |
| packages/grpc-web/tests/test_framing.py | Tests framing encode/split/trailer parsing and malformed/compressed rejection. |
| packages/grpc-web/tests/test_transport.py | Tests unary transport behavior, metadata folding, timeout header, and error mapping. |
| packages/grpc-web/tests/test_shim_install.py | Subprocess tests for shim install, import weaviate, and real-proto unary round-trip. |
| packages/grpc-web/README.md | Documents grpc-web transport usage and limitations. |
| packages/grpc-web/pyproject.toml | Defines the companion distribution metadata and packaging config. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…h 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
<scheme>://<host>:<port><prefix>/weaviate.v1.Weaviate/<Method>.
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) <noreply@anthropic.com>
Follow-up commit: grpc-web on the REST host:port via a base-path prefix (
|
…ce 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2056 +/- ##
==========================================
+ Coverage 86.64% 88.64% +2.00%
==========================================
Files 300 307 +7
Lines 23172 23926 +754
==========================================
+ Hits 20077 21209 +1132
+ Misses 3095 2717 -378 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…eout/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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
The literal weaviate_client.egg-info pattern missed the new packages/grpc-web build artifact; generalize it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…46b6-acdd-9b083ac3a6eb # Conflicts: # weaviate/connect/v4.py
…uccess Per the grpc-web contract every unary response must carry a grpc-status (trailer frame or header). A proxy that dropped the trailer frame previously read as OK and returned the first message frame.
Fails on drift in either direction: the Emscripten fallback must pass every vendored *_pb2_grpc.py version gate, and the grpc-web shim's FAKE_GRPC_VERSION must equal weaviate.proto.v1._GRPCIO_FALLBACK_VERSION.
…async-only error With PyPI httpx the sync REST path previously failed first with an opaque WeaviateStartUpError ConnectError; the shim's clear async-only guidance was only reachable at open_connection_grpc. Raise it at ConnectionSync construction instead.
run.mjs loads a pinned Pyodide (314.0.4, CPython 3.14) under Node, micropip-installs the two locally built pure wheels (grpcio skipped via its emscripten marker; pydantic_core/cryptography come from the Pyodide distribution — pydantic_core has no wasm wheel on PyPI, which also rules out the 0.28.x line whose bundled pydantic 2.10.6 predates our >=2.12 pin), then awaits e2e.py against Weaviate's core-native /v1/grpc-web endpoint via grpc_path_prefix: connect with live init checks, insert_many, queries/filters/aggregations, multi-tenancy with per-tenant batch insert/delete, error mapping, and the batch.stream()/ experimental() fail-fast. anyio is installed explicitly: Pyodide's httpx recipe drops it but authlib imports it directly.
New pyodide-e2e job beside grpc-web-tests: build both pure wheels, start the async-tests Weaviate (WEAVIATE_139) from the existing ci/docker-compose-async.yml, and run ci/pyodide-e2e/run.mjs under Node 22. No Python matrix — the pinned Pyodide bundle fixes the interpreter. No Envoy/browser: core serves grpc-web natively on the REST port under /v1/grpc-web (default-on since 1.38.3).
…dide-e2e fix(grpc-web): review fixes + in-Pyodide e2e CI for the WASM transport
A platform-guarded soft import at the top of weaviate/__init__.py makes plain 'import weaviate' work under Pyodide (clear ImportError when the companion is missing); the companion wheel now carries 'anyio ; sys_platform == "emscripten"' so micropip resolves it without a manual install.
Three coordinated renames before first publish: distribution weaviate-python-grpc-web -> weaviate-client-web (mirrors the TS @weaviate/web), import module weaviate_grpc_web -> weaviate_client_web, and directory packages/grpc-web -> packages/web. Nothing was ever published under the old name, so no compat aliases are needed; grpc-web stays as the transport-mechanism term.
_handle_response split the body before checking the HTTP status, so any non-grpc-web error response — Weaviate's 404 JSON, an nginx HTML 502, an SPA index.html — failed frame parsing and surfaced as INTERNAL "malformed grpc-web response". That left the whole _status_from_http table dead in production: 502/503/504 never reached UNAVAILABLE, the only code _Retry retries, so transient upstream failures stopped being retried. Parse defensively and let the status decide. A non-200 with no grpc-status maps through _status_from_http and carries the status, the request URL and a body excerpt; a 404 names both candidate causes. A valid grpc-status still wins when a proxy sends one alongside a non-200. Trailer values decode leniently, so a non-ASCII grpc-message no longer destroys the grpc-status travelling with it. The replaced test asserted the mapping with an empty error body, a shape no real server or proxy emits.
The async token refresher caught only HTTPError, so an OAuthError from the IdP killed the task with no output at all — not before close, not at close, not at exit — and the token silently expired. Catch broadly, re-raise cancellation, and warn if the task dies anyway. Batch flush() checked only __bg_exception, so a BaseException in a background task (grpc.aio raises CancelledError on a cancelled stream) left the queues undrained and flush() spinning forever. Use the liveness check the sync colour already had. __put() could recurse on the same condition. connect() discarded the gRPC code and details and printed firewall and separate-port advice that cannot apply over grpc-web, where gRPC rides the REST port. Report the real status, and on a 404 name both candidates: a server older than 1.38.3, or a wrong grpc_path_prefix. httpx.ConnectTimeout subclasses neither ConnectError nor ReadTimeout, so it escaped the exception taxonomy raw. Map TimeoutException. Under Emscripten the connect timeout bounds the entire fetch, not just connection setup, capping every REST call at 5s no matter what the caller configured. Let it follow the request timeout there; other platforms are unchanged.
build-and-publish did not depend on grpc-web-tests or pyodide-e2e, so a release tag could publish with both red.
…ripten Pyodide's bundled httpx transport dereferences Response.body unconditionally, which is null for HEAD and 204 responses, so data.exists/update/delete_by_id and tenants.exists crashed with a raw AttributeError; it also does not enforce sub-5s REST timeouts end to end. Install the package transport whenever the platform is Emscripten instead of deferring, build the response as a stream so httpx stamps .elapsed (reference_add_many read it), take the request deadline from the read timeout only, and round the AbortSignal up with an int32 cap. The import hook now raises the install hint only when the companion itself is missing and chains any other ImportError, so a broken companion surfaces its own error. The Pyodide e2e gains a transport self-check plus HEAD/204 and reference steps so CI pins the fix.
Non-finite deadlines send no grpc-timeout header and no client-side wait; finite ones escalate m -> S -> M to stay within the spec's 8 digits and never use H, which Weaviate's transcoder rejects above 8H (values past ~190 years mean no deadline). Trailer blocks accept LF-only lines and non-ASCII keys, an unknown frame flag or a message after the trailer is a framing error, and a unary response carrying more than one message frame is INTERNAL rather than silently truncated. Metadata with CR/LF/NUL is rejected before any I/O. Diagnostics: an HTTP 405 and a missing grpc_path_prefix under Emscripten both name the fix, and a truncated grpc-web body is reported as truncated instead of as a wrong path. Stale cross-references in comments trimmed.
use_async_with_custom(..., grpc_path_prefix="/v1/grpc-web") on the REST port is the primary example; use_async_with_local/weaviate_cloud take no prefix and only work with a transcoder at the gRPC host:port root. Drop the skip_init_checks recommendation (the health check runs over grpc-web), list every header the client sends in the CORS section, state the tested Pyodide version, and correct the message-size and REST-transport wording.
…anitise timeouts A permanent OAuth failure (expired or revoked refresh token) made the async refresher POST to the IdP every second; the sync thread died silently on the same error. Both colours now catch any exception and retry with a capped exponential backoff (1s doubling to 60s, reset on success); Con001 says so. The async close() awaits the cancelled task, and the sync thread waits on the Event it was started with, so close() ends it promptly and a reconnect leaves no second refresher. A test drives a real refresher death through connect() to pin the done-callback wiring. Non-finite timeouts mean "no deadline" for every REST and gRPC hand-off, the Emscripten connect-timeout arm is gone (the package transport reads only the read timeout), the grpc-web prefix guards run at construction with a message that explains the CPython testing mode, and a RuntimeError is rewritten to WeaviateClosedClientError only when the http client is actually closed. Native connection errors keep the observed gRPC status line; the duplicated no-prefix hint lives in the transport only.
…rity Leaving `with`/`async with client.batch.stream()` no longer replaces the exception raised in the block (CancelledError included) with the background failure — the caller's wins and the background one is logged, unless it is the same exception already propagating. Both colours raise WeaviateBatchStreamError when the workers failed or the stream ended with data still queued, with a message that says how much was unsent; the sync colour previously swallowed this. _BatchStreamShutdownError joins the taxonomy, the shutdown wait tolerates an infinite insert timeout, and WeaviateBatchError carries the gRPC details only.
pyright now includes packages/web/src and the lint job's flake8 run covers the package alongside ruff, so the companion is held to the same bar as the client.
fix(grpc-web): round-2 review fixes — REST under Pyodide, OIDC refresh backoff, batch exit semantics
Under Emscripten there is no grpcio wheel and no socket, so native gRPC cannot work at all and grpc-web on the REST listener is the only transport that can. The connect helpers now pin gRPC to the HTTP endpoint under Weaviate's own grpc-web base path, and `grpc_path_prefix` leaves the public API entirely. This matches the TypeScript web client, which hardcodes the same path in one place and removes grpcHost/grpcPort/grpcSecure from its connect options rather than exposing a knob. Weaviate serves grpc-web at a fixed path, so there was nothing for a caller to choose. use_async_with_local() and use_async_with_weaviate_cloud() now work in a browser as written; previously neither could reach grpc-web at all, and only the seven-argument use_async_with_custom() form could. Off Emscripten every helper is byte-identical to before, down to pydantic's echo of the constructor arguments in a port-collision validation error. Discarding a gRPC endpoint the caller actually chose warns (Con006) and names both endpoints; the helpers' own defaults are replaced silently, so the documented calls stay quiet.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 41 out of 43 changed files in this pull request and generated 3 comments.
Suppressed comments (5)
weaviate/connect/v4.py:696
- The sync refresher also waits the full token lifetime before its first attempt, while later attempts use
expires_in - 30. For normal long-lived tokens this creates an authentication gap at every initial connection; start this loop with the same 30-second lead time.
packages/web/src/weaviate_client_web/_httpx_fetch.py:90 - An explicit zero timeout means an immediate timeout in native httpx and in this package's gRPC path (
asyncio.wait_for(..., 0)), but this REST transport turns it into no deadline. ThusTimeout(query=0)can wait indefinitely only for REST calls under Pyodide. Preserve zero as an immediateAbortSignaland reserveNone/non-finite values for no deadline.
Zero, negative and non-finite timeouts all mean "no deadline". Rounded up so a
sub-millisecond timeout never becomes an immediate abort.
"""
if timeout is None or not math.isfinite(timeout) or timeout <= 0:
return None
return min(math.ceil(timeout * 1000), _MAX_ABORT_SIGNAL_MS)
packages/web/src/weaviate_client_web/_channel.py:435
- The standard HTTP-to-gRPC fallback mapping maps HTTP 504 (Gateway Timeout) to
DEADLINE_EXCEEDED, notUNAVAILABLE. ReturningUNAVAILABLEmakes the client's retry layer repeat an operation that has already exceeded the gateway deadline and exposes the wrong status to callers.
429: StatusCode.UNAVAILABLE,
502: StatusCode.UNAVAILABLE,
503: StatusCode.UNAVAILABLE,
504: StatusCode.UNAVAILABLE,
weaviate/connect/helpers.py:711
- The PR description and quickstart contradict this public API: they pass
grpc_path_prefixtouse_async_with_custom()and state that the local/cloud helpers do not add a prefix, but this implementation exposes no such argument and automatically routes all three async helpers to/v1/grpc-webunder Emscripten. As written, the advertised quickstart raisesTypeError; update the PR description to match the implemented auto-routing contract.
weaviate/connect/helpers.py:57 - When only
grpc_securediffers, this warning is triggered but prints identicalhost:portstrings, so users cannot see what was overridden. Include the TLS state in both endpoint values so the new warning accurately identifies the discarded configuration.
| task = loop.create_task( | ||
| self.__periodic_token_refresh_async(expires_in, _auth, shutdown) | ||
| ) |
| if self._shutdown_background_event is not None: | ||
| self._shutdown_background_event.set() | ||
| task, self.__token_refresh_task = self.__token_refresh_task, None |
| for flag, payload in iter_frames(body): | ||
| if flag & _FLAG_TRAILER: | ||
| trailers.update(parse_trailers(payload)) | ||
| seen_trailer = True | ||
| elif flag & _FLAG_COMPRESSED: | ||
| raise FrameError( | ||
| "compressed grpc-web message frames are not supported by this transport" | ||
| ) | ||
| elif seen_trailer: | ||
| raise FrameError("message frame after the trailer frame") | ||
| else: | ||
| messages.append(payload) |
What & why
Lets the async client run inside Pyodide/WebAssembly (marimo notebooks, browser, WASM workers), where
grpciohas no wheel and sockets don't exist. gRPC is re-routed over grpc-web (fetch), REST over the browser'sfetch— against Weaviate core's native/v1/grpc-webendpoint (default-on since 1.38.3) or any grpc-web transcoder (Envoy, vanguard).Quickstart (Pyodide / marimo)
Only
use_async_with_custom(..., grpc_path_prefix="/v1/grpc-web")reaches Weaviate's native grpc-web endpoint (server ≥ 1.38.3, on the REST port).use_async_with_local/use_async_with_weaviate_cloudhave nogrpc_path_prefixand therefore only work with a grpc-web transcoder (Envoy/vanguard) at the root ofgrpc_host:grpc_port.Key pieces
packages/web/— new companion distributionweaviate-client-web(the Python counterpart of the TS web client@weaviate/web), reuses the client's generated protobuf stubs (no codegen fork):src/weaviate_client_web/__init__.py— bootstrap: installs the shim (Emscripten-only) and forces the pure-Python protobuf runtime; a bareimport weaviatetriggers it automatically (see the hook below), importing the package explicitly first also workspyproject.toml— carriesanyio ; sys_platform == "emscripten"(Pyodide's httpx build omits anyio, but authlib imports it directly)_shim.py— pure-Pythongrpcmodule shim intosys.modules; providesgrpc.aio.Channelas a real base class and satisfies the generated stubs' version gates; never installs on a normal machine_channel.py—GrpcWebChannel: unary multicallables, per-call metadata (API key / OIDC bearer) → headers, grpc-web status/trailers → the client's normalAioRpcErrorcodes (UNAVAILABLE retryable, DEADLINE_EXCEEDED on timeout, INTERNAL on missinggrpc-statusor protocol violations).grpc-timeoutis emitted in the largest unit that fits the spec's 8 digits (m → S → M, never H — Weaviate's transcoder rejects >8H); non-finite deadlines send no header. Diagnostics name the fix for an HTTP 404/405 (server < 1.38.3 or wronggrpc_path_prefix) and for a missing prefix under WASM; a truncated body is reported as truncated, not as a wrong path_framing.py— grpc-web wire format: frame encode/split, trailer parsing (CRLF or LF, lenient keys), rejection of compressed/unknown-flag frames, a message after the trailer, or a second message frame in a unary reply_sender.py—pyodide.http.pyfetchsender (production) + an httpx sender (CPython integration testing)_httpx_fetch.py— routes the client's httpx REST calls over JSfetchunder Emscripten and is always the active transport there (Pyodide's own httpx build is not used: it dereferences the response body unconditionally, which isnullfor HEAD/204 responses, and it does not enforce sub-5 s REST timeouts end to end). Timeouts map to anAbortSignal(rounded up, capped at 2³¹−1 ms); non-finite timeouts mean no deadlineREADME.md— usage (use_async_with_custom(..., grpc_path_prefix="/v1/grpc-web")on the REST port;use_async_with_local/use_async_with_weaviate_cloudhave no prefix and only work with a transcoder at the gRPC host:port root), the full CORS allow-list the client needs, what's not honoredBase client (no-ops on normal platforms except where noted):
weaviate/__init__.py— a platform-guarded hook (dead code off-WASM) soft-imports the companion before anything else, so plainimport weaviateworks under Pyodide; a missing companion raises a clear install hint instead ofModuleNotFoundError: grpc, a broken one surfaces its own errorsetup.cfg—grpcio ; sys_platform != "emscripten"marker so micropip skips itweaviate/connect/base.py/helpers.py—grpc_path_prefixonConnectionParams+use_async_with_custom(grpc-web on the REST host:port); fail-fast guards for sync-client / missing-shim misconfiguration run at client constructionweaviate/connect/v4.py— sync client rejected at construction under WASM; the async OIDC refresher is an asyncio task instead of a daemon thread, cancelled and awaited byclose()(fixes a leak where the thread survivedclose(); covered bymock_tests/test_auth.py); non-finite timeouts mean "no deadline" for every REST/gRPC hand-offweaviate/collections/batch/async_.py—batch.stream()/experimental()fail fast under grpc-web with a clear error pointing toinsert_many()(bidi streaming is impossible over fetch)weaviate/proto/v1/__init__.py— grpcio version fallback when dist metadata is absent (Emscripten-only), drift-pinned byproto_testCI / tests:
ci/pyodide-e2e/—run.mjs(Node runner: loads pinned Pyodide 314.0.4, micropip-installs the branch-built wheels),e2e.py(in-Pyodide e2e against core-native/v1/grpc-web: connect with init checks, insert_many, queries/filters/aggregations, tenants, HEAD/204 REST calls —data.exists/update/delete_by_id,tenants.exists—reference_add_many/reference_delete, error mapping,batch.stream()fail-fast, and a self-check that the package's fetch transport is the active one).github/workflows/main.yaml—grpc-web-tests(package suite, 3.10–3.14) +pyodide-e2e(real WASM interpreter, no browser needed); ruff, flake8 and pyright coverpackages/web;build-and-publishneeds both new jobstest/test_wasm_compat.py,proto_test/test_proto.py— Emscripten guards + fallback-version drift pinsChanges that affect every platform
Not everything here is WASM-gated. These change behavior for existing users:
connect/v4.py— the async OIDC refresher is an asyncio task rather than a daemon thread;close()cancels and awaits it (fixes a leak where the thread outlivedclose(); a client dropped withoutclose()now surfaces the Con004 resource warning instead of a leaked thread).connect/v4.py— token refresh failures in both colours (sync thread and async task) are caught broadly (not justHTTPError) and retried with a capped exponential backoff (1 s → 2 → 4 → … → 60 s, reset on success). Previously the sync thread died silently on a non-HTTP error. Con001's text now states the interval and failure count; a refresher that dies outside that loop is surfaced as Con003.connect/v4.py— the sync refresher thread exits promptly onclose()(event wait instead of sleep), and aclose()→connect()cycle no longer leaves the previous refresher thread alive.connect/v4.py—connect()reports the underlying gRPC code and details instead of discarding them; both the sync and async native-gRPC connection errors gain one appendedThe gRPC call failed with: <CODE> - <details>line (type unchanged).connect/v4.py—httpx.TimeoutExceptionsubclasses (ConnectTimeout,WriteTimeout,PoolTimeout) raiseWeaviateTimeoutErrorinstead of escaping the exception taxonomy raw; connection-time REST error messages now include the exception type name.connect/v4.py— non-finite timeouts (Timeout(query=float("inf")),nan) mean "no deadline" everywhere (REST, gRPC, batch shutdown wait) instead of overflowing in a transport layer.connect/v4.py— aRuntimeErroris rewritten toWeaviateClosedClientErroronly when the underlying httpx client is actually closed (previously: message-text match; otherRuntimeErrors now propagate as-is).connect/base.py,connect/helpers.py— thegrpc_path_prefixmisconfiguration guards fire at client construction (both colours), not insideconnect()after OIDC//v1/metasucceeded.collections/batch/async_.py,sync.py,batch_wrapper.py—flush()and__put()raise when background tasks die instead of spinning; leavingwith/async with client.batch.stream()raises aWeaviateBatchStreamErrorif the background worker failed or data was left unsent — in both colours (the sync colour previously swallowed this). If your own block raised, your exception wins and the background failure is logged._BatchStreamShutdownErroris now aWeaviateBatchStreamErrorsubclass;WeaviateBatchErrormessages carry the gRPC details only.client_executor.py—is_ready/is_live/gRPC-ping diagnostics moved fromprint()(stdout) tologger.warning()on theweaviate-clientlogger (stderr).Testing
Package suite (141 tests) + base suites (554 unit/mock/proto) in CI, ruff/flake8/pyright clean including
packages/web; real-Pyodide e2e (22 steps: connect w/ init checks, batch insert/delete, queries, aggregations, tenants, HEAD/204 REST calls, references, error mapping, stream fail-fast, transport self-check); the full client workflow also verified on CPython through the grpc-web path (httpx sender).A second review round (#2137, merged here) fixed what the first e2e did not reach: under Pyodide every HEAD/204 REST call crashed in Pyodide's own httpx transport (fixed by always using the package transport),
reference_add_manyread an unset.elapsed,Timeout(...=inf)overflowed in the fetch layer, and the async OIDC refresher retried a permanentinvalid_grantonce a second. Verified hands-on in Pyodide 314.0.4 against 1.39.0: the HEAD/204 calls,Timeout(query|insert|init=inf),Timeout(query=0.001)on REST →WeaviateTimeoutError, a 30 MBinsert_manyno longer cut at ~5 s, API-key auth incl. a trailers-only error, 30-way concurrency, 8 MB blobs, 110 MB → cleanRESOURCE_EXHAUSTED, cancellation, unicode round-trips; on CPython the OIDC backoff was observed as 4 refresh POSTs in 12 s.Additionally verified against a live 1.39.0 server: the query surface over grpc-web (
near_vectorwith distance/certainty,hybridin both fusion modes withexplain_score,near_object, two-level nested references,group_by, cursor paging, the complex-type matrix, generative); a 502 maps toUNAVAILABLEand is retried again (1 attempt -> 6); a 404 names both candidate causes (server older than 1.38.3, or a wronggrpc_path_prefix); and the ~5s REST cap under Pyodide is lifted (96MB insert: failed at 5.7s, now OK in 9.1s). Message ceiling over grpc-web is the server's owngrpcMaxMessageSize, surfaced asRESOURCE_EXHAUSTED.Verified in a real browser
A cross-origin run in Chrome (page on
:8399, Weaviate on:8290) passes end to end. Weaviate 1.39.0 serves grpc-web CORS correctly with no configuration, includingAccess-Control-Expose-Headers: Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin, so trailers-only errors are readable from browser JS.CORS_ALLOW_ORIGINonly narrows*. Two caveats: the server's allow-headers list is a closed allowlist, so a custom header passed viaheaders={...}breaks the browser preflight; and/v1/.well-known/readysends no CORS headers at all (not on the client's browser path).Deferred
Publish/lockstep versioning for
weaviate-client-web— once it ships to PyPI,weaviate-clientgainsweaviate-client-web ; sys_platform == "emscripten"(after verifying micropip handles the dependency cycle), collapsing the WASM install tomicropip.install("weaviate-client").🤖 Generated with Claude Code