Skip to content

[Bug] tell() throws MailboxFullError into the sender under the reject policy, so an actor is failed and restarted because the actor it sent to was slow #919

Description

@pathosDev

Problem

tell() is documented as fire-and-forget and typed void, but it throws MailboxFullError into the sender when the target's mailbox is full under the reject overflow policy. When the sender is an actor, that throw escapes its onReceive and the sender is failed and restarted because the receiver was slow — failure propagates backwards along the message flow, which is the opposite of what supervision is for.

Compounding it: reject is the default for an explicitly constructed BoundedMailbox, while the cell's implicit one uses drop-head. The same class has two different defaults depending on who built it, so opting into a bounded mailbox by hand silently changes the failure mode from "lose messages" to "kill the sender".

Evidence

The throw:

src/mailbox/BoundedMailbox.ts:56-61
        .with('drop-new', () => {
          this.droppedCount++;
          this.onDrop?.('drop-new');
        })
        .with('reject', () => { throw new MailboxFullError(this.capacity); })
        .exhaustive();

The default that makes it reachable by accident:

src/mailbox/BoundedMailbox.ts:36
    this.overflow = settings.overflow ?? 'reject';

versus the cell's implicit construction, which passes DEFAULT_MAILBOX_OVERFLOW ('drop-head') at src/internal/ActorCell.ts:194-198.

The contract it breaks:

src/ActorRef.ts:59
 * forget; ask() provides a request/response Promise.

Reproduced. An actor floods a slow child whose mailbox is { capacity: 2, overflow: 'reject' }:

sender restarts caused by the RECEIVER being full: 11

And from outside an actor, where there is no supervisor to absorb it:

plain tell() from outside an actor threw: MailboxFullError

Proposal

tell() must not throw. Two coherent designs, either acceptable:

  • reject reports to the sender as a message, not as an exception — the target's deadLetters receives the envelope and the sender may watch for it. This keeps tell total.
  • reject is renamed and re-scoped to a non-tell entry point: a trySend(): boolean (or a promise that resolves when there is room) that a caller opts into explicitly, with tell never taking that path.

The second is more useful, because a backpressure-aware sibling to tell is what the I/O edge actually needs — today an HTTP request or broker message becomes a tell with no way to learn that it did not fit.

Separately, make BoundedMailbox's default overflow the same whichever way it is constructed.

Acceptance sketch

  • tell() never throws, for any overflow policy.
  • An actor is not failed because a target it sent to was full.
  • A rejected message is observable — dead letter, metric, or an explicit trySend result.
  • BoundedMailbox has one default overflow policy regardless of construction site, documented.

Verification status

Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution for both the actor-sender and the plain-caller case.

Part of the production-readiness review batch — tracked in #913.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highTop priority — high impact, plan nextproduction-goalBlocks or defines the path to production readiness

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions