cat swift/milestone-concurrency-resilienza-errori.md

Lezione 7570 min

Milestone 3: concurrency, resilienza ed error handling

Rendere il progetto finale robusto: async/await, cancellation, retry, task group, actor, errori tecnici e messaggi utente.

La terza milestone rende il progetto affidabile quando il mondo smette di collaborare.

Qui entrano:

  • cancellazione;
  • retry;
  • timeout;
  • task concorrenti;
  • actor;
  • errori mappati;
  • fallback cache;
  • messaggi utente.

Non basta che il progetto funzioni con una risposta perfetta.

Errori a livelli

Separiamo tre livelli:

DomainError
  regole del problema

InfrastructureError
  rete, decoding, file, database

PresentationError
  messaggi e stato per utente

Esempio:

public enum NetworkError: Error, Sendable {
  case invalidURL
  case invalidResponse
  case statusCode(Int)
  case decoding
  case transport
  case timeout
}

Non mostrare NetworkError.decoding direttamente all’utente. Traducilo.

Cancellation

Ogni flusso async lungo deve rispettare la cancellazione.

public func search(query: SearchQuery) async throws -> [Story] {
  try Task.checkCancellation()

  let request = try requestBuilder.get(...)
  let (data, response) = try await client.data(for: request)

  try Task.checkCancellation()

  return try decode(data, response: response)
}

Regola: non catturare CancellationError e convertirlo in errore generico.

catch is CancellationError {
  throw CancellationError()
}

Retry

Retry solo per errori transitori.

Buoni candidati:

  • status code 500-599;
  • timeout;
  • rete temporaneamente non disponibile.

Cattivi candidati:

  • 400;
  • 401;
  • 403;
  • 404;
  • decoding error;
  • mapping error.
public struct RetryPolicy: Sendable {
  public let maxAttempts: Int
  public let delay: Duration

  public static let none = RetryPolicy(maxAttempts: 1, delay: .zero)
  public static let standard = RetryPolicy(maxAttempts: 3, delay: .milliseconds(250))
}

Retry executor

public struct RetryExecutor: Sendable {
  private let policy: RetryPolicy
  private let sleep: @Sendable (Duration) async throws -> Void

  public init(
    policy: RetryPolicy,
    sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
  ) {
    self.policy = policy
    self.sleep = sleep
  }

  public func run<T: Sendable>(
    _ operation: @Sendable () async throws -> T
  ) async throws -> T {
    var lastError: Error?

    for attempt in 1...policy.maxAttempts {
      try Task.checkCancellation()

      do {
        return try await operation()
      } catch is CancellationError {
        throw CancellationError()
      } catch {
        lastError = error

        guard attempt < policy.maxAttempts, error.isRetryable else {
          throw error
        }

        try await sleep(policy.delay)
      }
    }

    throw lastError ?? NetworkError.transport
  }
}

Nel test puoi iniettare una sleep finta.

Timeout

Se il progetto finale include timeout, rendilo esplicito.

public func withTimeout<T: Sendable>(
  _ duration: Duration,
  operation: @escaping @Sendable () async throws -> T
) async throws -> T {
  try await withThrowingTaskGroup(of: T.self) { group in
    group.addTask {
      try await operation()
    }

    group.addTask {
      try await Task.sleep(for: duration)
      throw NetworkError.timeout
    }

    guard let result = try await group.next() else {
      throw NetworkError.timeout
    }

    group.cancelAll()
    return result
  }
}

Questo esempio è didattico. Nel progetto reale valuta anche timeout nativi di URLRequest e URLSessionConfiguration.

TaskGroup

Se devi caricare dettagli multipli:

public func loadStories(ids: [Story.ID]) async throws -> [Story] {
  try await withThrowingTaskGroup(of: Story.self) { group in
    for id in ids {
      group.addTask {
        try await repository.story(id: id)
      }
    }

    var stories: [Story] = []
    for try await story in group {
      stories.append(story)
    }
    return stories
  }
}

Non promettere ordine se non lo ricostruisci.

Actor per stato condiviso

Cache, contatori, token e store in memoria possono essere actor.

public actor RequestMetrics {
  private var failures = 0

  public func recordFailure() {
    failures += 1
  }

  public func snapshot() -> Int {
    failures
  }
}

Swift 6 ti spinge a rendere esplicito l’accesso concorrente. È un bene.

Fallback cache

Per una buona demo:

public func search(query: SearchQuery) async throws -> [Story] {
  do {
    let fresh = try await remote.search(query: query)
    await cache.store(fresh, for: query)
    return fresh
  } catch is CancellationError {
    throw CancellationError()
  } catch {
    if let cached = await cache.value(for: query) {
      return cached
    }

    throw error
  }
}

Il fallback è comportamento di prodotto, quindi va testato.

Presentation mapping

enum ViewMessage {
  static func message(for error: Error) -> String {
    switch error {
    case SearchError.queryTooShort:
      return "Inserisci almeno due caratteri."
    case NetworkError.statusCode(429):
      return "Troppe richieste. Riprova tra poco."
    default:
      return "Qualcosa è andato storto."
    }
  }
}

Messaggi utente e errori tecnici non sono la stessa cosa.

Checklist

La milestone 3 è completa quando:

  • cancellation resta cancellation;
  • retry è limitato a errori retryable;
  • timeout è esplicito;
  • stato condiviso è protetto;
  • fallback cache è testato;
  • TaskGroup non promette ordine falso;
  • errori tecnici sono mappati;
  • la UI/CLI riceve messaggi comprensibili.

Esercizio

Aggiungi al tuo progetto:

  1. RetryPolicy;
  2. RetryExecutor;
  3. test retry successivo a fallimenti;
  4. test no retry su 404;
  5. fallback cache su errore rete;
  6. cancellation propagata;
  7. mapping errore -> messaggio utente.

Obiettivo: la demo deve restare utile anche con rete instabile o dati cached.