Guide

Retry Policies and Exhaustion

Keep one Activity occurrence across bounded attempts and preserve its outcome.

Retry is policy for one mounted Activity occurrence. It decides whether a modeled failure should begin another attempt or become the occurrence's terminal Error; it does not create a second application loop around the chart.

Retry delays an outcome; it does not replace it.

Retry is lifecycle policy for one mounted activity—not an application loop. Attempts remain one invocation until success or the policy stops.

1 · One attempt, one modeled failure

The first failure reaches Error immediately.

ChargeCard belongs to Charging. Without retry policy, its first modeled failure produces the existing typed Error outcome and the chart enters Declined.

import {
  Activity,
  State,
  Statechart,
  States,
  Transitions,
} from "@motive/motive";
import { Schema } from "effect";

const Charge = Schema.TaggedStruct("Charge", {});

export const ChargeCard = Activity.make("ChargeCard", {
  success: Schema.Struct({ receiptId: Schema.String }),
  error: Schema.String,
});

class Payment extends State.Compound<Payment>()("Payment") {
  static states = States.make(() => [Ready, Charging, Paid, Declined]);
}

class Ready extends State.Atomic<Ready>()("Ready") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Charge, Charging),
  ]);
}

class Charging extends State.Atomic<Charging>()("Charging") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(ChargeCard),
    on(ChargeCard.Done, Paid),
    on(ChargeCard.Error, Declined),
  ]);
}

class Paid extends State.Atomic<Paid>()("Paid") {}

class Declined extends State.Atomic<Declined>()("Declined") {}

export const PaymentChart = Statechart.make(Payment);

2 · Mount retry policy with the work

A retry keeps the same invocation alive.

RetryOnce belongs to this ChargeCard site. Attempt one fails, the engine follows the bound schedule, and attempt two succeeds without publishing a terminal Error edge in between.

import {
  Activity,
  Retry,
  State,
  Statechart,
  States,
  Transitions,
} from "@motive/motive";
import { Schema } from "effect";

const Charge = Schema.TaggedStruct("Charge", {});

export const ChargeCard = Activity.make("ChargeCard", {
  success: Schema.Struct({ receiptId: Schema.String }),
  error: Schema.String,
});

export const RetryOnce = Retry.make("RetryOnce");

class Payment extends State.Compound<Payment>()("Payment") {
  static states = States.make(() => [Ready, Charging, Paid, Declined]);
}

class Ready extends State.Atomic<Ready>()("Ready") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Charge, Charging),
  ]);
}

class Charging extends State.Atomic<Charging>()("Charging") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(ChargeCard).retry(RetryOnce),
    on(ChargeCard.Done, Paid),
    on(ChargeCard.Error, Declined),
  ]);
}

class Paid extends State.Atomic<Paid>()("Paid") {}

class Declined extends State.Atomic<Declined>()("Declined") {}

export const PaymentChart = Statechart.make(Payment);

3 · The schedule eventually says stop

Exhaustion delivers the original typed Error.

Both permitted attempts fail. The final ChargeCard.Error still carries the declared string, so the same listener enters Declined and records exactly why.

import {
  Activity,
  Retry,
  State,
  Statechart,
  States,
  Transitions,
} from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";

const Charge = Schema.TaggedStruct("Charge", {});

export const ChargeCard = Activity.make("ChargeCard", {
  success: Schema.Struct({ receiptId: Schema.String }),
  error: Schema.String,
});

export const RetryOnce = Retry.make("RetryOnce");

export class Payment extends State.Compound<Payment>()("Payment", {
  lastFailure: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("")),
  ),
}) {
  static states = States.make(() => [Ready, Charging, Paid, Declined]);
}

class Ready extends State.Atomic<Ready>()("Ready") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Charge, Charging),
  ]);
}

class Charging extends State.Atomic<Charging>()("Charging") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(ChargeCard).retry(RetryOnce),
    on(ChargeCard.Done, Paid),
    on(ChargeCard.Error, Declined).update(Payment, ({ event }) => ({
      lastFailure: event.error,
    })),
  ]);
}

class Paid extends State.Atomic<Paid>()("Paid") {}

class Declined extends State.Atomic<Declined>()("Declined") {}

export const PaymentChart = Statechart.make(Payment);

Failure does not always mean finished

ChargeCard declares a typed error channel. Without an attached retry policy, its first modeled failure settles the occurrence immediately and produces ChargeCard.Error.

The first frame makes that baseline visible: Charging invokes once, the provider fails once, and the chart enters Declined. Nothing outside the chart has to guess whether another attempt is pending.

Retry is an authored citizen

A retry policy has a stable name in the declarative model:

const RetryOnce = Retry.make("RetryOnce");

The invocation site attaches that policy explicitly:

invoke(ChargeCard).retry(RetryOnce),

This says which occurrence may retry without embedding runtime timing or infrastructure inside the topology. The chart's Layer binds RetryOnce to an Effect Schedule, and that binding receives the typed modeled error plus the retry subject when policy needs to inspect them.

Attempts belong to one occurrence

In the second frame, attempt one fails and the bound schedule permits one more attempt. Charging stays active, the same Activity occurrence remains mounted, and attempt two succeeds. Only then does ChargeCard.Done reach transition selection.

No intermediate ChargeCard.Error is published. The failure is evidence for the retry policy, not yet the occurrence's terminal outcome.

The readout also shows one stable invocation identity across both attempts. That is the safe default for an idempotent provider operation: several attempts still describe one logical charge. Attempt-specific external identity must be an explicit provider decision.

Exhaustion preserves the modeled error

The third frame lets every permitted attempt fail. When the schedule declines another retry, the occurrence produces the same typed ChargeCard.Error it would have produced without retry:

on(ChargeCard.Error, Declined, ({ event }) => ({
  lastFailure: event.error,
}));

There is no separate authored Exhausted event. Exhaustion is the policy's decision to stop delaying a modeled outcome; it does not erase, wrap, or reclassify the final error.

Defects bypass modeled retry

Retry policy receives the Activity's declared error type. A Cause containing a defect selects ChargeCard.Defect instead, so the modeled retry schedule is not asked to normalize unexpected failure into another ordinary attempt.

If an application truly wants to restart after a defect, that is supervisory topology: observe or isolate the defective occurrence, then explicitly mount a fresh one. It is not the same contract as retrying one still-live occurrence after a modeled error.

Keep the policy observable

The engine owns the attempt cursor and the schedule decision. Runtime bindings can therefore annotate attempts, delays, final exhaustion, and stable invocation identity without inventing a parallel retry state machine inside the provider.

Choose a bounded policy that matches the external operation. Backoff, jitter, elapsed-time limits, and error-sensitive decisions belong in the bound Schedule; application recovery after the final error belongs in chart topology.

Test the retry boundary

  1. Run all three frames and compare immediate failure, retry success, and exhausted failure.
  2. Change the Schedule to permit two retries and confirm attempts 1 · 2 · 3 share one invocation.
  3. Make the provider die and verify that ChargeCard.Defect, not the retry policy, receives the terminal Cause.
  4. Reenter Charging after exhaustion and verify that the new occurrence begins again at attempt one with a fresh lifecycle.

Next, required and optional defect dispositions explain which terminal channels the model must handle and which remain deliberate supervisory choices.