# Replacing `connectrpc` (and its `pyqwest` dependency) with a hand-rolled Connect client Exploration notes for `chattolib-zo8b`. Goal: determine whether chattolib can drop the `connectrpc` package — and with it the transitive `pyqwest` — by implementing the small slice of the Connect protocol it actually uses. ## TL;DR recommendation **Feasible, and small.** Everything chattolib needs from `connectrpc` reduces to one async method, `execute_unary`, plus a trivial binary codec and a JSON error-envelope parser. The whole thing is ~150 lines. The real cost is not the runtime — it's **code generation**: chattolib's 24 `*_connect.py` stubs are emitted by `protoc-gen-connect-python`, which is coupled to `connectrpc`'s types. We either (a) keep the stubs but repoint their base class, or (b) write a tiny codegen that emits only the async client. ## What chattolib actually uses from connectrpc Of the 8 `connectrpc` submodules the generated stubs import (`client`, `code`, `compression`, `errors`, `interceptor`, `method`, `request`, `server`), chattolib's runtime path touches only: | connectrpc piece | Used by chattolib? | Notes | |---|---|---| | `client.ConnectClient` (async) | **Yes** — 23 `*ServiceClient` classes subclass it | Only the async client. The `*ClientSync`, `*ASGIApplication`, `*WSGIApplication`, and `Protocol` classes in each stub are **dead weight** chattolib never imports. | | `client.execute_unary(...)` | **Yes** — the one method every stub calls | POSTs to `/{service}/{Method}`, encodes with the codec, decodes the response, maps errors. | | `compat.google_protobuf_binary_codec` | **Yes** | Trivial: `encode = SerializeToString`, `decode = cls.FromString`. ~10 lines. | | `errors.ConnectError` | **Yes** | Chattolib wraps it into `ChattoConnectError`. | | `code.Code` | **Yes** | The gRPC/Connect status-code enum. | | `method.MethodInfo` / `IdempotencyLevel` | **Yes** (data only) | Just carries `name`, `service_name`, input/output classes. | | `request.Headers` / `RequestContext` | Indirectly | Chattolib passes `headers=` (an auth bearer) into each call. | | `compression`, `interceptor`, `server.*` | **No** | Chattolib never compresses, never intercepts, never serves. | So the *entire* runtime surface is: **one async unary call + a binary codec + an error parser.** ## The wire protocol (what `execute_unary` does) From `connectrpc/_client_async.py::_send_request_unary`: 1. `url = f"{address}/{service_name}/{Method}"` (e.g. `/api/connect/chatto.api.v1.RoomService/JoinRoom`) 2. `body = codec.encode(request)` → `request.SerializeToString()` 3. `POST url` with headers (chattolib adds `Authorization: Bearer `), `Content-Type: application/proto` 4. On `200`: `return codec.decode(resp.content, output_cls)` → `output_cls.FromString(body)` 5. On non-200: parse the JSON error envelope `{"code": "...", "message": "...", "details": [...]}` into a `ConnectError(code, message, details)`; fall back to an HTTP-status→code map if the body isn't JSON. 6. Timeouts → `DEADLINE_EXCEEDED`; network errors → `UNAVAILABLE`. That's the whole protocol for unary Connect. (Chatto uses unary-only; there are no client/server/bidi streams in the Chatto API surface chattolib wraps.) ## The two real decisions ### 1. The codec is a non-issue `ProtoBinaryCodec` is `SerializeToString`/`FromString`. Trivial to copy. ### 2. Code generation is the actual work The 24 stubs are generated by `protoc-gen-connect-python` and import `connectrpc.{client,code,compression,errors,interceptor,method,request,server}`. Two paths: - **(a) Repoint, don't regenerate.** Keep the existing `*_connect.py` files but make their base class a chattolib-owned `ConnectClient` that *also* exposes `execute_unary`. The stubs' `MethodInfo(...)` calls would need our own `MethodInfo`/`IdempotencyLevel` shims. Pro: no codegen change. Con: we still carry 24 files that import names we now have to provide, and every future proto regen re-introduces `connectrpc` imports. - **(b) Tiny custom codegen.** Write a small `protoc` plugin (or a Python post-processor over the `.proto`/`_pb2` files) that emits *only* the async client class, importing from `chattolib._connect`. Pro: clean, no `connectrpc` anywhere, stubs shrink ~4x (drop sync/ASGI/WSGI/Protocol). Con: it's a new thing to maintain and to run on every proto regen. Option (b) is the cleaner end state and matches "chattolib owns its transport." Option (a) is the lower-effort first step and a fine way to de-risk. ## What we'd drop / keep - **Drop:** `connectrpc` (and therefore `pyqwest`) from `pyproject.toml`. - **Keep:** `protobuf` (the messages), `websockets` (realtime, unaffected). - **Net:** dependency tree goes from `connectrpc → {protobuf-py, pyqwest}` to just `protobuf`. `pyqwest` (a Rust-backed HTTP client) disappears entirely. ## Risks - **Correctness on the error path.** The JSON error envelope + `details` (protobuf `Any`, base64) + HTTP-status fallback is the fiddly bit. Must be tested against real server errors, not just happy paths. - **`use_get` / compression / interceptors.** Chattolib never uses them, so we can simply not implement them. (Confirm no stub call passes `use_get=True` in a way chattolib relies on — it doesn't; chattolib always POSTs.) - **Future Chatto changes.** If Chatto ever adds a streaming RPC, the hand-rolled client would need a streaming path. Today: none. - **We own the protocol now.** No more upstream `connectrpc` fixes/patches. Acceptable given how small the surface is. ## Prototype plan (next step) 1. `src/chattolib/_connect.py`: `Codec`, `MethodInfo`, `Code` (re-export or minimal enum), `ConnectError`, and `class ConnectClient` with `async execute_unary(...)` doing the 6 steps above over `httpx`. 2. Point the 23 `*ServiceClient` base classes at it (option a) — or regenerate (option b). 3. Re-run the full test suite + a live smoke test against the preview server. 4. If green: drop `connectrpc` from `pyproject.toml`. --- ## Result (spike outcome) **Feasible and done — as a working prototype in this worktree.** The hand-rolled client is green against the live server with `connectrpc` and `pyqwest` physically uninstalled. ### What was built (all in this worktree, throwaway until you approve) - **`src/chattolib/_connect.py`** (~180 lines) — the whole replacement: - `Code` enum (the 16 Connect/gRPC status codes) - `ConnectError` (`.code`, `.message`, `.details`, `.status_code`; `str()` is the message only, matching `connectrpc` so `translate_connect_error` is unchanged) - `MethodInfo` (name, service_name, input, output, idempotency_level) - `_BinaryCodec` (`SerializeToString` / `FromString`) + `google_protobuf_binary_codec()` - `ConnectClient` with `async execute_unary(...)` over `httpx`: POST `/{service}/{Method}`, `Content-Type: application/proto`, 200 → decode, non-200 → parse the JSON error envelope (with an HTTP-status→code fallback). - **`scripts/regen_connect.py`** — the tiny codegen. Reads each existing `*_connect.py` and rewrites it to keep **only** the async `*ServiceClient` (dropping the `Protocol`/`*Sync`/`*ASGIApplication`/`*WSGIApplication`/ `*ClientSync` classes chattolib never used), strip all `connectrpc` imports, repoint the base class + `MethodInfo`/`IdempotencyLevel` at `chattolib._connect`, and trim the `timeout_ms`/`use_get` params our client doesn't take. - **`src/chattolib/_transport.py`** — `Code`/`ConnectError`/ `google_protobuf_binary_codec` now imported from `chattolib._connect`. - **`src/chattolib/client.py`** — the `ConnectError` import in `_rpc` repointed. - **`pyproject.toml`** — `connectrpc` removed from `dependencies` (now just `protobuf`; `httpx` is already gone from the bot-only change). ### Verification - Full suite: **64 passed, 2 skipped** (the 2 are the bot-key-gated live tests). - `ruff check` + `mypy` clean. - **Live** against `next.preview.chatto.run` with `connectrpc`/`pyqwest` uninstalled: `get_server`, `me`, `list_rooms`, `post_message` (a write), and the error path (`not_found`) all work. ### The real trade-off (the actual recommendation) The runtime is small and the win is real: **the dependency tree loses both `connectrpc` and `pyqwest`** (a Rust-backed HTTP client), leaving just `protobuf` + `websockets`. In exchange: 1. **We own the Connect protocol now.** ~180 lines, well-tested here, but no more upstream patches. The surface is small (unary only) so this is low-risk — *as long as Chatto stays unary-only*. If Chatto ever adds a streaming RPC, this client needs a streaming path. 2. **Codegen is a new maintenance item.** `regen_connect.py` is a regex-based post-processor over the `connectrpc`-generated files, not a true `protoc` plugin. It works for the current 24 stubs, but on each proto regen you'd run `generate_pb.sh` (which still uses `protoc-gen-connect-python` to *produce* the stubs) **then** `regen_connect.py` to strip them down. So the `connectrpc` *code generator* is still in the build loop even though the *runtime* is gone. A fully clean end state would replace that generator too — a larger lift. 3. **The stubs are now "DO NOT EDIT" in a new sense.** They're generated, but by our script; the header says so. ### Recommendation **Go, with a caveat.** The runtime replacement is clearly worth it if dropping `pyqwest` is the goal — it's ~180 lines, fully tested, and verified live. The one thing to decide before merging: do you want to (a) ship this as-is (runtime freedom, but `generate_pb.sh` still shells out to the connectrpc codegen), or (b) also write a from-scratch `protoc` plugin so the connectrpc generator is gone from the build entirely? (a) is this worktree; (b) is a follow-on.