> ## Documentation Index
> Fetch the complete documentation index at: https://docs.output.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> How workflow and activity interceptors classify errors, when execution retries or stops, and how to match causes with hasErrorType

When something throws in your workflow, Output handles it differently depending on where it happens. Steps and evaluators are retried automatically. In workflow code, only explicit fatal failures end the run immediately — other throws retry the Workflow Task. You can control this with retry options, non-retryable errors, and try/catch.

## How interceptors classify errors

Output's workflow and activity interceptors decide whether a failure ends the execution, retries, or is rethrown as Temporal already classified it.

### Workflow

| Error                                                                                  | Behavior                                                                                                                                                                                                               |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ContinueAsNew`                                                                        | Ends the trace as continued-as-new and rethrows                                                                                                                                                                        |
| Cancellation (`isCancellation`)                                                        | Sinks a serialized error (without `stack`) and rethrows                                                                                                                                                                |
| `FatalError` / `ValidationError` / `TransparentFatalError`                             | Unwraps `TransparentFatalError` to its cause, sinks the serialized error, and throws a non-retryable `ApplicationFailure` with the serialized error in `.details[0].error` — the workflow **fails**                    |
| Temporal failures (`ActivityFailure`, `ChildWorkflowFailure`, other `TemporalFailure`) | Sinks the serialized error and rethrows unchanged. (`CancelledFailure` usually matches `isCancellation` first.)                                                                                                        |
| Other errors (`TypeError`, bad `JSON.parse`, unexpected throws)                        | Rethrown unchanged **without** sinking — the **Workflow Task retries** (or stays open until timeout). The CLI can look "stuck" until the task succeeds, times out, or you throw `FatalError` / an `ApplicationFailure` |

### Activity (steps and evaluators)

| Error                                                      | Behavior                                                                                                                                                                                                                                                                      |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CompleteAsyncError`                                       | Recorded as an async handoff in the trace (not an error). No activity error hook. Rethrown                                                                                                                                                                                    |
| Temporal failures (`TemporalFailure`)                      | Activity error hook and trace error fire, then rethrown unchanged                                                                                                                                                                                                             |
| `FatalError` / `ValidationError` / `TransparentFatalError` | Activity error hook and trace error fire (using the unwrapped cause for `TransparentFatalError`), then converted to `ApplicationFailure` with serialized error in `.details[0].error`. Always `nonRetryable: true`. User `nonRetryableErrorTypes` cannot make these retryable |
| Other errors                                               | Activity error hook and trace error fire, then converted to `ApplicationFailure` with serialized error in `.details[0].error`. `nonRetryable` when the error class name is listed in the activity retry policy's `nonRetryableErrorTypes`                                     |

`ValidationError` and `TransparentFatalError` both extend `FatalError`, so they share the fatal rows above. `TransparentFatalError` is only a transport: interceptors use its `.cause` for logs, traces, hooks, and failure details. See [FatalError, ValidationError, and TransparentFatalError](#fatalerror-validationerror-and-transparentfatalerror) for when to throw each.

## Steps and Evaluators

When a step or evaluator throws, we retry it automatically with exponential backoff. After all retries are exhausted, the error propagates to the workflow (typically as an `ActivityFailure` cause chain).

Default retry behavior:

| Setting                  | Default          |
| ------------------------ | ---------------- |
| `initialInterval`        | 10s              |
| `backoffCoefficient`     | 2.0              |
| `maximumAttempts`        | 3                |
| `nonRetryableErrorTypes` | `['FatalError']` |

This means a failing step waits 10s, then 20s, then fails for good. You can override these per step via the `options.activityOptions` property — see [Step Options](/steps#options). Custom `nonRetryableErrorTypes` are added alongside `FatalError`; Output always keeps `FatalError` non-retryable.

## Workflows

Errors in the workflow `fn` itself are classified as in the table above. Throw `FatalError` or `ValidationError` when the run must fail immediately. Ordinary bugs (for example a bad `JSON.parse`) retry the Workflow Task instead of failing the execution right away.

You can catch step errors with try/catch and handle them however you want:

```typescript workflow.ts theme={null}
import { workflow } from '@outputai/core';
import { lookupCompany, generateSummary } from './steps.js';
import { EnrichmentInput, EnrichmentOutput } from './types.js';

export default workflow({
  name: 'lead_enrichment',
  inputSchema: EnrichmentInput,
  outputSchema: EnrichmentOutput,
  fn: async (input) => {
    const company = await lookupCompany(input.companyDomain);

    let summary;
    try {
      summary = await generateSummary(company);
    } catch (error) {
      // Step failed after all retries — use a fallback
      summary = `${company.name} is a company in the ${company.industry} industry.`;
    }

    return { company: company.name, summary };
  }
});

// types.ts
// import { z } from '@outputai/core';
//
// export const EnrichmentInput = z.object({
//   companyDomain: z.string()
// });
//
// export const EnrichmentOutput = z.object({
//   company: z.string(),
//   summary: z.string()
// });
```

If you don't catch a step failure, it surfaces to the workflow as a Temporal failure and the workflow fails (unless you handle it).

### Checking error types in workflows

Errors thrown by steps and evaluators cross Temporal's Activity boundary before they reach workflow code. Temporal serializes them into a failure cause chain, so a direct `error instanceof CustomError` check is not reliable inside a workflow. The same applies when Output wraps a failure in `ApplicationFailure` or `TransparentFatalError` — the error you care about is often on `.cause`, not the outer object.

Use `hasErrorType(error, CustomError)` from `@outputai/core` to walk the complete cause chain. It matches native instances as well as Temporal's serialized `type` and `name` fields.

Define the custom error in a shared module imported by both the step and workflow:

```typescript types.ts theme={null}
export class CompanyNotFoundError extends Error {}
```

```typescript steps.ts theme={null}
import { step } from '@outputai/core';
import { CompanyNotFoundError } from './types.js';

export const lookupCompany = step({
  name: 'lookup_company',
  fn: async () => {
    throw new CompanyNotFoundError('Company not found');
  }
});
```

```typescript workflow.ts theme={null}
import { hasErrorType, workflow } from '@outputai/core';
import { lookupCompany } from './steps.js';
import { CompanyNotFoundError } from './types.js';

export default workflow({
  name: 'company_lookup',
  fn: async () => {
    try {
      return await lookupCompany();
    } catch (error) {
      if (hasErrorType(error, CompanyNotFoundError)) {
        return null;
      }

      // Preserve unexpected failures.
      throw error;
    }
  }
});
```

Always rethrow errors that do not match the type the workflow explicitly handles.

### Error metadata

Trace destinations are stored in Temporal memo and exposed as the workflow result's `trace` field. They are available independently of whether the workflow succeeds or fails.

Current API responses expose failures as a serialized `error` object with `name`, `message`, and any additional diagnostic properties captured from the original error. Activity failures also include `activityType` when available.

If you inspect Temporal errors directly, workflow failures remain native Temporal errors such as `ApplicationFailure` or `ChildWorkflowFailure`. Output may store the serialized original error under `details[0].error` on a nested failure in the `.cause` chain.

```typescript theme={null}
function getSerializedWorkflowError(error: unknown) {
  let current = error as { cause?: unknown; details?: unknown[] } | null;

  while (current) {
    const detail = current.details?.find((value) =>
      value &&
      typeof value === 'object' &&
      'error' in value
    );

    if (detail && typeof detail === 'object' && 'error' in detail) {
      return detail.error;
    }

    current = current.cause as typeof current;
  }

  return null;
}
```

## FatalError, ValidationError, and TransparentFatalError

Sometimes retrying won't help — the API key is invalid, the resource doesn't exist, or the data is fundamentally wrong. For these cases, throw `FatalError` or `ValidationError` to fail immediately without retries.

`ValidationError` and `TransparentFatalError` extend `FatalError`. The entire `FatalError` family is always non-retryable and cannot be made retryable through activity options. Throw a regular or custom `Error` when retry configuration should control the behavior.

| Error                   | Use when                                                                                                                               |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `FatalError`            | The failure is not recoverable. Retrying would not help (e.g. invalid API key, resource not found, business rule violation).           |
| `ValidationError`       | The data is invalid. Use for custom validation checks alongside the framework's built-in schema validation.                            |
| `TransparentFatalError` | An existing error must be non-retryable, but its original type, message, cause chain, and diagnostic properties should remain visible. |

All three are exported from `@outputai/core`:

```typescript steps.ts theme={null}
import { step, FatalError, ValidationError } from '@outputai/core';
import { getContact } from '../../clients/hubspot.js';
import { LookupContactInput, LookupContactOutput } from './types.js';

export const lookupContact = step({
  name: 'lookupContact',
  description: 'Look up a contact in HubSpot',
  inputSchema: LookupContactInput,
  outputSchema: LookupContactOutput,
  fn: async (email) => {
    const contact = await getContact(email);

    if (contact.error === 'INVALID_API_KEY') {
      // Don't retry — the key won't fix itself
      throw new FatalError('HubSpot API key is invalid');
    }

    if (!contact.properties?.email) {
      // Data doesn't match what we need
      throw new ValidationError('Contact has no email address');
    }

    return {
      exists: true,
      contactId: contact.id,
      lifecycleStage: contact.properties.lifecyclestage
    };
  }
});
```

`TransparentFatalError` accepts an existing `Error` as its cause. It has the same non-retryable behavior as `FatalError`, but Output removes the wrapper when reporting or propagating the failure. Logs, traces, hooks, and workflow results therefore show the original error rather than a generic fatal-error message.

```typescript theme={null}
import { TransparentFatalError } from '@outputai/core';

try {
  await callProvider();
} catch (error) {
  if (error instanceof ProviderError && !error.isRetryable) {
    throw new TransparentFatalError(error);
  }
  throw error;
}
```

## Schema Validation

The framework validates inputs and outputs automatically using your Zod schemas. When validation fails, it throws a `ValidationError` with a message that tells you exactly what went wrong.

**Steps and evaluators:** Input is validated against `inputSchema` before `fn` runs. The return value is validated against `outputSchema` after `fn` returns. If either fails, the step fails immediately (no retries).

**Workflows:** Input is validated against `inputSchema` before the workflow starts. Output is validated against `outputSchema` after the workflow returns. If either fails, the execution fails.

The error message includes context like `"Step lookupContact input validation failed: ..."` so you can trace exactly where and why validation failed.
