Skip to content

Error Handling

This guide covers comprehensive error handling strategies for backupx, including custom error types, recovery mechanisms, and debugging techniques.

Error Types & Classification

Built-in Error Codes

ts
enum BackupErrorCode {
  // Configuration errors
  INVALID_CONFIG = 'INVALID_CONFIG',
  MISSING_CREDENTIALS = 'MISSING_CREDENTIALS',
  INVALID_PATH = 'INVALID_PATH',

  // Connection errors
  CONNECTION_FAILED = 'CONNECTION_FAILED',
  TIMEOUT = 'TIMEOUT',
  AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',

  // File system errors
  FILE_NOT_FOUND = 'FILE_NOT_FOUND',
  PERMISSION_DENIED = 'PERMISSION_DENIED',
  DISK_FULL = 'DISK_FULL',

  // Database errors
  DATABASE_ERROR = 'DATABASE_ERROR',
  QUERY_FAILED = 'QUERY_FAILED',
  SCHEMA_ERROR = 'SCHEMA_ERROR',
}

class BackupError extends Error {
  constructor(
    public readonly code: BackupErrorCode,
    message: string,
    public readonly details: Record<string, any> = {},
    public readonly recoverable = false,
  ) {
    super(message)
    this.name = 'BackupError'
  }
}

Error Classification

ts
class ErrorClassifier {
  static classifyError(error: Error): {
    category: string
    severity: 'low' | 'medium' | 'high' | 'critical'
    recoverable: boolean
    retryable: boolean
  } {
    // File system errors
    if (error.message.includes('ENOENT') || error.message.includes('no such file')) {
      return {
        category: 'filesystem',
        severity: 'medium',
        recoverable: false,
        retryable: false,
      }
    }

    // Permission errors
    if (error.message.includes('EACCES') || error.message.includes('permission denied')) {
      return {
        category: 'permissions',
        severity: 'high',
        recoverable: false,
        retryable: false,
      }
    }

    // Network/connection errors
    if (error.message.includes('ECONNREFUSED') || error.message.includes('connection')) {
      return {
        category: 'network',
        severity: 'medium',
        recoverable: true,
        retryable: true,
      }
    }

    // Memory errors
    if (error.message.includes('out of memory') || error.message.includes('heap')) {
      return {
        category: 'memory',
        severity: 'critical',
        recoverable: true,
        retryable: false,
      }
    }

    // Disk space errors
    if (error.message.includes('ENOSPC') || error.message.includes('no space')) {
      return {
        category: 'storage',
        severity: 'critical',
        recoverable: false,
        retryable: false,
      }
    }

    // Default classification
    return {
      category: 'unknown',
      severity: 'medium',
      recoverable: false,
      retryable: true,
    }
  }
}

Retry Mechanisms

Exponential Backoff

ts
class RetryManager {
  async executeWithRetry<T>(
    operation: () => Promise<T>,
    maxAttempts = 3,
    baseDelay = 1000,
  ): Promise<T> {
    let attempt = 1

    while (attempt <= maxAttempts) {
      try {
        return await operation()
      }
      catch (error) {
        if (attempt === maxAttempts)
          throw error

        const delay = baseDelay * (2 ** (attempt - 1))
        await new Promise(resolve => setTimeout(resolve, delay))
        attempt++
      }
    }

    throw new Error('Max attempts reached')
  }
}

Circuit Breaker Pattern

ts
enum CircuitState {
  CLOSED = 'CLOSED',
  OPEN = 'OPEN',
  HALF_OPEN = 'HALF_OPEN',
}

class CircuitBreaker {
  private state: CircuitState = CircuitState.CLOSED
  private failureCount = 0
  private lastFailureTime = 0
  private successCount = 0

  constructor(
    private readonly failureThreshold: number = 5,
    private readonly timeout: number = 60000, // 1 minute
    private readonly monitoringPeriod: number = 10000, // 10 seconds
  ) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === CircuitState.OPEN) {
      if (Date.now() - this.lastFailureTime > this.timeout) {
        this.state = CircuitState.HALF_OPEN
        this.successCount = 0
      }
      else {
        throw new BackupError(
          BackupErrorCode.CONNECTION_FAILED,
          'Circuit breaker is OPEN',
          { state: this.state, failureCount: this.failureCount },
        )
      }
    }

    try {
      const result = await operation()
      this.onSuccess()
      return result
    }
    catch (error) {
      this.onFailure()
      throw error
    }
  }

  private onSuccess(): void {
    this.failureCount = 0

    if (this.state === CircuitState.HALF_OPEN) {
      this.successCount++
      if (this.successCount >= 3) { // Require 3 successes to close
        this.state = CircuitState.CLOSED
      }
    }
  }

  private onFailure(): void {
    this.failureCount++
    this.lastFailureTime = Date.now()

    if (this.state === CircuitState.HALF_OPEN) {
      this.state = CircuitState.OPEN
    }
    else if (this.failureCount >= this.failureThreshold) {
      this.state = CircuitState.OPEN
    }
  }

  getState(): CircuitState {
    return this.state
  }
}

Resilient Backup Manager

Comprehensive Error Handling

ts
class ResilientBackupManager extends BackupManager {
  private readonly retryManager = new RetryManager()
  private readonly circuitBreakers = new Map<string, CircuitBreaker>()
  private readonly errorLog: BackupError[] = []

  async createBackup(): Promise<BackupSummary> {
    const startTime = Date.now()
    const results: BackupResult[] = []

    try {
      // Process databases with error handling
      for (const dbConfig of this.config.databases) {
        try {
          const result = await this.backupDatabaseWithResilience(dbConfig)
          results.push(result)
        }
        catch (error) {
          const backupError = this.wrapError(error, dbConfig.name)
          this.logError(backupError)

          results.push({
            name: dbConfig.name,
            type: dbConfig.type,
            filename: '',
            size: 0,
            duration: 0,
            success: false,
            error: backupError.message,
          })
        }
      }

      // Process files with error handling
      for (const fileConfig of this.config.files) {
        try {
          const result = await this.backupFileWithResilience(fileConfig)
          results.push(result)
        }
        catch (error) {
          const backupError = this.wrapError(error, fileConfig.name)
          this.logError(backupError)

          results.push({
            name: fileConfig.name,
            type: BackupType.FILE,
            filename: '',
            size: 0,
            duration: 0,
            success: false,
            error: backupError.message,
          })
        }
      }

      // Clean up old backups with error handling
      if (this.config.retention) {
        try {
          await this.cleanupWithErrorHandling()
        }
        catch (error) {
          console.warn('⚠️ Cleanup failed:', error instanceof Error ? error.message : String(error))
        }
      }

      const duration = Date.now() - startTime
      return this.createSummaryWithErrorAnalysis(results, duration)
    }
    catch (error) {
      const wrappedError = this.wrapError(error, 'backup-process')
      this.logError(wrappedError)
      throw wrappedError
    }
  }

  private async backupDatabaseWithResilience(config: DatabaseConfig): Promise<BackupResult> {
    const circuitBreaker = this.getCircuitBreaker(`db-${config.name}`)

    return this.retryManager.executeWithRetry(
      () => circuitBreaker.execute(() => this.backupDatabase(config)),
      {
        maxAttempts: config.type === BackupType.SQLITE ? 2 : 3,
        baseDelay: 2000,
        maxDelay: 15000,
      },
    )
  }

  private async backupFileWithResilience(config: FileConfig): Promise<BackupResult> {
    return this.retryManager.executeWithRetry(
      () => this.backupFile(config),
      {
        maxAttempts: 2,
        baseDelay: 1000,
        maxDelay: 10000,
      },
    )
  }

  private getCircuitBreaker(key: string): CircuitBreaker {
    if (!this.circuitBreakers.has(key)) {
      this.circuitBreakers.set(key, new CircuitBreaker(3, 30000, 5000))
    }
    return this.circuitBreakers.get(key)!
  }

  private wrapError(error: unknown, context: string): BackupError {
    if (error instanceof BackupError) {
      return error
    }

    const errorMessage = error instanceof Error ? error.message : String(error)
    const classification = ErrorClassifier.classifyError(error as Error)

    // Map common error patterns to specific codes
    let code = BackupErrorCode.UNKNOWN_ERROR

    if (errorMessage.includes('ENOENT')) {
      code = BackupErrorCode.FILE_NOT_FOUND
    }
    else if (errorMessage.includes('EACCES')) {
      code = BackupErrorCode.PERMISSION_DENIED
    }
    else if (errorMessage.includes('ENOSPC')) {
      code = BackupErrorCode.DISK_FULL
    }
    else if (errorMessage.includes('connection')) {
      code = BackupErrorCode.CONNECTION_FAILED
    }

    return new BackupError(
      code,
      errorMessage,
      {
        context,
        originalError: error instanceof Error ? error.stack : String(error),
        classification,
      },
      classification.recoverable,
    )
  }

  private logError(error: BackupError): void {
    this.errorLog.push(error)

    // Keep only last 100 errors
    if (this.errorLog.length > 100) {
      this.errorLog.shift()
    }

    // Log to console with appropriate level
    const classification = ErrorClassifier.classifyError(error)

    switch (classification.severity) {
      case 'critical':
        console.error('🚨 CRITICAL:', error.message, error.details)
        break
      case 'high':
        console.error('❌ ERROR:', error.message)
        break
      case 'medium':
        console.warn('⚠️ WARNING:', error.message)
        break
      default:
        console.info('ℹ️ INFO:', error.message)
    }
  }

  private createSummaryWithErrorAnalysis(
    results: BackupResult[],
    duration: number,
  ): BackupSummary {
    const summary = {
      startTime: new Date(Date.now() - duration),
      endTime: new Date(),
      duration,
      results,
      successCount: results.filter(r => r.success).length,
      failureCount: results.filter(r => !r.success).length,
      totalSize: results.reduce((sum, r) => sum + (r.success ? r.size : 0), 0),
      errors: this.errorLog.slice(-10), // Include last 10 errors in summary
    }

    // Log summary with error analysis
    if (this.config.verbose) {
      this.logSummaryWithErrors(summary)
    }

    return summary
  }

  private logSummaryWithErrors(summary: any): void {
    console.warn('\n📊 Backup Summary:')
    console.warn(`   Duration: ${summary.duration}ms`)
    console.warn(`   Success: ${summary.successCount}`)
    console.warn(`   Failures: ${summary.failureCount}`)
    console.warn(`   Total Size: ${this.formatBytes(summary.totalSize)}`)

    if (summary.failureCount > 0) {
      console.warn('\n❌ Failures:')
      summary.errors.forEach((error: BackupError) => {
        console.warn(`   ${error.code}: ${error.message}`)
      })
    }

    // Circuit breaker status
    console.warn('\n🔌 Circuit Breaker Status:')
    this.circuitBreakers.forEach((cb, key) => {
      console.warn(`   ${key}: ${cb.getState()}`)
    })
  }

  getErrorReport(): any {
    const errorsByCode = new Map<string, number>()
    const errorsByCategory = new Map<string, number>()

    this.errorLog.forEach((error) => {
      // Count by error code
      errorsByCode.set(error.code, (errorsByCode.get(error.code) || 0) + 1)

      // Count by category
      const classification = ErrorClassifier.classifyError(error)
      errorsByCategory.set(
        classification.category,
        (errorsByCategory.get(classification.category) || 0) + 1,
      )
    })

    return {
      totalErrors: this.errorLog.length,
      errorsByCode: Object.fromEntries(errorsByCode),
      errorsByCategory: Object.fromEntries(errorsByCategory),
      recentErrors: this.errorLog.slice(-5).map(e => e.toJSON()),
      circuitBreakerStatus: Object.fromEntries(
        Array.from(this.circuitBreakers.entries()).map(([key, cb]) => [key, cb.getState()]),
      ),
    }
  }

  private formatBytes(bytes: number): string {
    const sizes = ['Bytes', 'KB', 'MB', 'GB']
    if (bytes === 0)
      return '0 Bytes'
    const i = Math.floor(Math.log(bytes) / Math.log(1024))
    return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`
  }
}

Debugging and Diagnostics

Debug Mode Configuration

ts
interface DebugConfig {
  enabled: boolean
  level: 'error' | 'warn' | 'info' | 'debug' | 'trace'
  outputFile?: string
  includeStackTrace: boolean
  captureEnvironment: boolean
}

class DebugBackupManager extends ResilientBackupManager {
  private debugConfig: DebugConfig
  private debugLog: any[] = []

  constructor(config: BackupConfig, debugConfig: Partial<DebugConfig> = {}) {
    super(config)
    this.debugConfig = {
      enabled: false,
      level: 'info',
      includeStackTrace: true,
      captureEnvironment: false,
      ...debugConfig,
    }
  }

  async createBackup(): Promise<BackupSummary> {
    if (this.debugConfig.enabled) {
      this.debug('Starting backup process', {
        config: this.sanitizeConfig(this.config),
        environment: this.debugConfig.captureEnvironment ? this.captureEnvironment() : undefined,
      })
    }

    try {
      const summary = await super.createBackup()

      if (this.debugConfig.enabled) {
        this.debug('Backup completed', { summary })
        await this.saveDebugLog()
      }

      return summary
    }
    catch (error) {
      if (this.debugConfig.enabled) {
        this.debug('Backup failed', { error: this.serializeError(error) })
        await this.saveDebugLog()
      }
      throw error
    }
  }

  private debug(message: string, data?: any): void {
    if (!this.debugConfig.enabled)
      return

    const entry = {
      timestamp: new Date().toISOString(),
      level: 'debug',
      message,
      data,
      stack: this.debugConfig.includeStackTrace ? new Error('Stack trace').stack : undefined,
    }

    this.debugLog.push(entry)
    console.debug(`[DEBUG] ${message}`, data)
  }

  private sanitizeConfig(config: any): any {
    // Remove sensitive information from config
    const sanitized = JSON.parse(JSON.stringify(config))

    sanitized.databases?.forEach((db: any) => {
      if (typeof db.connection === 'string') {
        // Hide password in connection string
        db.connection = db.connection.replace(/:([^:@]+)@/, ':***@')
      }
      else if (typeof db.connection === 'object') {
        if (db.connection.password) {
          db.connection.password = '***'
        }
      }
    })

    return sanitized
  }

  private captureEnvironment(): any {
    return {
      nodeVersion: process.version,
      platform: process.platform,
      arch: process.arch,
      memory: process.memoryUsage(),
      uptime: process.uptime(),
      cwd: process.cwd(),
      env: {
        NODE_ENV: process.env.NODE_ENV,
        // Add other non-sensitive env vars as needed
      },
    }
  }

  private serializeError(error: unknown): any {
    if (error instanceof Error) {
      return {
        name: error.name,
        message: error.message,
        stack: error.stack,
        ...(error as any), // Include any additional properties
      }
    }
    return { value: String(error) }
  }

  private async saveDebugLog(): Promise<void> {
    if (!this.debugConfig.outputFile)
      return

    try {
      const fs = await import('node:fs/promises')
      const content = JSON.stringify(this.debugLog, null, 2)
      await fs.writeFile(this.debugConfig.outputFile, content)
    }
    catch (error) {
      console.error('Failed to save debug log:', error)
    }
  }

  getDebugInfo(): any {
    return {
      config: this.debugConfig,
      logEntries: this.debugLog.length,
      lastEntries: this.debugLog.slice(-10),
      errorReport: this.getErrorReport(),
    }
  }
}

Health Check System

ts
interface HealthCheck {
  name: string
  status: 'healthy' | 'warning' | 'unhealthy'
  message: string
  details?: any
  lastChecked: Date
}

class BackupHealthMonitor {
  private checks: HealthCheck[] = []

  async performHealthChecks(config: BackupConfig): Promise<HealthCheck[]> {
    this.checks = []

    // Check output directory
    await this.checkOutputDirectory(config.outputPath)

    // Check database connections
    for (const dbConfig of config.databases) {
      await this.checkDatabaseConnection(dbConfig)
    }

    // Check file paths
    for (const fileConfig of config.files) {
      await this.checkFilePath(fileConfig)
    }

    // Check system resources
    await this.checkSystemResources()

    return this.checks
  }

  private async checkOutputDirectory(outputPath?: string): Promise<void> {
    try {
      const path = outputPath || './backups'
      const fs = await import('node:fs/promises')

      await fs.access(path)
      const stats = await fs.stat(path)

      if (!stats.isDirectory()) {
        this.addCheck({
          name: 'output-directory',
          status: 'unhealthy',
          message: 'Output path is not a directory',
          details: { path },
        })
        return
      }

      // Check write permissions
      const testFile = `${path}/.write-test-${Date.now()}`
      await fs.writeFile(testFile, 'test')
      await fs.unlink(testFile)

      this.addCheck({
        name: 'output-directory',
        status: 'healthy',
        message: 'Output directory is writable',
        details: { path },
      })
    }
    catch (error) {
      this.addCheck({
        name: 'output-directory',
        status: 'unhealthy',
        message: error instanceof Error ? error.message : 'Unknown error',
        details: { outputPath },
      })
    }
  }

  private async checkDatabaseConnection(config: DatabaseConfig): Promise<void> {
    try {
      // This would implement actual connection testing
      // For now, just check configuration

      if (config.type === BackupType.SQLITE) {
        const fs = await import('node:fs/promises')
        await fs.access((config as any).path)
      }

      this.addCheck({
        name: `database-${config.name}`,
        status: 'healthy',
        message: 'Database connection valid',
        details: { type: config.type, name: config.name },
      })
    }
    catch (error) {
      this.addCheck({
        name: `database-${config.name}`,
        status: 'unhealthy',
        message: error instanceof Error ? error.message : 'Connection failed',
        details: { type: config.type, name: config.name },
      })
    }
  }

  private async checkFilePath(config: FileConfig): Promise<void> {
    try {
      const fs = await import('node:fs/promises')
      await fs.access(config.path)

      this.addCheck({
        name: `file-${config.name}`,
        status: 'healthy',
        message: 'File path accessible',
        details: { path: config.path },
      })
    }
    catch (error) {
      this.addCheck({
        name: `file-${config.name}`,
        status: 'unhealthy',
        message: error instanceof Error ? error.message : 'File not accessible',
        details: { path: config.path },
      })
    }
  }

  private async checkSystemResources(): Promise<void> {
    const usage = process.memoryUsage()
    const memoryUsagePercent = (usage.heapUsed / usage.heapTotal) * 100

    let status: 'healthy' | 'warning' | 'unhealthy' = 'healthy'
    let message = 'System resources normal'

    if (memoryUsagePercent > 90) {
      status = 'unhealthy'
      message = 'Critical memory usage'
    }
    else if (memoryUsagePercent > 80) {
      status = 'warning'
      message = 'High memory usage'
    }

    this.addCheck({
      name: 'system-resources',
      status,
      message,
      details: {
        memory: usage,
        memoryUsagePercent: memoryUsagePercent.toFixed(1),
      },
    })
  }

  private addCheck(check: Omit<HealthCheck, 'lastChecked'>): void {
    this.checks.push({
      ...check,
      lastChecked: new Date(),
    })
  }

  getOverallHealth(): 'healthy' | 'warning' | 'unhealthy' {
    if (this.checks.some(c => c.status === 'unhealthy')) {
      return 'unhealthy'
    }
    if (this.checks.some(c => c.status === 'warning')) {
      return 'warning'
    }
    return 'healthy'
  }
}

This comprehensive error handling guide provides robust strategies for dealing with failures, implementing retry mechanisms, and maintaining system health in backup operations.

Released under the MIT License.