Skip to content

Retry

What It Is

Dagonizer has two different retry mechanisms, and the distinction matters.

Node retry is graph topology: a node routes a retry output back into the DAG, and a state counter decides when to stop looping and route to salvage. RetryPolicy is operation resilience: it retries one transient call with backoff while honoring context.signal.

How It Works

Node retry is a route decision: state counts attempts and the DAG loops to retry or routes to salvage. RetryPolicy is an operation wrapper: it waits with backoff and cooperates with context.signal before returning control to the node. Both can be used together, but they solve different problems.

Dagonizer separates two concerns that both get called "retry":

  • Node retry is a flow shape. When a node cannot complete (its own deadline fires, or its work throws), it makes a flow decision: route a retry output that the DAG wires back to the node (a loop edge), or, once the attempt budget is spent, route a salvage output to a recovery node. The loop and the recovery both live in the topology. No retry policy hides inside the node.
  • RetryPolicy guards a single operation. It wraps one thunk and re-runs it on transient failures with a backoff curve. It is operation-level resilience: the right tool for a flaky network call inside a tool or adapter, not for node control flow.

Diagrams, Examples, and Outputs

The Archivist compose retry loop shows retry as visible topology: retry loops back, salvage routes to recovery, and success moves forward. The diagram is generated from the embedded DAG used by the runnable example pages:

Archivist compose retry loop

4 placements
DAG JSON-LD registered with the dispatcher
{
  "@context": {
    "@version": 1.1,
    "name": {
      "@id": "https://noocodec.dev/ontology/dag/name"
    },
    "version": {
      "@id": "https://noocodec.dev/ontology/dag/version"
    },
    "entrypoints": {
      "@id": "https://noocodec.dev/ontology/dag/entrypoints",
      "@container": "@index"
    },
    "nodes": {
      "@id": "https://noocodec.dev/ontology/dag/nodes",
      "@container": "@set"
    },
    "outputs": {
      "@id": "https://noocodec.dev/ontology/dag/outputs"
    },
    "node": {
      "@id": "https://noocodec.dev/ontology/dag/node"
    },
    "dag": {
      "@id": "https://noocodec.dev/ontology/dag/dag"
    },
    "body": {
      "@id": "https://noocodec.dev/ontology/dag/body"
    },
    "source": {
      "@id": "https://noocodec.dev/ontology/dag/source"
    },
    "sources": {
      "@id": "https://noocodec.dev/ontology/dag/sources",
      "@container": "@index"
    },
    "itemKey": {
      "@id": "https://noocodec.dev/ontology/dag/itemKey"
    },
    "execution": {
      "@id": "https://noocodec.dev/ontology/dag/execution"
    },
    "concurrency": {
      "@id": "https://noocodec.dev/ontology/dag/concurrency"
    },
    "throttle": {
      "@id": "https://noocodec.dev/ontology/dag/throttle"
    },
    "reservoir": {
      "@id": "https://noocodec.dev/ontology/dag/reservoir"
    },
    "gather": {
      "@id": "https://noocodec.dev/ontology/dag/gather"
    },
    "dagReference": {
      "@id": "https://noocodec.dev/ontology/dag/dagReference",
      "@type": "@id"
    },
    "DagReference": {
      "@id": "https://noocodec.dev/ontology/dag/DagReference"
    },
    "from": {
      "@id": "https://noocodec.dev/ontology/dag/from"
    },
    "path": {
      "@id": "https://noocodec.dev/ontology/dag/path"
    },
    "candidates": {
      "@id": "https://noocodec.dev/ontology/dag/candidates",
      "@container": "@set"
    },
    "candidateDag": {
      "@id": "https://noocodec.dev/ontology/dag/candidateDag",
      "@type": "@id"
    },
    "selectedDag": {
      "@id": "https://noocodec.dev/ontology/dag/selectedDag",
      "@type": "@id"
    },
    "resultField": {
      "@id": "https://noocodec.dev/ontology/dag/resultField"
    },
    "policy": {
      "@id": "https://noocodec.dev/ontology/dag/policy"
    },
    "reducer": {
      "@id": "https://noocodec.dev/ontology/dag/reducer"
    },
    "outcome": {
      "@id": "https://noocodec.dev/ontology/dag/outcome"
    },
    "phase": {
      "@id": "https://noocodec.dev/ontology/dag/phase"
    },
    "stateMapping": {
      "@id": "https://noocodec.dev/ontology/dag/stateMapping"
    },
    "container": {
      "@id": "https://noocodec.dev/ontology/dag/container"
    },
    "DAG": {
      "@id": "https://noocodec.dev/ontology/dag/DAG"
    },
    "Placement": {
      "@id": "https://noocodec.dev/ontology/dag/Placement"
    },
    "SingleNode": {
      "@id": "https://noocodec.dev/ontology/dag/SingleNode"
    },
    "ScatterNode": {
      "@id": "https://noocodec.dev/ontology/dag/ScatterNode"
    },
    "EmbeddedDAGNode": {
      "@id": "https://noocodec.dev/ontology/dag/EmbeddedDAGNode"
    },
    "GatherNode": {
      "@id": "https://noocodec.dev/ontology/dag/GatherNode"
    },
    "TerminalNode": {
      "@id": "https://noocodec.dev/ontology/dag/TerminalNode"
    },
    "PhaseNode": {
      "@id": "https://noocodec.dev/ontology/dag/PhaseNode"
    }
  },
  "@id": "urn:noocodec:dag:compose-retry-loop",
  "@type": "DAG",
  "name": "compose-retry-loop",
  "version": "1.1",
  "entrypoints": {
    "main": "urn:noocodec:dag:compose-retry-loop/node/compose-response"
  },
  "nodes": [
    {
      "@id": "urn:noocodec:dag:compose-retry-loop/node/compose-response",
      "@type": "SingleNode",
      "name": "compose-response",
      "node": "urn:noocodec:node:compose-response",
      "outputs": {
        "drafted": "urn:noocodec:dag:compose-retry-loop/node/validate-response",
        "retry": "urn:noocodec:dag:compose-retry-loop/node/compose-response",
        "salvage": "urn:noocodec:dag:compose-retry-loop/node/compose-salvage"
      }
    },
    {
      "@id": "urn:noocodec:dag:compose-retry-loop/node/compose-salvage",
      "@type": "SingleNode",
      "name": "compose-salvage",
      "node": "urn:noocodec:node:compose-salvage",
      "outputs": {
        "done": "urn:noocodec:dag:compose-retry-loop/node/composed"
      }
    },
    {
      "@id": "urn:noocodec:dag:compose-retry-loop/node/validate-response",
      "@type": "SingleNode",
      "name": "validate-response",
      "node": "urn:noocodec:node:validate-response",
      "outputs": {
        "approved": "urn:noocodec:dag:compose-retry-loop/node/composed",
        "retry": "urn:noocodec:dag:compose-retry-loop/node/compose-response",
        "exhausted": "urn:noocodec:dag:compose-retry-loop/node/composed"
      }
    },
    {
      "@id": "urn:noocodec:dag:compose-retry-loop/node/composed",
      "@type": "TerminalNode",
      "name": "composed",
      "outcome": "completed"
    }
  ]
}
Mermaid generated from the same DAG
Mermaid source
%%{init: {"flowchart":{"nodeSpacing":92,"rankSpacing":104,"padding":28}}}%%
flowchart TB
  %% compose-retry-loop (v1.1)
  entry_main(["main"])
  entry_main --> urn_noocodec_dag_compose-retry-loop/node/compose-response
  urn_noocodec_dag_compose-retry-loop/node/compose-response["compose-response"]
  urn_noocodec_dag_compose-retry-loop/node/compose-response -->|drafted| urn_noocodec_dag_compose-retry-loop/node/validate-response
  urn_noocodec_dag_compose-retry-loop/node/compose-response -->|retry| urn_noocodec_dag_compose-retry-loop/node/compose-response
  urn_noocodec_dag_compose-retry-loop/node/compose-response -->|salvage| urn_noocodec_dag_compose-retry-loop/node/compose-salvage
  urn_noocodec_dag_compose-retry-loop/node/compose-salvage["compose-salvage"]
  urn_noocodec_dag_compose-retry-loop/node/compose-salvage -->|done| urn_noocodec_dag_compose-retry-loop/node/composed
  urn_noocodec_dag_compose-retry-loop/node/validate-response["validate-response"]
  urn_noocodec_dag_compose-retry-loop/node/validate-response -->|approved| urn_noocodec_dag_compose-retry-loop/node/composed
  urn_noocodec_dag_compose-retry-loop/node/validate-response -->|retry| urn_noocodec_dag_compose-retry-loop/node/compose-response
  urn_noocodec_dag_compose-retry-loop/node/validate-response -->|exhausted| urn_noocodec_dag_compose-retry-loop/node/composed
  urn_noocodec_dag_compose-retry-loop/node/composed((("composed")))

What It Lets You Do

Use when

Use this guide when deciding between retry as visible DAG flow and retry as a runtime policy around one transient operation. The distinction keeps reviewer-visible control flow separate from provider/network resilience.

Code Samples

The snippets below show both sides of retry: visible retry/salvage edges in a real DAG and operation-level RetryPolicy configuration for transient calls.

Details for Nerds

Node retry as a flow shape

The attempt counter is built into NodeStateBase, the state every application extends, so any node can use it and it survives checkpoint/resume:

MethodPurpose
state.recordAttempt(key)Increment the counter for key; returns the new count.
state.retriesFor(key)Read the count (0 when never recorded).
state.withinRetryBudget(key, max)Record an attempt and report whether more remain: true → route retry, false → route salvage.
state.clearAttempts(key)Reset on success, so a re-entered placement starts fresh.

key is typically context.nodeName (the placement observability label), so each placement keeps its own budget. A node arms its own deadline, and on failure asks the budget which way to route:

ts
export class ExtractQueryNode extends MonadicNode<ArchivistState, 'success' | 'retry' | 'salvage'> {
  private readonly services: ArchivistServices;
  readonly name = 'extract-query';
  readonly '@id' = 'urn:noocodec:node:extract-query';
  constructor(services: ArchivistServices) {
    super();
    this.services = services;
  }
  readonly outputs = ['success', 'retry', 'salvage'] as const;
  override get outputSchema(): Record<'success' | 'retry' | 'salvage', SchemaObjectType> {
    return {
      'success': { 'type': 'object' },
      'retry':   { 'type': 'object' },
      'salvage': { 'type': 'object' },
    };
  }

  override async execute(batch: Batch<ArchivistState>, context: NodeContextType) {
    const successItems: ItemType<ArchivistState>[] = [];
    const retryItems: ItemType<ArchivistState>[] = [];
    const salvageItems: ItemType<ArchivistState>[] = [];

    for (const item of batch) {
      const { state } = item;
      const signal = Signal.compose({
        'deadlineMs': this.services.nodeTimeouts[context.nodeName] ?? NODE_TIMEOUT_MS,
        'signal':     context.signal,
      });
      try {
        const terms = await this.services.llm.extractTerms(state.query, signal);
        if (terms.length === 0) {
          if (state.withinRetryBudget(context.nodeName, RETRY_BUDGET)) {
            const result = NodeOutput.create('retry');
            for (const error of result.errors) state.collectError(error);
            retryItems.push(item);
          } else {
            state.clearAttempts(context.nodeName);
            const result = NodeOutput.create('salvage');
            for (const error of result.errors) state.collectError(error);
            salvageItems.push(item);
          }
          continue;
        }
        state.terms = terms;
        state.clearAttempts(context.nodeName);
        const result = NodeOutput.create('success');
        for (const error of result.errors) state.collectError(error);
        successItems.push(item);
      } catch (err) {
        // External cancellation / run deadline propagates unchanged.
        if (context.signal.aborted) throw err;
        // Node-local timeout or LLM failure -> retry budget decides the flow.
        if (state.withinRetryBudget(context.nodeName, RETRY_BUDGET)) {
          const result = NodeOutput.create('retry');
          for (const error of result.errors) state.collectError(error);
          retryItems.push(item);
        } else {
          state.clearAttempts(context.nodeName);
          const result = NodeOutput.create('salvage');
          for (const error of result.errors) state.collectError(error);
          salvageItems.push(item);
        }
      }
    }

    const routes: Array<readonly ['success' | 'retry' | 'salvage', Batch<ArchivistState>]> = [];
    if (successItems.length > 0) routes.push(['success', Batch.from(successItems)]);
    if (retryItems.length > 0) routes.push(['retry', Batch.from(retryItems)]);
    if (salvageItems.length > 0) routes.push(['salvage', Batch.from(salvageItems)]);
    return RoutedBatch.create(routes);
  }
}

The DAG closes the loop. The retry output is a self-edge back to the same placement; the salvage output routes to a deterministic recovery node that rejoins the happy path:

ts
.node(placement('extract-query'), extractQuery, {
  'success': placement('decide-tools'),
  'retry':   placement('extract-query'),          // flow-shape retry loop (self-edge)
  'salvage': placement('extract-query-salvage'),  // recovery route
}, display('extract-query'))
.node(placement('extract-query-salvage'), extractQuerySalvage, {
  'done': placement('decide-tools'),              // deterministic recovery rejoins the happy path
}, display('extract-query-salvage'))

The recovery computation (here, a naive whitespace term-split when the LLM extractor never answered) lives in extract-query-salvage, its own node reached by the salvage edge. Keeping it out of the producing node's catch is the point: execution (what a node computes) stays separate from flow decisioning (which edge the DAG takes), and an application can re-route or replace any recovery without touching the node that failed.

External cancellation is not a retry. When context.signal is already aborted, the node re-throws so the engine records the run as cancelled rather than looping.

The validator does no acyclic check, so the self-edge and the multi-node compose loop are both legal topologies. The renderers draw the loop edge directly.

RetryPolicy

RetryPolicy retries a thunk on declared error classes with a configurable backoff strategy. policy.run(task, { signal }) cooperates with the dispatcher's AbortSignal, so a cancelled flow stops cleanly mid-retry. Reach for it when a single operation (an HTTP fetch, an API round-trip) fails transiently and the right response is "try the same call again," not "re-route the flow." The adapters use it (via RetryableErrorPolicy) for rate-limited LLM API calls.

Example 07 constructs the policy at module scope so the configuration lives next to the operation it guards and no fresh instance is built per invocation. jitterFactor: 0 keeps the delay deterministic for the example:

ts
const policy = RetryPolicy.from({
  'maxAttempts':  5,
  'strategy':     BackoffStrategyNames.EXPONENTIAL,  // 50ms → 100ms → 200ms → …
  'baseDelay':    50,
  'jitterFactor': 0,                            // deterministic delays for testing
  'retryOn':      [TransientError],             // only retry on this error class
});

The node body calls policy.run(...), propagating context.signal:

ts
export class FetchNode extends MonadicNode<FetchState, 'success' | 'error'> {
  readonly name = 'fetch';
  readonly '@id' = 'urn:noocodec:node:fetch';
  readonly outputs = ['success', 'error'] as const;
  override get outputSchema(): Record<'success' | 'error', SchemaObjectType> {
    return { 'success': { 'type': 'object' }, 'error': { 'type': 'object' } };
  }

  override async execute(batch: Batch<FetchState>, context: NodeContextType) {
    const policy = RetryPolicy.from({
      'maxAttempts':  5,
      'strategy':     BackoffStrategyNames.EXPONENTIAL,  // 50ms → 100ms → 200ms → …
      'baseDelay':    50,
      'jitterFactor': 0,                            // deterministic delays for testing
      'retryOn':      [TransientError],             // only retry on this error class
    });
    const entries: Array<readonly ['success' | 'error', Batch<FetchState>]> = [];
    for (const item of batch) {
      const state = item.state;
      const downstream = new FlakyDownstream();
      try {
        // policy.run() re-invokes downstream.call() until it succeeds or
        // maxAttempts is reached. The options object passes context.signal so
        // an abort cancels the wait between retries immediately.
        state.result = await policy.run(() => downstream.call(), { signal: context.signal });
        const output = NodeOutput.create('success');
        entries.push([output.output, Batch.from([item])]);
      } catch {
        const output = NodeOutput.create('error');
        entries.push([output.output, Batch.from([item])]);
      }
    }
    return RoutedBatch.create(entries);
  }
}

Runtime wiring is the standard registerNode plus registerDAG pair:

ts
const dispatcher = new Dagonizer<FetchState>();
dispatcher.registerNode(new FetchNode());
dispatcher.registerDAG(dag);

const state = new FetchState();
await dispatcher.execute('urn:noocodec:dag:retry-dag', state);

// FlakyDownstream (inside FetchNode.execute) throws twice before succeeding.
// The stub is per-execution so attempts are scoped to the node invocation.
process.stdout.write('\nRetry DAG: fetch with EXPONENTIAL backoff\n');
process.stdout.write(`  result="${state.result}"\n`);
process.stdout.write('\nLesson: RetryPolicy.run(fn, { signal }) retries on declared error classes;\n');
process.stdout.write('        passing context.signal ensures abort short-circuits the delay.\n');

BackoffStrategy

BackoffStrategyType is a string union: 'constant' | 'linear' | 'exponential' | 'decorrelated-jitter'. Use the runtime string values directly, or reference the BackoffStrategyNames constants object (CONSTANT, LINEAR, EXPONENTIAL, DECORRELATED_JITTER) whose values resolve to those strings.

Constant keyRuntime string valueDelay formula
CONSTANT'constant'baseDelay (each attempt identical)
LINEAR'linear'baseDelay × attempt
EXPONENTIAL'exponential'baseDelay × multiplier^(attempt-1) (default)
DECORRELATED_JITTER'decorrelated-jitter'Random in [baseDelay, baseDelay × 3]

All strategies apply jitterFactor (default 0.1, plus or minus 10%) to spread retry traffic, except 'decorrelated-jitter' which is already random. The final delay is capped at maxDelay (default 30 s).

Error filtering

ts
export class NetworkError extends Error { constructor() { super('network'); } }
export class AuthError    extends Error { constructor() { super('auth');    } }

/** Policy that only retries NetworkError and never retries AuthError. */
export const filteredPolicy = RetryPolicy.from({
  maxAttempts: 5,
  strategy:    BackoffStrategyNames.EXPONENTIAL,
  retryOn:     [NetworkError],  // only retry these
  abortOn:     [AuthError],     // never retry these, even if listed in retryOn
});
// Precedence: abortOn wins over retryOn. If the error matches abortOn, no retry.

Precedence:

  1. If attempt >= maxAttempts, do not retry.
  2. If abortOn is set and the error matches, do not retry — an explicit abort list always wins, even against a DAGError that self-reports retryable: true.
  3. If retryOn is set, the error must match it to retry; a miss does not retry, even against a DAGError that self-reports retryable: true.
  4. If no retryOn filter is set and the error is a DAGError, retry only when error.retryable is true.
  5. Otherwise (no filters, non-DAGError error), retry.

A DAGError constructed with retryable: false (the schema default — see Reference: Errors) is therefore not retried unless an explicit retryOn matcher opts it back in, or the throw site passes retryable: true.

Abort cooperation

policy.run(task, { signal: context.signal }) checks the signal before each attempt. During a backoff wait, if the signal fires the wait resolves with the abort reason (thrown). A cancelled flow stops cleanly mid-retry:

ts
/** Drives a task under a RetryPolicy and propagates an AbortSignal.
 *  If the signal fires during a backoff wait, run() throws immediately
 *  rather than waiting for the next attempt window to expire.
 */
export class AbortRunner {
  static async run(task: () => Promise<string>, signal: AbortSignal): Promise<string> {
    const policy = RetryPolicy.from({ maxAttempts: 10, baseDelay: 1000 });
    // If signal aborts during a 1 s sleep, run() throws immediately.
    return policy.run(task, { signal });
  }
}

Custom backoff

Subclass RetryPolicy and override getDelay for non-standard curves:

ts
/** RetryPolicy subclass that spaces retries on the Fibonacci sequence (× 100 ms). */
export class FibonacciRetry extends RetryPolicy {
  constructor(options: RetryPolicyOptionsType = {}) {
    super(options);
  }

  override getDelay(attempt: number, _options: { readonly error?: Error | null } = {}): number {
    const fib = (n: number): number => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
    return Math.min(fib(attempt) * 100, this.maxDelay);
  }
}

Override shouldRetry to express conditional logic without modifying the constructor options.

Deterministic testing

Install VirtualScheduler before the policy run so retry sleeps do not block real wall time. Drive each backoff window with scheduler.advance(ms); restore real time with Clock.reset() and Scheduler.reset() when done:

ts
/**
 * 07-retry: RetryPolicy inside a node's execute().
 *
 * Demonstrates using RetryPolicy.run() to handle transient downstream
 * failures. The policy is constructed inside the node and cooperates
 * with the dispatcher's AbortSignal; if the DAG is cancelled mid-retry,
 * the policy propagates the abort rather than retrying again.
 *
 * Watch: the flaky downstream throws twice, then succeeds on attempt 3.
 * RetryPolicy with EXPONENTIAL backoff spaces out the retries; jitterFactor=0
 * makes the timing deterministic for the example output.
 *
 * DAG definition (state, flaky downstream, fetch node, dag): examples/dags/07-retry.ts
 *
 * Run: npx tsx examples/07-retry.ts
 */

import { BackoffStrategyNames, Dagonizer, RetryPolicy } from '@studnicky/dagonizer';
import { FetchState, FetchNode, FlakyDownstream, TransientError, dag } from './dags/07-retry.js';

// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------

// #region runtime
const dispatcher = new Dagonizer<FetchState>();
dispatcher.registerNode(new FetchNode());
dispatcher.registerDAG(dag);

const state = new FetchState();
await dispatcher.execute('urn:noocodec:dag:retry-dag', state);

// FlakyDownstream (inside FetchNode.execute) throws twice before succeeding.
// The stub is per-execution so attempts are scoped to the node invocation.
process.stdout.write('\nRetry DAG: fetch with EXPONENTIAL backoff\n');
process.stdout.write(`  result="${state.result}"\n`);
process.stdout.write('\nLesson: RetryPolicy.run(fn, { signal }) retries on declared error classes;\n');
process.stdout.write('        passing context.signal ensures abort short-circuits the delay.\n');
// #endregion runtime

// ---------------------------------------------------------------------------
// Elapsed-time verification: retry backoff runs on a real timer
// ---------------------------------------------------------------------------

// #region elapsed-time-verification
// `RetryPolicy` schedules its backoff delays through `@studnicky/retry`'s
// `Retry`, which sleeps on its own internal timer rather than the injected
// `Scheduler` — `VirtualScheduler.advance()` has no effect on retry timing.
// This run verifies the EXPONENTIAL backoff actually elapses real wall-clock
// time: baseDelay=50ms → waits of 50ms then 100ms (150ms total) before the
// third attempt succeeds.
const testPolicy = RetryPolicy.from({
  maxAttempts:  3,
  strategy:     BackoffStrategyNames.EXPONENTIAL,
  baseDelay:    50,
  jitterFactor: 0,
  retryOn:      [TransientError],
});
const testDownstream = new FlakyDownstream(); // throws twice, succeeds on third
const startedAt = Date.now();
const testResult = await testPolicy.run(() => testDownstream.call());
const elapsedMs = Date.now() - startedAt;
// #endregion elapsed-time-verification

process.stdout.write(`\nElapsed-time test: result="${testResult}" after ${String(testDownstream.attempts)} attempts, ${String(elapsedMs)}ms elapsed\n`);
process.stdout.write('Lesson: RetryPolicy backoff runs on a real timer owned by substrate\'s Retry;\n');
process.stdout.write('        VirtualScheduler.advance() no longer drives retry delays.\n');

See Testing for the full VirtualScheduler and VirtualClockProvider API.

Composing with adapter resilience

RetryPolicy, BaseAdapter's opt-in circuit breaker/token bucket, and DAGError.retryable are three independent failure-handling concerns. Each answers a different question, and BaseAdapter.chat() composes all three in one fixed order for its OWN internal handling:

  1. Circuit breaking (CircuitBreaker, outermost) — fail fast once a backend has failed enough times in a row. An open circuit rejects chat() with CircuitBreakerOpenError instantly, before anything else runs.
  2. Rate limiting (TokenBucket, next) — bound throughput. An exhausted bucket rejects chat() with TokenBucketExhaustedError, again before any attempt or retry.
  3. Retry (RetryableErrorPolicy, an internal RetryPolicy subclass, innermost) — re-run one transient failure with backoff, honoring each LlmError's classification.retryable flag.

This ordering means a call that is about to fail fast (open circuit, empty bucket) never burns a retry attempt or a rate-limit token it was never going to use — see Reference: Adapters for the field-level configuration (circuitBreaker/tokenBucket on BaseAdapterOptionsType).

An application-owned outer RetryPolicy is a different scenario. Wrapping the whole chat() call in your own retry policy (await outer.run(() => adapter.chat(request))) sits above all three internal mechanisms. When the circuit is open, chat() throws CircuitBreakerOpenError immediately — no internal retry is even attempted — and a naive outer RetryPolicy with no filters would retry that rejection anyway, hammering an already-open circuit maxAttempts times. List both resilience errors in abortOn to avoid this:

ts
import { RetryPolicy } from '@studnicky/dagonizer/runtime';
import { CircuitBreakerOpenError, TokenBucketExhaustedError } from '@studnicky/dagonizer/adapter';

const outer = RetryPolicy.from({
  maxAttempts: 3,
  abortOn: [CircuitBreakerOpenError, TokenBucketExhaustedError],
});

const response = await outer.run(() => adapter.chat(request));

This is documented guidance, not an enforced default. An application that wants an outer retry to keep probing through a half-open circuit can configure its policy differently. See Error filtering above for how abortOn composes with DAGError.retryable.

Choosing between them

Node retry (flow shape)RetryPolicy
GranularityA whole node's executionA single operation (thunk)
Where the loop livesThe DAG (a retry edge)Inside run(), invisible to the graph
Boundstate.withinRetryBudget(key, max)maxAttempts
On exhaustionRoutes salvage to a recovery nodeThrows the last error
Best forLLM/agent nodes whose failure is a flow decisionTransient network/API calls in a tool or adapter

Watched over by the Order of Dagon.