pyRPC
← Back to Blog

Following an RPC Call: From TypeScript Client to Python Function and Back

·8 min read

The RPC Call Flow dynamic diagram traces a single remote procedure call from the TypeScript client, through HTTP, into the Python backend, and back with a response. Let's walk through each step.

The setup

A TypeScript developer calls:

import { createClient } from "@pyrpc/client";
import type { Types } from "@pyrpc/types";

const client = createClient<Types>({ baseUrl: "http://localhost:8000" });
const result = await client.greet({ name: "World" });

And on the Python side, the corresponding endpoint:

from pyrpc_core import rpc

@rpc
def greet(name: str) -> str:
    return f"Hello, {name}!"

Step 1: Proxy intercepts the call

createClient<Types>() returns a Proxy object. When you access client.greet, the Proxy captures the property name "greet" and returns a function. Calling that function with { name: "World" }:

  1. Detects the argument is a single object → named parameters mode
  2. Builds a JSON-RPC 2.0 request: { id: "1", method: "greet", params: { name: "World" } }
  3. Issues HTTP POST to /rpc

The Proxy supports two calling conventions: client.method({ arg1: val }) (named params, single object) and client.method(val1, val2) (positional params, array).

Step 2: Adapter dispatches to core

The POST request arrives at one of four adapters. The FastAPI adapter does:

@app.post("/rpc")
async def rpc_handler(request: Request):
    payload = await request.json()
    response = await handle_request(payload, router)
    return JSONResponse(response)

26 lines for the entire adapter. The Flask adapter is more interesting — it must bridge sync to async:

@flask.route("/rpc", methods=["POST"])
def rpc_handler():
    payload = request.get_json()
    response = anyio.run(handle_request, payload, router)
    return jsonify(response)

Step 3: Envelope validation

handle_request() validates the incoming payload against the Pydantic v2 RpcRequest model:

class RpcRequest(BaseModel):
    id: str | int | None
    method: str
    params: list[object] | dict[str, object] | None = None

If validation fails, a JSON-RPC error response is returned immediately with -32600 (Invalid Request) or -32700 (Parse Error). This is a critical security boundary — no unvalidated data reaches user code.

Step 4: Router lookup

The interpreter extracts the method field and calls router.get("greet"). The Router is a thread-safe dictionary. If "greet" isn't registered, the interpreter returns -32601 (Method Not Found).

Step 5: Procedure.execute() — the hot path

Each Procedure is compiled at registration time with pre-built Pydantic TypeAdapters:

class Procedure:
    def __init__(self, fn, request_model, response_model):
        self.fn = fn
        self.request_adapter = TypeAdapter(request_model)
        self.response_adapter = TypeAdapter(response_model)

    def execute(self, params):
        validated_params = self.request_adapter.validate_python(params)
        result = self.fn(**validated_params)
        validated_result = self.response_adapter.validate_python(result)
        return validated_result

Why this matters: The TypeAdapters are built once at registration time (import), not on every request. The hot path has zero type-resolution overhead — just fast C-level Pydantic validation.

For our greet call: request_adapter validates { name: "World" } (name must be str), the function runs returning "Hello, World!", response_adapter validates the return type (must be str).

Step 6: Response flows back

The interpreter builds an RpcResponse:

class RpcResponse(BaseModel):
    id: str | int | None
    result: Any = None
    error: ErrorModel | None = None

Success: { "id": "1", "result": "Hello, World!" }. Error: { "id": "1", "error": { "code": -32603, "message": "Internal error" } }. The response serializes back through the adapter → HTTP → client.

The error path

If the server returns an error, the TypeScript client throws PyRPCError with structured fields: code, message, and optional data. The caller catches it with full type information:

try {
    await client.method(arg);
} catch (e) {
    if (e instanceof PyRPCError) {
        console.error(`RPC error ${e.code}: ${e.message}`, e.data);
    }
}

What this tells us

Tracing a single call reveals several architectural decisions:

  1. The Proxy pattern is brilliant for DX. No code generation needed on the client for method dispatch. Just use it.
  2. Validation happens at the right boundaries. The JSON-RPC envelope is validated at the interpreter boundary. Function parameters are validated by pre-built TypeAdapters. User code never sees invalid data.
  3. The adapters are pure plumbing. They add zero overhead to the hot path. The entire adapter layer could be replaced (WebSocket, anyone?) without changing a line of core code.
  4. The hot path is optimized. TypeAdapters built at init time mean the critical execute() path is pure C-level Pydantic — no Python-level type introspection on the hot path.

Read the visual tour post for the full diagram walkthrough, or run npx likec4 start architecture to explore the RPC Call Flow dynamic diagram interactively.