Async RPC between services with Resonate
Functions in different processes call each other through Resonate. Each call is durable; chains survive crashes anywhere along the path.
A gateway calls service A, which calls service B, which calls service C. Each step uses Resonate's remote function invocation APIs, so the entire chain is durable — a crash on any service resumes the call without re-running prior steps.
TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server.
Nine-service Express example demonstrating await-chain, detached, and fan-out RPC flows.
Multi-service example demonstrating ephemeral-to-durable and durable-to-durable RPC.
The problem#
Cross-service calls inside a request flow are where most distributed systems fall over. The HTTP request from a client is ephemeral — if the client or any service in the chain crashes, the request is lost and has to start over. Teams paper this over with retries, idempotency keys, and saga frameworks; it's a lot of glue, and the seams leak.
Resonate's solution#
Resonate exposes two layers of remote function invocation:
resonate.rpc(...)— ephemeral to durable. Called from a regular process (like an HTTP handler). The receiving function is durable; the caller is not.ctx.rfc(...)/ctx.rfi(...)— durable to durable. Called from inside another durable function. Both ends checkpoint, so the chain stays resumable end-to-end.
rfc (Remote Function Call) blocks the caller until the result returns. rfi (Remote Function Invocation) returns a handle immediately so the caller can do other work and await later.
Code walkthrough#
The example chains a gateway through service-a → service-b. Each service is a separate process that registers its function with the Resonate Server and listens for invocations.
The gateway (ephemeral → durable)#
app.post("/await-chain", async (_req, res) => {
const promiseId = "await-chain";
// Ephemeral-to-durable: HTTP handler calls into a durable service.
const handle = await resonate.beginRpc<number>(
promiseId,
"foo",
"foo",
resonate.options({ target: "poll://any@service-a" }),
);
// Block the HTTP response on the chain's result.
const message = await handle.result();
res.status(200).json({ message });
});@app.route("/await-chain", methods=["POST"])
def await_chain_route_handler():
promise_id = "await-chain"
# Ephemeral-to-durable: HTTP handler calls into a durable service.
handle = resonate.options(target="poll://service-a").rpc(promise_id, "foo")
# Block the HTTP response on the chain's result.
message = handle.result()
return jsonify({"message": message}), 200resonate.beginRpc (TypeScript) / resonate.rpc (Python) dispatches foo on whichever process is registered as service-a. The promise ID makes the call idempotent — re-running the same handler with the same ID reconnects to the in-flight execution rather than starting a duplicate chain.
Service A → Service B (durable → durable)#
function* foo(ctx: Context): Generator<any, number, any> {
// ctx.rpc dispatches to service-b and waits for the result.
// Both sides are durable; the call resumes if either process crashes.
const result: number = yield* ctx.rpc<number>(
"bar",
ctx.options({ target: "poll://any@service-b" }),
);
return result + 1;
}
resonate.register("foo", foo);function* bar(ctx: Context): Generator<any, number, any> {
const result: number = yield* ctx.rpc<number>(
"baz",
ctx.options({ target: "poll://any@service-c" }),
);
return result + 1;
}
resonate.register("bar", bar);For fire-and-forget calls, swap ctx.rpc for ctx.detached:
// Detached: dispatch and don't wait. Returns immediately.
yield* ctx.detached("qux", ctx.options({ target: "poll://any@service-d" }));@resonate.register
def foo(ctx):
# ctx.rfc dispatches to service-b and waits for the result.
# Both sides are durable; the call resumes if either process crashes.
result = yield ctx.rfc("bar").options(target="poll://service-b")
return result + 1@resonate.register
def bar(ctx):
result = yield ctx.rfc("baz").options(target="poll://service-c")
return result + 1For fire-and-forget calls — kicking off work without blocking on its result — swap ctx.rfc for ctx.rfi:
# Detached: dispatch and don't wait.
ctx.rfi("qux").options(target="poll://service-d")The ctx.rpc / ctx.rfc call inside a durable function is the durable equivalent of await someService.bar(). The result is checkpointed, so a worker that completes bar and then dies still passes its result back when the chain resumes.
Run it locally#
Both repos ship with nine services so you can exercise the await-chain, detached-chain, and fan-out flows.
git clone https://github.com/resonatehq-examples/example-async-rpc-ts
cd example-async-rpc-ts
bun installbrew install resonatehq/tap/resonate
resonate devStart each service in its own terminal (a through i):
bun run a
bun run b
# ...c through i, one terminal eachStart the gateway:
bun run gatewaygit clone https://github.com/resonatehq-examples/example-async-rpc-py
cd example-async-rpc-py
uv syncbrew install resonatehq/tap/resonate
resonate serveStart each service in its own terminal:
uv run python src/service_a.py
uv run python src/service_b.py
# ...service_c through service_i, one terminal eachStart the gateway:
uv run python src/gateway.pyTrigger the await-chain:
curl -X POST http://localhost:5000/await-chainThe gateway logs each step as the chain hops from service-a to service-b to service-c, returning a final value back through the gateway to the HTTP response.
Try the recovery story#
Trigger the chain, then kill service-b while service-c is still working. Restart service-b. The chain resumes — service-b doesn't re-call service-c (its result is already checkpointed); it just returns the cached result up the stack to service-a.
Related#
- Async HTTP API endpoints — the same ephemeral-to-durable pattern, focused on the HTTP boundary.
- Human-in-the-loop —
ctx.promise()is the suspend-and-resume cousin ofctx.rfc.