Skip to main content
Workflow errors are error types you can throw within your workflow and step functions to control execution flow and retry behavior. These errors give you fine-grained control over how failures are handled.

FatalError

A fatal error stops retries immediately and fails the step. Use this when you encounter a permanent failure condition that won’t succeed on retry.

When to Use FatalError

  • Resource not found - API returns 404
  • Invalid input - Validation failures
  • Permission denied - Authentication/authorization errors (401, 403)
  • Business logic violations - Insufficient funds, duplicate orders
  • Configuration errors - Missing required environment variables

Basic Usage

import { FatalError } from 'workflow';

async function getUser(userId: string) {
  "use step";
  
  const response = await fetch(`https://api.example.com/users/${userId}`);
  
  if (response.status === 404) {
    throw new FatalError('User not found');
  }
  
  return response.json();
}

API Reference

new FatalError(message: string)
Parameters:
  • message (string) - Error message describing the failure
Properties:
  • name - Always "FatalError"
  • message - The error message
  • fatal - Always true
  • stack - Stack trace
Methods:
  • FatalError.is(value) - Type guard to check if value is a FatalError

Examples

import { FatalError } from 'workflow';

interface OrderInput {
  userId: string;
  items: string[];
  total: number;
}

async function createOrder(input: OrderInput) {
  "use step";
  
  // Validate input
  if (!input.userId || input.userId.trim() === '') {
    throw new FatalError('userId is required');
  }
  
  if (!input.items || input.items.length === 0) {
    throw new FatalError('Order must contain at least one item');
  }
  
  if (input.total <= 0) {
    throw new FatalError('Order total must be greater than zero');
  }
  
  // Create order...
  const order = await saveOrder(input);
  return order;
}
import { FatalError } from 'workflow';

async function processDocument(documentId: string) {
  "use step";
  
  const response = await fetch(
    `https://api.example.com/documents/${documentId}`
  );
  
  if (response.status === 404) {
    throw new FatalError(`Document ${documentId} not found`);
  }
  
  if (response.status === 403) {
    throw new FatalError('Permission denied to access document');
  }
  
  return response.json();
}
import { FatalError } from 'workflow';

async function chargePayment(userId: string, amount: number) {
  "use step";
  
  const account = await getAccount(userId);
  
  if (account.balance < amount) {
    throw new FatalError(
      `Insufficient funds. Balance: ${account.balance}, Required: ${amount}`
    );
  }
  
  // Check for duplicate transaction
  const existingCharge = await findExistingCharge(userId, amount);
  if (existingCharge) {
    throw new FatalError('Duplicate charge detected');
  }
  
  return await processCharge(account, amount);
}
import { FatalError } from 'workflow';

export async function processWorkflow(input: unknown) {
  "use workflow";
  
  try {
    await riskyStep();
  } catch (error) {
    if (FatalError.is(error)) {
      // This is a fatal error - don't retry the workflow
      console.error('Fatal error occurred:', error.message);
      throw error;
    }
    // Other errors - might be retryable
    console.warn('Retryable error:', error);
    throw error;
  }
}

RetryableError

A retryable error allows you to customize when a step should be retried. Use this when you know how long to wait before the operation might succeed.

When to Use RetryableError

  • Rate limiting - API returns 429 with Retry-After header
  • Temporary unavailability - Service is temporarily down (503)
  • Backpressure - System is overloaded but will recover
  • Exponential backoff - Progressive retry delays
  • Scheduled retries - Retry at a specific time

Basic Usage

import { RetryableError } from 'workflow';

async function callAPI(endpoint: string) {
  "use step";
  
  const response = await fetch(endpoint);
  
  if (response.status === 429) {
    throw new RetryableError('Rate limited', {
      retryAfter: '1m' // Retry after 1 minute
    });
  }
  
  return response.json();
}

API Reference

new RetryableError(message: string, options?: RetryableErrorOptions)
Parameters:
  • message (string) - Error message describing the failure
  • options (object, optional) - Retry configuration
    • retryAfter (number | string | Date, optional) - When to retry
      • Number: milliseconds
      • String: duration (e.g., "5s", "2m", "1h")
      • Date: specific time to retry
      • Default: 1 second (1000ms)
Properties:
  • name - Always "RetryableError"
  • message - The error message
  • retryAfter - Date when the step should be retried
  • stack - Stack trace
Methods:
  • RetryableError.is(value) - Type guard to check if value is a RetryableError

Examples

import { RetryableError } from 'workflow';

async function callRateLimitedAPI(endpoint: string) {
  "use step";
  
  const response = await fetch(endpoint);
  
  if (response.status === 429) {
    // Check for Retry-After header
    const retryAfter = response.headers.get('Retry-After');
    
    if (retryAfter) {
      // Retry-After can be seconds or HTTP date
      const retrySeconds = parseInt(retryAfter, 10);
      
      if (!isNaN(retrySeconds)) {
        // Retry after N seconds
        throw new RetryableError('Rate limited by API', {
          retryAfter: retrySeconds * 1000 // Convert to ms
        });
      } else {
        // Retry at specific date
        throw new RetryableError('Rate limited by API', {
          retryAfter: new Date(retryAfter)
        });
      }
    }
    
    // Default retry after 1 minute
    throw new RetryableError('Rate limited by API', {
      retryAfter: '1m'
    });
  }
  
  return response.json();
}
import { RetryableError, getStepMetadata } from 'workflow';

async function unreliableOperation() {
  "use step";
  
  const metadata = getStepMetadata();
  
  try {
    const response = await fetch('https://api.example.com/data');
    
    if (!response.ok) {
      throw new Error('Request failed');
    }
    
    return response.json();
  } catch (error) {
    // Exponential backoff: 1s, 4s, 16s, 64s, 256s
    const delayMs = (metadata.attempt ** 2) * 1000;
    
    throw new RetryableError(
      `Attempt ${metadata.attempt} failed, retrying...`,
      { retryAfter: delayMs }
    );
  }
}

// Allow up to 5 retries (6 total attempts)
unreliableOperation.maxRetries = 5;
import { RetryableError, FatalError } from 'workflow';

async function callExternalService(endpoint: string) {
  "use step";
  
  const response = await fetch(endpoint);
  
  if (response.status === 503) {
    // Service temporarily unavailable
    throw new RetryableError('Service unavailable', {
      retryAfter: '30s'
    });
  }
  
  if (response.status === 500) {
    // Server error - might recover
    throw new RetryableError('Server error', {
      retryAfter: '10s'
    });
  }
  
  if (response.status === 404) {
    // Permanent failure
    throw new FatalError('Endpoint not found');
  }
  
  return response.json();
}
import { RetryableError } from 'workflow';

async function checkBusinessHours() {
  "use step";
  
  const now = new Date();
  const hour = now.getHours();
  
  // Business hours: 9 AM - 5 PM
  if (hour < 9 || hour >= 17) {
    // Calculate next 9 AM
    const tomorrow = new Date(now);
    tomorrow.setDate(tomorrow.getDate() + 1);
    tomorrow.setHours(9, 0, 0, 0);
    
    throw new RetryableError('Outside business hours', {
      retryAfter: tomorrow
    });
  }
  
  // Continue processing during business hours
  return await processBusinessLogic();
}
import { RetryableError } from 'workflow';

// All valid duration formats:
throw new RetryableError('Retry needed', {
  retryAfter: '5s'    // 5 seconds
});

throw new RetryableError('Retry needed', {
  retryAfter: '2m'    // 2 minutes
});

throw new RetryableError('Retry needed', {
  retryAfter: '1h'    // 1 hour
});

throw new RetryableError('Retry needed', {
  retryAfter: '24h'   // 24 hours
});

throw new RetryableError('Retry needed', {
  retryAfter: 5000    // 5000 milliseconds (5 seconds)
});

throw new RetryableError('Retry needed', {
  retryAfter: new Date('2026-03-04T10:00:00Z') // Specific time
});

Combining FatalError and RetryableError

Use both error types together to handle different failure scenarios appropriately:
import { FatalError, RetryableError, getStepMetadata } from 'workflow';

async function robustAPICall(endpoint: string) {
  "use step";
  
  const metadata = getStepMetadata();
  
  try {
    const response = await fetch(endpoint);
    
    // Permanent failures - don't retry
    if (response.status === 400) {
      throw new FatalError('Bad request - invalid input');
    }
    if (response.status === 401) {
      throw new FatalError('Unauthorized - check credentials');
    }
    if (response.status === 403) {
      throw new FatalError('Forbidden - insufficient permissions');
    }
    if (response.status === 404) {
      throw new FatalError('Resource not found');
    }
    
    // Temporary failures - retry with backoff
    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      throw new RetryableError('Rate limited', {
        retryAfter: retryAfter ? `${retryAfter}s` : '60s'
      });
    }
    
    if (response.status >= 500) {
      // Exponential backoff for server errors
      throw new RetryableError('Server error', {
        retryAfter: (metadata.attempt ** 2) * 1000
      });
    }
    
    return response.json();
  } catch (error) {
    // Network errors are retryable
    if (error instanceof TypeError && error.message.includes('fetch')) {
      throw new RetryableError('Network error', {
        retryAfter: '5s'
      });
    }
    
    // Re-throw FatalError and RetryableError as-is
    throw error;
  }
}

robustAPICall.maxRetries = 5;

Best Practices

  1. Use FatalError for permanent failures
    • Don’t waste retries on errors that will never succeed
    • Examples: validation errors, 404s, permission errors
  2. Use RetryableError for temporary failures
    • Respect rate limits and backpressure signals
    • Use appropriate delays based on the error type
  3. Set appropriate maxRetries
    // For operations that should retry aggressively
    importantOperation.maxRetries = 10;
    
    // For operations that should fail fast
    quickOperation.maxRetries = 1;
    
    // To disable retries entirely
    noRetryOperation.maxRetries = 0;
    
  4. Implement exponential backoff
    • Use getStepMetadata() to access attempt number
    • Increase delay with each attempt
  5. Make steps idempotent
    • Steps may execute multiple times
    • Ensure repeated execution is safe
    • See Idempotency Guide
  6. Include helpful error messages
    // Good - includes context
    throw new FatalError(`User ${userId} not found in database`);
    
    // Bad - lacks context
    throw new FatalError('Not found');
    

Build docs developers (and LLMs) love