| name | output-error-try-catch |
| description | Fix try-catch anti-pattern in Output SDK workflows. Use when retries aren't working, errors are being swallowed, seeing unexpected FatalError wrapping, or when step failures don't trigger retry policies. |
| allowed-tools | ["Bash","Read"] |
Fix Try-Catch Anti-Pattern
Overview
This skill helps diagnose and fix a common anti-pattern where step calls are wrapped in try-catch blocks. This prevents Output SDK's retry mechanism from working properly and can lead to confusing error behavior.
When to Use This Skill
You're seeing:
- Retries not working as expected
- Errors being swallowed silently
- Unexpected FatalError wrapping
- Step failures not triggering retry policies
- Errors being caught and re-thrown incorrectly
Root Cause
When you wrap step calls in try-catch blocks, you intercept errors before the Output SDK retry mechanism can handle them. This defeats the built-in retry logic and can cause:
- Retries not happening: The error is caught, so the framework doesn't know to retry
- Wrong error classification: Re-throwing as FatalError prevents retries entirely
- Lost error context: Original error details may be lost in the catch block
Symptoms
Pattern 1: Errors Swallowed
try {
const result = await myStep( input );
} catch ( error ) {
console.log( 'Step failed' );
return { success: false };
}
Pattern 2: FatalError Wrapping
try {
const result = await myStep( input );
} catch ( error ) {
throw new FatalError( error.message );
}
Pattern 3: Re-throwing Generic Errors
try {
const result = await myStep( input );
} catch ( error ) {
throw new Error( `Step failed: ${error.message}` );
}
Solution
Let failures propagate naturally. Remove try-catch blocks around step calls and let the Output SDK handle errors:
Before (Wrong)
export default workflow( {
fn: async input => {
try {
const data = await fetchDataStep( input );
const result = await processDataStep( data );
return result;
} catch ( error ) {
throw new FatalError( error.message );
}
}
} );
After (Correct)
export default workflow( {
fn: async input => {
const data = await fetchDataStep( input );
const result = await processDataStep( data );
return result;
}
} );
When Try-Catch IS Appropriate
There are limited cases where catching errors in workflows is valid:
1. Optional/Fallback Steps
When a step failure should trigger an alternative path:
export default workflow( {
fn: async input => {
const data = await ( async () => {
try {
return await fetchFromPrimarySource( input );
} catch {
return await fetchFromSecondarySource( input );
}
} )();
return await processData( data );
}
} );
For readability, you can extract the fallback logic into a named helper function instead of using an IIFE:
const fetchWithFallback = async input => {
try {
return await fetchFromPrimarySource( input );
} catch {
return await fetchFromSecondarySource( input );
}
};
export default workflow( {
fn: async input => {
const data = await fetchWithFallback( input );
return await processData( data );
}
} );
2. Aggregate Results with Partial Failures
When processing multiple items where some may fail:
export default workflow( {
fn: async input => {
const results = [];
for ( const item of input.items ) {
try {
const result = await processItem( item );
results.push( { item, result, success: true } );
} catch ( error ) {
results.push( { item, error: error.message, success: false } );
}
}
return results;
}
} );
Note: Even in these cases, be careful not to swallow errors that should cause the whole workflow to fail.
Finding Try-Catch Around Steps
Search for the pattern:
grep -rn "try {" src/workflows/
grep -rn "FatalError" src/workflows/
Then review each match to see if it's wrapping step calls.
How Retries Work
When you DON'T catch errors:
- Step throws an error
- Output SDK receives the error
- SDK checks retry policy (configured per step)
- If retries remain, step is re-executed
- If retries exhausted, workflow fails with full error context
When you DO catch errors:
- Step throws an error
- Your catch block handles it
- Output SDK never sees the original error
- Retry logic is bypassed
- You control what happens (often incorrectly)
Configuring Retry Behavior
Instead of try-catch, configure retry policies on steps:
export const fetchData = step( {
name: 'fetchData',
retry: {
maxAttempts: 3,
initialInterval: '1s',
maxInterval: '30s',
backoffCoefficient: 2
},
fn: async input => {
return await callApi( input );
}
} );
Using FatalError Correctly
FatalError is for errors that should NEVER be retried:
export const validateInput = step( {
name: 'validateInput',
fn: async input => {
if ( !input.userId ) {
throw new FatalError( 'userId is required' );
}
return input;
}
} );
Do NOT use FatalError to wrap other errors unless you're certain they shouldn't retry.
Verification
After removing try-catch:
- Test normal operation:
npx output workflow run <name> --input '<valid-input>'
- Test failure scenarios: Use input that causes step failures
- Check retry behavior: Look for retry attempts in
npx output workflow debug <id>
Related Issues
- For configuring retry policies, see step definition documentation
- For handling expected failures gracefully, consider using conditional logic instead of try-catch