Skip to content

feat(grpc-web): Pyodide/WASM grpc-web transport for the async client - #2056

Open
g-despot wants to merge 31 commits into
mainfrom
feat/grpc-web-wasm-transport
Open

feat(grpc-web): Pyodide/WASM grpc-web transport for the async client#2056
g-despot wants to merge 31 commits into
mainfrom
feat/grpc-web-wasm-transport

Conversation

@g-despot

@g-despot g-despot commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

What & why

Lets the async client run inside Pyodide/WebAssembly (marimo notebooks, browser, WASM workers), where grpcio has no wheel and sockets don't exist. gRPC is re-routed over grpc-web (fetch), REST over the browser's fetch — against Weaviate core's native /v1/grpc-web endpoint (default-on since 1.38.3) or any grpc-web transcoder (Envoy, vanguard).

Quickstart (Pyodide / marimo)

# install once (until release: use the wheels built from this branch)
import micropip
await micropip.install(["weaviate-client", "weaviate-client-web"])
import weaviate  # bootstraps the grpc-web transport automatically under WASM
from weaviate.classes.config import Configure, DataType, Property

client = weaviate.use_async_with_custom(
    http_host="weaviate.example.com", http_port=443, http_secure=True,
    grpc_host="weaviate.example.com", grpc_port=443, grpc_secure=True,
    grpc_path_prefix="/v1/grpc-web",  # core-native grpc-web endpoint (server ≥ 1.38.3)
)
await client.connect()

await client.collections.create(
    "Article",
    vector_config=Configure.Vectors.self_provided(),
    properties=[Property(name="title", data_type=DataType.TEXT)],
)
articles = client.collections.get("Article")

await articles.data.insert_many([{"title": f"Article {i}"} for i in range(10)])

res = await articles.query.bm25("article", limit=3)
for o in res.objects:
    print(o.properties["title"])

await client.close()

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_cloud have no grpc_path_prefix and therefore only work with a grpc-web transcoder (Envoy/vanguard) at the root of grpc_host:grpc_port.

Key pieces

packages/web/ — new companion distribution weaviate-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 bare import weaviate triggers it automatically (see the hook below), importing the package explicitly first also works
  • pyproject.toml — carries anyio ; sys_platform == "emscripten" (Pyodide's httpx build omits anyio, but authlib imports it directly)
  • _shim.py — pure-Python grpc module shim into sys.modules; provides grpc.aio.Channel as a real base class and satisfies the generated stubs' version gates; never installs on a normal machine
  • _channel.pyGrpcWebChannel: unary multicallables, per-call metadata (API key / OIDC bearer) → headers, grpc-web status/trailers → the client's normal AioRpcError codes (UNAVAILABLE retryable, DEADLINE_EXCEEDED on timeout, INTERNAL on missing grpc-status or protocol violations). grpc-timeout is 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 wrong grpc_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.pypyodide.http.pyfetch sender (production) + an httpx sender (CPython integration testing)
  • _httpx_fetch.py — routes the client's httpx REST calls over JS fetch under Emscripten and is always the active transport there (Pyodide's own httpx build is not used: it dereferences the response body unconditionally, which is null for HEAD/204 responses, and it does not enforce sub-5 s REST timeouts end to end). Timeouts map to an AbortSignal (rounded up, capped at 2³¹−1 ms); non-finite timeouts mean no deadline
  • README.md — usage (use_async_with_custom(..., grpc_path_prefix="/v1/grpc-web") on the REST port; use_async_with_local/use_async_with_weaviate_cloud have 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 honored

Base 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 plain import weaviate works under Pyodide; a missing companion raises a clear install hint instead of ModuleNotFoundError: grpc, a broken one surfaces its own error
  • setup.cfggrpcio ; sys_platform != "emscripten" marker so micropip skips it
  • weaviate/connect/base.py / helpers.pygrpc_path_prefix on ConnectionParams + use_async_with_custom (grpc-web on the REST host:port); fail-fast guards for sync-client / missing-shim misconfiguration run at client construction
  • weaviate/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 by close() (fixes a leak where the thread survived close(); covered by mock_tests/test_auth.py); non-finite timeouts mean "no deadline" for every REST/gRPC hand-off
  • weaviate/collections/batch/async_.pybatch.stream()/experimental() fail fast under grpc-web with a clear error pointing to insert_many() (bidi streaming is impossible over fetch)
  • weaviate/proto/v1/__init__.py — grpcio version fallback when dist metadata is absent (Emscripten-only), drift-pinned by proto_test

CI / 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.existsreference_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.yamlgrpc-web-tests (package suite, 3.10–3.14) + pyodide-e2e (real WASM interpreter, no browser needed); ruff, flake8 and pyright cover packages/web; build-and-publish needs both new jobs
  • test/test_wasm_compat.py, proto_test/test_proto.py — Emscripten guards + fallback-version drift pins

Changes 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 outlived close(); a client dropped without close() 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 just HTTPError) 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 on close() (event wait instead of sleep), and a close()connect() cycle no longer leaves the previous refresher thread alive.
  • connect/v4.pyconnect() reports the underlying gRPC code and details instead of discarding them; both the sync and async native-gRPC connection errors gain one appended The gRPC call failed with: <CODE> - <details> line (type unchanged).
  • connect/v4.pyhttpx.TimeoutException subclasses (ConnectTimeout, WriteTimeout, PoolTimeout) raise WeaviateTimeoutError instead 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 — a RuntimeError is rewritten to WeaviateClosedClientError only when the underlying httpx client is actually closed (previously: message-text match; other RuntimeErrors now propagate as-is).
  • connect/base.py, connect/helpers.py — the grpc_path_prefix misconfiguration guards fire at client construction (both colours), not inside connect() after OIDC//v1/meta succeeded.
  • collections/batch/async_.py, sync.py, batch_wrapper.pyflush() and __put() raise when background tasks die instead of spinning; leaving with/async with client.batch.stream() raises a WeaviateBatchStreamError if 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. _BatchStreamShutdownError is now a WeaviateBatchStreamError subclass; WeaviateBatchError messages carry the gRPC details only.
  • client_executor.pyis_ready/is_live/gRPC-ping diagnostics moved from print() (stdout) to logger.warning() on the weaviate-client logger (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_many read an unset .elapsed, Timeout(...=inf) overflowed in the fetch layer, and the async OIDC refresher retried a permanent invalid_grant once 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 MB insert_many no longer cut at ~5 s, API-key auth incl. a trailers-only error, 30-way concurrency, 8 MB blobs, 110 MB → clean RESOURCE_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_vector with distance/certainty, hybrid in both fusion modes with explain_score, near_object, two-level nested references, group_by, cursor paging, the complex-type matrix, generative); a 502 maps to UNAVAILABLE and is retried again (1 attempt -> 6); a 404 names both candidate causes (server older than 1.38.3, or a wrong grpc_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 own grpcMaxMessageSize, surfaced as RESOURCE_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, including Access-Control-Expose-Headers: Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin, so trailers-only errors are readable from browser JS. CORS_ALLOW_ORIGIN only narrows *. Two caveats: the server's allow-headers list is a closed allowlist, so a custom header passed via headers={...} breaks the browser preflight; and /v1/.well-known/ready sends 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-client gains weaviate-client-web ; sys_platform == "emscripten" (after verifying micropip handles the dependency cycle), collapsing the WASM install to micropip.install("weaviate-client").

🤖 Generated with Claude Code

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>

@orca-security-eu orca-security-eu Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orca Security Scan Summary

Status Check Issues by priority
Passed Passed Infrastructure as Code high 0   medium 0   low 0   info 0 View in Orca
Passed Passed SAST high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Secrets high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Vulnerabilities high 0   medium 0   low 0   info 0 View in Orca

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 grpcio as not required on sys_platform == "emscripten" and add a narrow grpcio metadata fallback for proto variant selection.
  • Add packages/grpc-web/ with a grpc shim (sys.modules install) and a unary-only grpc-web channel that maps grpc-web responses to AioRpcError.
  • Add unit/integration-style tests for framing, transport behavior, and shim-based import weaviate in 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.

Comment thread packages/web/src/weaviate_client_web/_sender.py
Comment thread packages/web/src/weaviate_client_web/_channel.py Outdated
Comment thread packages/web/src/weaviate_client_web/_channel.py
…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>
@g-despot

g-despot commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up commit: grpc-web on the REST host:port via a base-path prefix (99f5356e)

Builds on the transport in this PR to support the production wire contract from weaviate/weaviate#11673 — grpc-web multiplexed onto the REST host:port under a base-path prefix (e.g. /grpc-web, via an in-process vanguard transcoder).

Core client

  • 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 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 native channel options stay byte-for-byte unchanged.

weaviate-python-grpc-web

  • The shim's channel factories read that option; GrpcWebChannel prepends the prefix, so requests go to <scheme>://<host>:<port><prefix>/weaviate.v1.Weaviate/<Method>.

Verification

  • Unit: new test/test_connection_params.py (collision relaxed only with a prefix; native same-port still raises; option forwarded/omitted) + new grpc-web transport cases for the prefixed/normalized URL.
  • End-to-end against a vanguard transcoder with grpc_host == http_host == localhost:8090 and grpc_path_prefix="/grpc-web": insert_many / fetch_objects / aggregate round-trip over grpc-web with matching counts; the native same-host:port config still raises ValidationError.

The relaxation is strictly opt-in (keyed on the prefix), so native gRPC behavior is preserved.

g-despot and others added 2 commits June 9, 2026 21:51
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 5 comments.

Comment thread weaviate/connect/base.py Outdated
Comment thread weaviate/connect/helpers.py
Comment thread weaviate/connect/helpers.py Outdated
Comment thread packages/grpc-web/src/weaviate_grpc_web/_channel.py Outdated
Comment thread weaviate/proto/v1/__init__.py
@codecov-commenter

codecov-commenter commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.56311% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.64%. Comparing base (95b5d76) to head (524a27a).
⚠️ Report is 79 commits behind head on main.

Files with missing lines Patch % Lines
weaviate/connect/v4.py 82.14% 20 Missing ⚠️
weaviate/collections/batch/async_.py 82.60% 4 Missing ⚠️
weaviate/exceptions.py 90.90% 2 Missing ⚠️
mock_tests/test_auth.py 98.52% 1 Missing ⚠️
test/test_batch_async.py 98.63% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…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>
g-despot and others added 12 commits June 10, 2026 11:05
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
@g-despot
g-despot requested a review from a team as a code owner August 17, 2026 07:09
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.
@g-despot
g-despot requested a balanced review from Copilot August 20, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Thus Timeout(query=0) can wait indefinitely only for REST calls under Pyodide. Preserve zero as an immediate AbortSignal and reserve None/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, not UNAVAILABLE. Returning UNAVAILABLE makes 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_prefix to use_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-web under Emscripten. As written, the advertised quickstart raises TypeError; update the PR description to match the implemented auto-routing contract.
    weaviate/connect/helpers.py:57
  • When only grpc_secure differs, this warning is triggered but prints identical host:port strings, so users cannot see what was overridden. Include the TLS state in both endpoint values so the new warning accurately identifies the discarded configuration.

Comment thread weaviate/connect/v4.py
Comment on lines +625 to +627
task = loop.create_task(
self.__periodic_token_refresh_async(expires_in, _auth, shutdown)
)
Comment thread weaviate/connect/v4.py
Comment on lines +708 to +710
if self._shutdown_background_event is not None:
self._shutdown_background_event.set()
task, self.__token_refresh_task = self.__token_refresh_task, None
Comment on lines +89 to +100
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants