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.

Async RPC banner

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.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server.

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-aservice-b. Each service is a separate process that registers its function with the Resonate Server and listens for invocations.

The gateway (ephemeral → durable)#

src/gateway.ts
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 });
});

resonate.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)#

src/service_a.ts
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);
src/service_b.ts
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:

code
// Detached: dispatch and don't wait. Returns immediately.
yield* ctx.detached("qux", ctx.options({ target: "poll://any@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.

code
git clone https://github.com/resonatehq-examples/example-async-rpc-ts
cd example-async-rpc-ts
bun install
Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev

Start each service in its own terminal (a through i):

code
bun run a
bun run b
# ...c through i, one terminal each

Start the gateway:

code
bun run gateway

Trigger the await-chain:

code
curl -X POST http://localhost:5000/await-chain

The 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.