Aller au contenu
Français

Ask pattern

Ce contenu n’est pas encore disponible dans votre langue.

tell is fire-and-forget — it returns immediately and you never see the actor’s response. Most actor-to-actor communication is fine like that. But sometimes you need a reply: an HTTP handler that asks an actor for state and serializes it as a response, a saga that needs the result of step N before issuing step N+1, a test that wants to assert on a return value.

ask is the helper for those cases. It sends a message to an actor, attaches a temporary “reply-to” ref under the hood, and returns a Promise that resolves with the first reply or rejects on timeout.

import { match } from 'ts-pattern';
import { Actor, ActorSystem, type ActorRef } from 'actor-ts';
type IncrementCommand = { readonly kind: 'increment' };
type GetCommand = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
type Command = IncrementCommand | GetCommand;
class Counter extends Actor<Command> {
private count = 0;
override onReceive(command: Command): void {
match(command)
.with({ kind: 'increment' }, () => this.onIncrement())
.with({ kind: 'get' }, (c) => this.onGet(c))
.exhaustive();
}
private onIncrement(): void { this.count++; }
private onGet(command: GetCommand): void { command.replyTo.tell(this.count); }
}
const system = ActorSystem.create('demo');
const counter = system.spawnAnonymous(Counter);
counter.tell({ kind: 'increment' });
counter.tell({ kind: 'increment' });
// Method-on-ref form — terse, infers the reply type:
const current = await counter.ask<number>({ kind: 'get' }, 5_000);
console.log(current); // 2
await system.terminate();

ask is a method on every ActorRef:

// Method on every ActorRef — infers Reply from the type argument:
ref.ask<Reply>(message, timeoutMs?): Promise<Reply>

The OmitReplyTo<TMessage> parameter type subtracts replyTo from every variant of the message union that declares it — callers never write the field themselves. The framework allocates a one-shot reply ref, attaches it both as context.sender and (when the message shape expects it) as message.replyTo, and resolves the promise with the first reply.

ask works by sending your message with a temporary reply-to ref attached. The receiver replies by tellling that ref.

By convention, the framework uses replyTo as the field name in the request message, and the recipient pattern-matches on it the same as any other field. Look at the Command type above: the 'get' variant has replyTo: ActorRef<number>.

For the recipient, it’s just a regular tell on a regular ref:

private onGet(command: GetCommand): void {
command.replyTo.tell(this.count);
}

Inside the recipient, you can also reach the same ref via this.sender — it’s an Option<ActorRef>, and ask attaches the temporary ref through that channel too:

private onGet(command: GetCommand): void {
this.sender.forEach((replyTo) => replyTo.tell(this.count));
}

Either works. Using an explicit replyTo field makes the type contract more obvious (the request shape declares it expects a reply of type ActorRef<number>); using this.sender is more implicit but works for actors that handle both ask-style and fire-and-forget messages.

import { AskTimeoutError } from 'actor-ts';
try {
const result = await counter.ask({ kind: 'get' }, 1_000);
console.log(result);
} catch (e) {
if (e instanceof AskTimeoutError) {
console.log('counter did not reply in time');
} else {
throw e;
}
}

The timeout is mandatory in spirit — defaulting to 5 seconds catches the case where you forgot to think about it. Tune per call site:

  • Cheap in-process replies: 100-500 ms.
  • Cross-cluster lookups: 1-5 s depending on network expectations.
  • Anything that might hit a slow downstream (DB query, HTTP call inside the actor): match the underlying-call’s timeout + a small buffer.

When the timeout fires, the AskTimeoutError rejects the Promise. The temporary reply-to ref is a one-shot: once it has settled — on the timeout or on the first reply — any later reply is silently discarded in place (it does not go to dead letters). The actor itself isn’t affected — it has no idea the asker gave up.

timeoutMs must be a positive finite number. 0, a negative value, NaN and Infinity all throw OptionsError at the call site, synchronously — before the message is sent, and as a thrown error rather than a rejected Promise, because an argument outside its domain is a bug in the caller rather than a failed request.

import { OptionsError } from 'actor-ts';
const budget = deadline - Date.now(); // negative once the deadline passed
try {
await counter.ask({ kind: 'get' }, budget);
} catch (e) {
if (e instanceof OptionsError) {
// budget was <= 0 — the deadline is already gone, don't ask at all
}
}

That is stricter than it looks at first, and deliberately so. The timeout is what arms the reply ref’s only self-settling path: a ref with no timer can be settled by a reply and by nothing else, so an unanswered ask would leave the await pending for the life of the process — and, across the cluster, leave one entry in the per-path handler map that every inbound envelope is matched against. Neither value needs a typo to arrive: a computed budget goes negative the moment its deadline passes, and an untyped configuration value arrives as whatever it parsed to.

To get the 5-second default, omit the argument (or pass undefined) — the parameter default still applies:

const value = await counter.ask<number>({ kind: 'get' });

A recipient can reject an ask by replying with an Error instance. The ask Promise rejects with that error rather than resolving with the message.

class Service extends Actor<Command> {
override onReceive(command: Command): void {
match(command)
.with({ kind: 'fetch' }, (c) => this.onFetch(c))
.exhaustive();
}
private onFetch(command: FetchCommand): void {
if (this.unavailable) {
command.replyTo.tell(new Error('service unavailable'));
return;
}
command.replyTo.tell(this.data);
}
}
try {
const data = await service.ask({ kind: 'fetch' });
} catch (e) {
// e is the actor's "service unavailable" Error.
}

This is a deliberate convention — the framework recognizes Error-typed replies and rejects the Promise. Carry domain errors the normal way (typed via the reply union) for everything else; reserve the Error-reply path for genuine failure states.

ask is convenient but it has overhead — it allocates a temporary ref, registers it with the system, and tears it down on resolution/timeout. Three situations where plain tell is the right call instead:

  • Messages — the replyTo: ActorRef<T> field convention covered in detail.
  • Actor — this.sender and how the framework wires it.
  • ActorSystem — where temporary ask refs are registered and torn down.
  • CircuitBreaker — when the ask’s downstream call is flakey and you want to fail fast rather than time out.
  • Future patterns — pipeTo, after, sequence — composing async results back into the actor world without await-blocking.

The ActorRef class API reference documents the ask method’s full signature and error types.