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

This skill guides expert decision making for error handling, designing error hierarchies, recovery strategies, and user-facing presentation.

npx playbooks add skill kaakati/rails-enterprise-dev --skill error-handling-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (2)
SKILL.md
10.5 KB
---
name: error-handling-patterns
description: "Expert error handling decisions for iOS/tvOS: when to use throws vs Result, error type design trade-offs, recovery strategy selection, and user-facing error presentation patterns. Use when designing error hierarchies, implementing retry logic, or deciding how to surface errors to users. Trigger keywords: Error, throws, Result, LocalizedError, retry, recovery, error presentation, do-catch, try, error handling, failure"
version: "3.0.0"
---

# 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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

```swift
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

```swift
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

```swift
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

```swift
@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 |

Overview

This skill provides expert guidance on error handling decisions for iOS and tvOS projects. It helps choose between throws and Result, design actionable error types, select recovery strategies, and pick user-facing presentation patterns. Use it to make trade-offs that improve reliability, observability, and UX.

How this skill works

The skill evaluates context (caller expectations, failure frequency, API boundaries) and recommends patterns: Result when failures are expected and must be handled, throws for exceptional failures, and try?/try! for cases where failure is acceptable or impossible. It also prescribes error granularity, retry policies (idempotency and backoff), and presentation strategies (toast, alert, full-screen). It flags common mistakes like swallowing errors, leaking sensitive info, retrying non-idempotent operations, and exposing CancellationError to users.

When to use it

  • Designing library or framework error APIs and deciding how coarse/fine-grained errors should be
  • Choosing between throws, Result, try?, and try! for a given API or call site
  • Implementing retry logic with exponential backoff and idempotency keys
  • Mapping internal errors to user-facing messages and recovery suggestions
  • Creating ViewModel error states and deciding when to show vs log errors

Best practices

  • Use Result<T,E> for validation and parsing where failure is expected and must be handled explicitly
  • Use throws for exceptional I/O and network errors to keep call sites clean with async/await
  • Design error enums to be actionable; collapse cases if handlers are identical
  • Never include sensitive data in error associated values or logs; sanitize before throwing
  • Treat CancellationError specially—don’t surface it to the user
  • Retry only idempotent or safe operations, with limited attempts and exponential backoff

Example use cases

  • API client: expose coarse NetworkError across framework boundaries and map to LocalizedError for UI
  • Form validation: return Result to force exhaustive handling at the caller and show inline errors
  • Payment processing: use idempotency keys and cautious retries for non-idempotent operations
  • ViewModel load flows: translate domain errors into state with user message and isRetryable flag
  • Background sync: log failures silently and use fallback values when user does not need to know

FAQ

When should I prefer Result over throws?

Prefer Result when failures are common and callers must handle them explicitly (validation, parsing). Use throws for exceptional I/O or network errors where callers can treat failure as exceptional.

How granular should my error enums be?

Make errors as specific as needed for actionable handling. If every case uses the same handler, collapse cases. Provide user-facing messages via LocalizedError when errors reach UI.