Skip to content

Request / Response

One parent machine owns an ActorMap of short-lived request handlers, one per order. Each handler settles exactly once (answered or timed out) and reports back through declared outputs.

One short-lived machine per request. idle until told to work, then pending with its own timeout, then one final state: answered or timedOut.

const request = event("request")<{ orderId: string; timeoutMs: number }>();
const answer = event("answer")<{ orderId: string; result: string }>();
const { timeout } = events("timeout");
const requestSettled = event("requestSettled")<Settled>();
const { idle, pending } = states("idle", "pending");
const answered = state("answered")().final();
const timedOut = state("timedOut")().final();
function createRequestHandler(orderId: string, clock: Clock) {
return new Actor({
inputs: [request, answer],
internal: [timeout],
outputs: [requestSettled],
states: [idle, pending, answered, timedOut],
initial: idle,
clock,
context: { orderId, timeoutMs: 0 },
setup: (m) => {
m.on(idle, request, (event, opts) => {
const s = opts.context.get();
s.timeoutMs = event.payload.timeoutMs;
opts.context.set(s);
return { state: pending };
});
m.effect(pending, {
name: "startResponseTimeout",
fn: (input) => {
withTimeout(input.context.get().timeoutMs, input, () => timeout.create());
},
});
m.on(pending, answer, (event) => ({
state: answered,
emit: [
requestSettled.create({ orderId, status: "answered", result: event.payload.result }),
],
}));
m.on(pending, timeout, () => ({
state: timedOut,
emit: [requestSettled.create({ orderId, status: "timedOut" })],
}));
},
});
}

requestSettled is declared in outputs. The handler emits it and walks away. Emitted events that are not internal or input become outputs delivered to the wiring. That is the child-to-parent reporting mechanism.

One parent owns an ActorMap of handlers keyed by order id. Routes answers to the right handler, collects settlements.

const requests = new ActorMap(
(orderId) => {
const child = createRequestHandler(orderId, clock);
onOutput(child, (e) => {
if (requestSettled.is(e)) manager.send(e);
});
return child;
},
{ autoReap: true },
);

ActorMap children do not wire automatically. One onOutput line per child. The is() guard narrows the child’s output to the parent’s declared input.

Promises live at the boundary, never in the machine. The bridge maps orderSettled output to a resolver keyed by order id.

{ autoReap: true } removes a handler the moment it reaches a final state. Answer arrives, handler final, done, removed. Timeout fires, same.

Late answers are no-ops: the handler is gone. Settles exactly once.

The handler owns its timeout. The pending effect arms it via withTimeout; leaving the state aborts it. Answer wins, timer cancelled. Timeout wins, handler settles timedOut. No dangling timers.

Each request has its own timeoutMs. Independent deadlines, one clock.

waitFor(orderId) returns a promise that resolves when that request settles.

  • Reads parent context first. Already settled = resolves immediately.
  • Re-dispatching an order clears its settled result, so waitFor waits for the new attempt.
requester.request("ord_1", 1000);
requester.answer("ord_1", "shipped");
await requester.waitFor("ord_1"); // { orderId: "ord_1", status: "answered", result: "shipped" }
await requester.waitFor("ord_1"); // same, already settled

Deterministic on one VirtualClock:

const clock = new VirtualClock();
const requester = createRequester(clock, 1000);
const promise = requester.waitFor("ord_2");
requester.request("ord_2", 1000);
clock.advance(1001);
await expect(promise).resolves.toEqual({ orderId: "ord_2", status: "timedOut" });
expect(requester.requests.size).toBe(0);