DEV Community

Wilson Xu
Wilson Xu

Posted on

Error Handling Patterns Every CLI Tool Needs

Error Handling Patterns Every CLI Tool Needs

Error handling in CLI tools is fundamentally different from error handling in web applications. There's no retry button. No error boundary component. No loading spinner to buy time. When your CLI fails, the user sees a stack trace, a cryptic message, or nothing at all — and they lose trust in your tool.

Good error handling in CLI tools means three things: the user understands what went wrong, they know how to fix it, and your CI pipeline gets the right exit code. Let's build all three.

The Three Audiences for Errors

Every CLI error serves three audiences simultaneously:

  1. The human in a terminal — needs a clear, actionable message
  2. The CI pipeline — needs a non-zero exit code
  3. The developer debugging — needs details when --verbose is on
class CliError extends Error {
  constructor(message, { code = 1, suggestion, details } = {}) {
    super(message);
    this.code = code;
    this.suggestion = suggestion;
    this.details = details;
  }
}

function handleError(error, options = {}) {
  if (error instanceof CliError) {
    // Human-readable message
    console.error(chalk.red(`Error: ${error.message}`));

    // Actionable suggestion
    if (error.suggestion) {
      console.error(chalk.yellow(`  Suggestion: ${error.suggestion}`));
    }

    // Verbose details for debugging
    if (options.verbose && error.details) {
      console.error(chalk.gray(`\n  Details: ${error.details}`));
    }

    process.exit(error.code);
  }

  // Unexpected errors: show stack trace
  console.error(chalk.red('Unexpected error:'));
  console.error(error.stack);
  process.exit(99);
}
Enter fullscreen mode Exit fullscreen mode

Pattern 1: Validate Before You Execute

Don't let your tool get halfway through a task before discovering the input was bad:

function validateInputs(options) {
  const errors = [];

  if (!options.url) {
    errors.push(new CliError('URL is required', {
      suggestion: 'Usage: mytool audit <url>'
    }));
  } else if (!isValidUrl(options.url)) {
    errors.push(new CliError(`Invalid URL: "${options.url}"`, {
      suggestion: 'URLs must start with http:// or https://'
    }));
  }

  if (options.threshold !== undefined) {
    if (options.threshold < 0 || options.threshold > 100) {
      errors.push(new CliError(`Threshold must be 0-100, got ${options.threshold}`, {
        suggestion: 'Example: --threshold 85'
      }));
    }
  }

  if (options.output) {
    const dir = path.dirname(options.output);
    if (!fs.existsSync(dir)) {
      errors.push(new CliError(`Output directory doesn't exist: ${dir}`, {
        suggestion: `Run: mkdir -p "${dir}"`
      }));
    }
  }

  if (errors.length > 0) {
    errors.forEach(e => console.error(chalk.red(`  ✗ ${e.message}`)));
    if (errors[0].suggestion) {
      console.error(chalk.yellow(`\n  ${errors[0].suggestion}`));
    }
    process.exit(2);
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Network Error Recovery

Network errors are the most common failure mode for CLI tools that talk to APIs:

async function fetchWithRetry(url, { retries = 3, timeout = 10000 } = {}) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeout);

      const response = await fetch(url, { signal: controller.signal });
      clearTimeout(timer);

      if (!response.ok) {
        throw new CliError(`HTTP ${response.status}: ${response.statusText}`, {
          code: 3,
          suggestion: response.status === 404
            ? 'Check that the URL is correct'
            : response.status === 429
            ? 'Rate limited — try again in a few minutes'
            : response.status >= 500
            ? 'Server error — try again later'
            : 'Check your authentication and permissions',
          details: `URL: ${url}, Status: ${response.status}`
        });
      }

      return response;
    } catch (error) {
      if (error instanceof CliError) throw error;

      if (error.name === 'AbortError') {
        if (attempt === retries) {
          throw new CliError(`Request timed out after ${timeout}ms`, {
            code: 4,
            suggestion: 'Check your network connection or increase timeout with --timeout',
            details: `URL: ${url}, Attempts: ${retries}`
          });
        }
        // Retry with exponential backoff
        const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
        process.stderr.write(chalk.gray(`  Timeout, retrying in ${delay}ms...\n`));
        await new Promise(r => setTimeout(r, delay));
        continue;
      }

      if (error.code === 'ENOTFOUND') {
        throw new CliError(`Cannot resolve hostname: ${new URL(url).hostname}`, {
          code: 3,
          suggestion: 'Check your internet connection and DNS settings'
        });
      }

      throw error;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: File System Errors

async function readInputFile(filePath) {
  try {
    const content = await readFile(filePath, 'utf-8');
    return content;
  } catch (error) {
    if (error.code === 'ENOENT') {
      throw new CliError(`File not found: ${filePath}`, {
        code: 2,
        suggestion: `Check the path and try again. Current directory: ${process.cwd()}`
      });
    }
    if (error.code === 'EACCES') {
      throw new CliError(`Permission denied: ${filePath}`, {
        code: 2,
        suggestion: `Check file permissions: ls -la "${filePath}"`
      });
    }
    if (error.code === 'EISDIR') {
      throw new CliError(`Expected a file, got a directory: ${filePath}`, {
        code: 2,
        suggestion: 'Provide a file path, not a directory'
      });
    }
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Graceful Shutdown

Handle SIGINT (Ctrl+C) and SIGTERM properly:

let isShuttingDown = false;

function setupGracefulShutdown(cleanup) {
  const shutdown = async (signal) => {
    if (isShuttingDown) return;
    isShuttingDown = true;

    console.error(chalk.gray(`\n  Received ${signal}, cleaning up...`));

    try {
      await cleanup();
    } catch (error) {
      console.error(chalk.red(`  Cleanup failed: ${error.message}`));
    }

    process.exit(signal === 'SIGINT' ? 130 : 143);
  };

  process.on('SIGINT', () => shutdown('SIGINT'));
  process.on('SIGTERM', () => shutdown('SIGTERM'));
}

// Usage
setupGracefulShutdown(async () => {
  await chrome?.kill();    // Kill headless Chrome
  await db?.close();       // Close database connections
  tempDir && await rm(tempDir, { recursive: true }); // Clean temp files
});
Enter fullscreen mode Exit fullscreen mode

Pattern 5: The --verbose Flag

Give users a debugging escape hatch:

let verboseMode = false;

export function setVerbose(enabled) {
  verboseMode = enabled;
}

export function debug(message) {
  if (verboseMode) {
    process.stderr.write(chalk.gray(`  [debug] ${message}\n`));
  }
}

// Usage throughout your code
debug(`Fetching ${url}`);
debug(`Response: ${response.status} in ${elapsed}ms`);
debug(`Parsed ${items.length} items`);
debug(`Writing output to ${outputPath}`);
Enter fullscreen mode Exit fullscreen mode

Pattern 6: Catch-All Error Boundary

Wrap your entire CLI in a top-level error handler:

#!/usr/bin/env node

async function main() {
  // All your CLI logic here
  program.parse();
}

main().catch((error) => {
  handleError(error, {
    verbose: process.argv.includes('--verbose') || process.argv.includes('-v')
  });
});

// Also catch unhandled rejections
process.on('unhandledRejection', (error) => {
  console.error(chalk.red('Unhandled error:'));
  console.error(error);
  process.exit(99);
});
Enter fullscreen mode Exit fullscreen mode

Error Message Checklist

Good CLI error messages:

  • [ ] Say what went wrong — not just "Error" or a stack trace
  • [ ] Say why — "File not found" is better than "ENOENT"
  • [ ] Say how to fix it — include the command or action needed
  • [ ] Use stderr — never pollute stdout with error messages
  • [ ] Use color sparingly — red for errors, yellow for suggestions
  • [ ] Include context — which file, which URL, which flag
  • [ ] Respect --quiet — suppress non-essential error details

Conclusion

Error handling isn't the exciting part of building CLI tools. But it's the difference between a tool developers trust and one they abandon after the first confusing failure. Every error is an opportunity to help your user succeed — or to lose them forever.


Wilson Xu publishes developer CLI tools on npm and writes about developer experience at dev.to/chengyixu.

Top comments (0)