skills/kaakati/rails-enterprise-dev/error-handling-patterns

error-handling-patterns

SKILL.md

Error Handling Patterns — Expert Decisions

Expert decision frameworks for error handling choices. Claude knows Swift error syntax — this skill provides judgment calls for error type design and recovery strategies.


Decision Trees

throws vs Result

Does the caller need to handle success/failure explicitly?
├─ YES (caller must acknowledge failure)
│  └─ Is the failure common and expected?
│     ├─ YES → Result<T, E> (explicit handling, no try/catch)
│     └─ NO → throws (exceptional case)
└─ NO (caller can ignore failure)
   └─ Use throws with try? at call site

When Result wins: Parsing, validation, operations where failure is common and needs explicit handling. Forces exhaustive switch.

When throws wins: Network calls, file operations where failure is exceptional. Cleaner syntax with async/await.

Error Type Granularity

How specific should error types be?
├─ API boundary (framework, library)
│  └─ Coarse-grained domain errors
│     enum NetworkError: Error { case timeout, serverError, ... }
├─ Internal module
│  └─ Fine-grained for actionable handling
│     enum PaymentError: Error {
│         case cardDeclined(reason: String)
│         case insufficientFunds(balance: Decimal, required: Decimal)
│     }
└─ User-facing
   └─ LocalizedError with user-friendly messages
      var errorDescription: String? { "Payment failed" }

The trap: Too many error types that no one handles differently. If every case has the same handler, collapse them.

Recovery Strategy Selection

Is the error transient (network, timeout)?
├─ YES → Is it idempotent?
│  ├─ YES → Retry with exponential backoff
│  └─ NO → Retry cautiously or fail
└─ NO → Is recovery possible?
   ├─ YES → Can user fix it?
   │  ├─ YES → Show actionable error UI
   │  └─ NO → Auto-recover or fallback value
   └─ NO → Log and show generic error

Error Presentation Selection

How critical is the failure?
├─ Critical (can't continue)
│  └─ Full-screen error with retry
├─ Important (user needs to know)
│  └─ Alert dialog
├─ Minor (informational)
│  └─ Toast or inline message
└─ Silent (user doesn't need to know)
   └─ Log only, use fallback

NEVER Do

Error Type Design

NEVER use generic catch-all errors:

// ❌ Caller can't handle different cases
enum AppError: Error {
    case somethingWentWrong
    case error(String)  // Just a message — no actionable info
}

// ✅ Specific, actionable cases
enum AuthError: Error {
    case invalidCredentials
    case sessionExpired
    case accountLocked(unlockTime: Date?)
}

NEVER throw errors with sensitive information:

// ❌ Leaks internal details
throw DatabaseError.queryFailed(sql: query, password: dbPassword)

// ✅ Sanitize sensitive data
throw DatabaseError.queryFailed(table: tableName)

NEVER create error enums with one case:

// ❌ Pointless enum
enum ValidationError: Error {
    case invalid
}

// ✅ Use existing error or don't create enum
struct ValidationError: Error {
    let field: String
    let reason: String
}

Error Propagation

NEVER swallow errors silently:

// ❌ Failure is invisible
func loadUser() async {
    try? await fetchUser()  // Error ignored, user is nil, no feedback
}

// ✅ Handle or propagate
func loadUser() async {
    do {
        user = try await fetchUser()
    } catch {
        errorMessage = error.localizedDescription
        analytics.trackError(error)
    }
}

NEVER catch and rethrow without adding context:

// ❌ Pointless catch
do {
    try operation()
} catch {
    throw error  // Why catch at all?
}

// ✅ Add context or handle
do {
    try operation()
} catch {
    throw ContextualError.operationFailed(underlying: error, context: "during checkout")
}

NEVER use force-try (try!) outside of known-safe scenarios:

// ❌ Crashes if JSON is malformed
let data = try! JSONEncoder().encode(untrustedInput)

// ✅ Safe scenarios only
let data = try! JSONEncoder().encode(staticKnownValue)  // Compile-time known
let url = URL(string: "https://example.com")!  // Literal string

// ✅ Handle unknown input
guard let data = try? JSONEncoder().encode(userInput) else {
    throw EncodingError.failed
}

CancellationError

NEVER show CancellationError to users:

// ❌ User sees "cancelled" error when navigating away
func loadData() async {
    do {
        data = try await fetchData()
    } catch {
        errorMessage = error.localizedDescription  // Shows "cancelled"
    }
}

// ✅ Handle cancellation separately
func loadData() async {
    do {
        data = try await fetchData()
    } catch is CancellationError {
        return  // User navigated away — not an error
    } catch {
        errorMessage = error.localizedDescription
    }
}

Retry Logic

NEVER retry non-idempotent operations blindly:

// ❌ May charge user multiple times
func processPayment() async throws {
    try await retryWithBackoff {
        try await chargeCard(amount)  // Not idempotent!
    }
}

// ✅ Use idempotency key or check state first
func processPayment() async throws {
    let idempotencyKey = UUID().uuidString
    try await retryWithBackoff {
        try await chargeCard(amount, idempotencyKey: idempotencyKey)
    }
}

NEVER retry without limits:

// ❌ Infinite loop if server is down
func fetch() async throws -> Data {
    while true {
        do {
            return try await request()
        } catch {
            try await Task.sleep(nanoseconds: 1_000_000_000)
        }
    }
}

// ✅ Limited retries with backoff
func fetch(maxAttempts: Int = 3) async throws -> Data {
    var delay: UInt64 = 1_000_000_000
    for attempt in 1...maxAttempts {
        do {
            return try await request()
        } catch where attempt < maxAttempts && isRetryable(error) {
            try await Task.sleep(nanoseconds: delay)
            delay *= 2
        }
    }
    throw FetchError.maxRetriesExceeded
}

Essential Patterns

Typed Error with Recovery Info

enum NetworkError: Error, LocalizedError {
    case noConnection
    case timeout
    case serverError(statusCode: Int)
    case unauthorized

    var errorDescription: String? {
        switch self {
        case .noConnection: return "No internet connection"
        case .timeout: return "Request timed out"
        case .serverError(let code): return "Server error (\(code))"
        case .unauthorized: return "Session expired"
        }
    }

    var recoverySuggestion: String? {
        switch self {
        case .noConnection: return "Check your network settings"
        case .timeout: return "Try again"
        case .serverError: return "Please try again later"
        case .unauthorized: return "Please log in again"
        }
    }

    var isRetryable: Bool {
        switch self {
        case .noConnection, .timeout, .serverError: return true
        case .unauthorized: return false
        }
    }
}

Result with Typed Error

func validate(email: String) -> Result<String, ValidationError> {
    guard !email.isEmpty else {
        return .failure(.emptyField("email"))
    }
    guard email.contains("@") else {
        return .failure(.invalidFormat("email"))
    }
    return .success(email)
}

// Forced exhaustive handling
switch validate(email: input) {
case .success(let email):
    createAccount(email: email)
case .failure(let error):
    showValidationError(error)
}

Retry with Exponential Backoff

func withRetry<T>(
    maxAttempts: Int = 3,
    initialDelay: Duration = .seconds(1),
    operation: () async throws -> T
) async throws -> T {
    var delay = initialDelay

    for attempt in 1...maxAttempts {
        do {
            return try await operation()
        } catch let error as NetworkError where error.isRetryable && attempt < maxAttempts {
            try await Task.sleep(for: delay)
            delay *= 2
        } catch {
            throw error
        }
    }
    fatalError("Should not reach")
}

Error Presentation in ViewModel

@MainActor
final class ViewModel: ObservableObject {
    @Published private(set) var state: State = .idle

    enum State: Equatable {
        case idle
        case loading
        case loaded(Data)
        case error(String, isRetryable: Bool)
    }

    func load() async {
        state = .loading
        do {
            let data = try await fetchData()
            state = .loaded(data)
        } catch is CancellationError {
            state = .idle  // Don't show error
        } catch let error as NetworkError {
            state = .error(error.localizedDescription, isRetryable: error.isRetryable)
        } catch {
            state = .error("Something went wrong", isRetryable: true)
        }
    }
}

Quick Reference

Error Handling Decision Matrix

Scenario Pattern Why
Network call throws Exceptional failure
Validation Result<T, E> Expected failure, needs handling
Optional parsing try? Failure acceptable, use default
Must succeed try! Only for literals/static
Background task Log only User doesn't need to know

Error Type Checklist

  • Cases are actionable (handler differs per case)
  • No sensitive data in associated values
  • Conforms to LocalizedError for user-facing
  • Has isRetryable property if recovery is possible
  • Has recoverySuggestion for user guidance

Red Flags

Smell Problem Fix
catch { } empty Error silently swallowed Log or propagate
Same handler for all cases Over-specific error type Collapse to fewer cases
try? on critical path Hidden failures Use do-catch
Retry without limit Infinite loop risk Add max attempts
CancellationError shown to user Bad UX Handle separately
error.localizedDescription on NSError Technical message Map to user-friendly
Weekly Installs
14
GitHub Stars
6
First Seen
Jan 25, 2026
Installed on
opencode12
claude-code11
codex11
gemini-cli11
github-copilot10
antigravity9