cat swift/milestone-concurrency-resilienza-errori.md
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:
RetryPolicy;RetryExecutor;- test retry successivo a fallimenti;
- test no retry su 404;
- fallback cache su errore rete;
- cancellation propagata;
- mapping errore -> messaggio utente.
Obiettivo: la demo deve restare utile anche con rete instabile o dati cached.