Skip to content

withTryCatch

Structured try / catch / finally blocks for flow steps. Wraps one or more steps in an exception-safe block without reaching for top-level error handlers.

Setup

typescript
import { FlowBuilder } from "flowneer";
import { withTryCatch } from "flowneer/plugins/resilience";

const AppFlow = FlowBuilder.extend([withTryCatch]);

Usage

typescript
import { FlowBuilder, fragment } from "flowneer";
import { withTryCatch } from "flowneer/plugins/resilience";

const AppFlow = FlowBuilder.extend([withTryCatch]);

const flow = new AppFlow<State>()
  .try(fragment<State>().then(fetchData).then(processData))
  .catch(
    fragment<State>().then((s) => {
      console.error("Pipeline failed:", s.__tryError);
      s.result = "fallback";
    }),
  )
  .finally(fragment<State>().then(cleanup))
  .then(sendResult);

API

.try(fragment)

Executes all steps in fragment. If any step throws, control passes to the .catch() fragment (if registered), or the error propagates.

.catch(fragment)

Handles an error thrown inside the preceding .try(). The error is available on shared.__tryError before the fragment runs and is removed once the fragment completes.

If the catch fragment also throws, the error propagates (and the .finally() fragment still runs).

.finally(fragment)

Always runs after the .try() (and optional .catch()), regardless of success or failure. Calling .finally() closes the try/catch block.

Note: .catch() and .finally() must be called immediately after .try() — no other .then() or builder calls can appear between them.

InterruptError: InterruptError (thrown by withHumanNode and flow abort signals) is never caught by .try()/.catch() — it propagates immediately to the caller just like it would outside a try block.

__tryError context

The caught error is stored on shared.__tryError inside the catch fragment:

typescript
.catch(
  fragment<State>().then((s) => {
    const err = s.__tryError; // original Error or value that was thrown

    if (err instanceof Error) {
      s.errorMessage = err.message;
    }

    s.usedFallback = true;
  }),
)

__tryError is always the original thrown value. If Flowneer wrapped it in a FlowError, the unwrapped cause is exposed here.

Nested blocks

Try/catch blocks can be nested:

typescript
const AppFlow = FlowBuilder.extend([withTryCatch]);

const flow = new AppFlow<State>()
  .try(
    fragment<State>()
      .try(fragment<State>().then(riskyInner))
      .catch(
        fragment<State>().then((s) => {
          s.innerFailed = true;
        }),
      )
      .then(continueFrag),
  )
  .catch(
    fragment<State>().then((s) => {
      s.outerFailed = true;
    }),
  );

Example — fetch with recovery

typescript
import { FlowBuilder, fragment } from "flowneer";
import { withTryCatch } from "flowneer/plugins/resilience";

const AppFlow = FlowBuilder.extend([withTryCatch]);

interface State {
  userId: string;
  profile: Record<string, unknown> | null;
  fromCache: boolean;
}

const flow = new AppFlow<State>()
  .try(
    fragment<State>().then(async (s) => {
      const res = await fetch(`/api/users/${s.userId}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      s.profile = await res.json();
    }),
  )
  .catch(
    fragment<State>().then(async (s) => {
      console.warn("Live fetch failed, loading from cache:", s.__tryError);
      s.profile = await loadFromCache(s.userId);
      s.fromCache = true;
    }),
  )
  .then((s) => {
    console.log("Profile ready, fromCache:", s.fromCache);
  });