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" }:
- Detects the argument is a single object → named parameters mode
- Builds a JSON-RPC 2.0 request:
{ id: "1", method: "greet", params: { name: "World" } } - 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 = NoneIf 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_resultWhy 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 = NoneSuccess: { "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:
- The Proxy pattern is brilliant for DX. No code generation needed on the client for method dispatch. Just use it.
- 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.
- 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.
- 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.

pyRPC