Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkgs/website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ export default defineConfig({
},
],
},
{
label: 'Conditional Steps',
autogenerate: { directory: 'build/conditional-steps/' },
},
{
label: 'Starting Flows',
autogenerate: { directory: 'build/starting-flows/' },
Expand Down
7 changes: 6 additions & 1 deletion pkgs/website/src/assets/pgflow-theme.d2
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ classes: {
style.stroke: "#e85c5c"
}

# Step state classes (created, started, completed, failed)
# Step state classes (created, started, completed, failed, skipped)
step_created: {
style.fill: "#95a0a3"
style.stroke: "#4a5759"
Expand All @@ -86,6 +86,11 @@ classes: {
style.fill: "#a33636"
style.stroke: "#e85c5c"
}
step_skipped: {
style.fill: "#4a5759"
style.stroke: "#6b7a7d"
style.stroke-dash: 3
}

# Task state classes (queued, completed, failed)
task_queued: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
---
title: Error Handling
description: Handle step failures gracefully with retriesExhausted option.
sidebar:
order: 3
---

import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';

The `retriesExhausted` option controls what happens when a step fails after exhausting all retry attempts. Instead of failing the entire run, you can mark the step as skipped and continue.

## Default Behavior

By default, when a step fails after all retries, the entire run fails:

```typescript
.step({
slug: 'sendNotification',
maxAttempts: 3,
// retriesExhausted: 'fail' // This is the default
}, async (input) => {
await sendEmail(input.run.email); // If this fails 3 times, run fails
})
```

## Graceful Failure with skip

Use `retriesExhausted: 'skip'` for non-critical steps that shouldn't block the workflow:

```typescript
.step({
slug: 'sendWelcomeEmail',
maxAttempts: 3,
retriesExhausted: 'skip', // If email fails, continue anyway
}, async (input) => {
return await sendEmail(input.run.email);
})
.step({
slug: 'createAccount',
dependsOn: ['sendWelcomeEmail'],
}, async (input) => {
// This runs even if email failed
// input.sendWelcomeEmail may be undefined
const emailSent = input.sendWelcomeEmail !== undefined;
return { accountId: await createUser(input.run), emailSent };
})
```

<Aside type="tip">
Use `retriesExhausted: 'skip'` for:
- Notifications (email, SMS, push)
- Analytics and tracking
- Optional enrichment
- Logging to external services

These shouldn't block your core business logic.

</Aside>

## Cascade Failure with skip-cascade

Use `retriesExhausted: 'skip-cascade'` when a failed step should skip its entire dependency chain:

```typescript
.step({
slug: 'fetchExternalData',
maxAttempts: 5,
retriesExhausted: 'skip-cascade', // Skip this + dependents on failure
}, async (input) => {
return await fetchFromUnreliableAPI(input.run.id);
})
.step({
slug: 'processExternalData',
dependsOn: ['fetchExternalData'],
}, processHandler) // Also skipped if fetch fails
.step({
slug: 'useLocalFallback',
// No dependency on external data
}, fallbackHandler) // This always runs
```

```d2 width="700" pad="20"
...@../../../../assets/pgflow-theme.d2

direction: right

fetch: "fetchExternalData"
fetch.class: step_skipped

process: "processExternalData"
process.class: step_skipped

fallback: "useLocalFallback"
fallback.class: step_completed

fetch -> process: "cascade skip" {
style.stroke-dash: 3
}

note: "fetch failed after 5 attempts\nprocess cascaded to skipped\nfallback runs independently" {
style.fill: transparent
style.stroke: transparent
}
```

## Type Safety

Like `whenUnmet`, the `retriesExhausted` option affects TypeScript types:

| Mode | Dependent Input Type |
| -------------- | --------------------------- |
| `fail` | `T` (required) |
| `skip` | `T \| undefined` (optional) |
| `skip-cascade` | `T` (required) |

```typescript
// With retriesExhausted: 'skip'
.step({
slug: 'optional',
retriesExhausted: 'skip',
}, () => ({ data: 'value' }))

.step({
slug: 'consumer',
dependsOn: ['optional'],
}, async (input) => {
// TypeScript knows input.optional may be undefined
if (input.optional) {
return processData(input.optional.data);
}
return processWithoutData();
})
```

## TYPE_VIOLATION: The Exception

Some errors indicate programming mistakes that should never be silently ignored. These **TYPE_VIOLATION** errors always fail the run, regardless of `retriesExhausted`:

```typescript
// This will ALWAYS fail the run, even with retriesExhausted: 'skip'
.step({
slug: 'fetchItems',
retriesExhausted: 'skip',
}, () => "not an array") // Returns string instead of array!
.map({
slug: 'processItems',
array: 'fetchItems', // Expects array, gets string
}, (item) => item * 2)
```

<Aside type="caution" title="TYPE_VIOLATION Errors">
These programming errors ALWAYS fail the run:
- A step returns a non-array value for a dependent `.map()` step
- Root `.map()` receives non-array flow input

These are bugs in your code, not runtime conditions. Fix the handler or input validation.

</Aside>

## Skip Reason for Failed Steps

When `retriesExhausted: 'skip'` triggers, the step gets:

- `status: 'skipped'`
- `skip_reason: 'handler_failed'`
- Original error preserved in `error_message`

Query failed-but-skipped steps:

```sql
SELECT step_slug, error_message, skip_reason
FROM pgflow.step_states
WHERE run_id = 'your-run-id'
AND skip_reason = 'handler_failed';
```

## Combining with Conditions

You can use both condition options and failure handling on the same step:

```typescript
.step({
slug: 'premiumNotification',
if: { plan: 'premium' }, // Only for premium users
whenUnmet: 'skip', // Skip if not premium
maxAttempts: 3,
retriesExhausted: 'skip', // Skip if notification fails
}, async (input) => {
return await sendPremiumEmail(input.run.email);
})
```

The step can be skipped for two different reasons:

1. `condition_unmet` - User isn't premium
2. `handler_failed` - Email service failed after 3 attempts

## Best Practices

### Do Use skip for Side Effects

```typescript
// Good: Notifications shouldn't block core logic
.step({
slug: 'sendSlackNotification',
maxAttempts: 2,
retriesExhausted: 'skip',
}, notifySlack)
```

### Don't Use skip for Critical Steps

```typescript
// Bad: Payment processing should never silently fail
.step({
slug: 'chargePayment',
maxAttempts: 3,
retriesExhausted: 'skip', // Don't do this!
}, chargeCard)
```

### Use skip-cascade for Feature Branches

```typescript
// Good: If enrichment source is down, skip all enrichment
.step({
slug: 'fetchEnrichmentData',
retriesExhausted: 'skip-cascade',
}, fetchEnrichment)
.step({
slug: 'applyEnrichment',
dependsOn: ['fetchEnrichmentData'],
}, applyEnrichment) // Skipped if fetch failed
```

## Learn More

<CardGrid>
<LinkCard
title="Retrying Steps"
href="/build/retrying-steps/"
description="Configure retry policies and understand failure types"
/>
<LinkCard
title="Skip Modes"
href="/build/conditional-steps/skip-modes/"
description="Understand fail, skip, and skip-cascade behaviors"
/>
<LinkCard
title="Pattern Matching"
href="/build/conditional-steps/pattern-matching/"
description="Use if/ifNot conditions with JSON containment patterns"
/>
</CardGrid>
Loading
Loading