cat swift/laboratorio-client-api-concorrente.md
Laboratorio: client API concorrente
Progetto guidato del Modulo 5: costruire un client API con cache actor, retry cancellabile, task paralleli e aggiornamento UI su MainActor.
Questo laboratorio mette insieme tutto il modulo: async/await, structured concurrency, async let, task group, cancellation, actor, MainActor e Sendable.
Costruiremo un piccolo client per caricare dettagli prodotto:
- prodotto;
- recensioni;
- suggerimenti;
- cache;
- retry;
- cancellazione;
- aggiornamento UI.
Modelli Sendable
struct Prodotto: Sendable, Decodable, Identifiable {
let id: String
let nome: String
let prezzoInCent: Int
}
struct Recensione: Sendable, Decodable, Identifiable {
let id: String
let voto: Int
let testo: String
}
struct DettaglioProdotto: Sendable {
let prodotto: Prodotto
let recensioni: [Recensione]
let suggeriti: [Prodotto]
}
I modelli sono value types immutabili. Sono buoni candidati per attraversare confini concorrenti.
Errori API
enum APIError: Error, Sendable {
case invalidURL
case invalidResponse
case statusCode(Int)
case decodingFailed
}
Errori piccoli, espliciti e sendable.
API client
struct ProductAPI: Sendable {
let baseURL: URL
let session: URLSession
let decoder: JSONDecoder
func prodotto(id: String) async throws -> Prodotto {
try await get("/products/\(id)")
}
func recensioni(id: String) async throws -> [Recensione] {
try await get("/products/\(id)/reviews")
}
func suggeriti(id: String) async throws -> [Prodotto] {
try await get("/products/\(id)/suggested")
}
private func get<T: Decodable & Sendable>(_ path: String) async throws -> T {
guard let url = URL(string: path, relativeTo: baseURL) else {
throw APIError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.statusCode(http.statusCode)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingFailed
}
}
}
Nota: in un progetto reale potresti incapsulare meglio URLSession per testabilità. Qui ci concentriamo sulla concurrency.
Retry cancellabile
func retry<T: Sendable>(
tentativi: Int,
delay: Duration = .milliseconds(250),
operazione: () async throws -> T
) async throws -> T {
var ultimoErrore: Error?
for tentativo in 0..<tentativi {
try Task.checkCancellation()
do {
return try await operazione()
} catch is CancellationError {
throw CancellationError()
} catch {
ultimoErrore = error
if tentativo < tentativi - 1 {
try await Task.sleep(for: delay)
}
}
}
throw ultimoErrore ?? APIError.invalidResponse
}
Il retry non deve insistere se il task è cancellato.
Cache actor
actor ProductDetailCache {
private var storage: [String: DettaglioProdotto] = [:]
func get(_ id: String) -> DettaglioProdotto? {
storage[id]
}
func set(_ value: DettaglioProdotto, for id: String) {
storage[id] = value
}
func clear() {
storage.removeAll()
}
}
La cache è actor-isolated: niente accesso concorrente non protetto allo storage.
Service con async let
struct ProductDetailService: Sendable {
let api: ProductAPI
let cache: ProductDetailCache
func dettaglio(id: String) async throws -> DettaglioProdotto {
if let cached = await cache.get(id) {
return cached
}
async let prodotto = retry(tentativi: 3) {
try await api.prodotto(id: id)
}
async let recensioni = retry(tentativi: 3) {
try await api.recensioni(id: id)
}
async let suggeriti = retry(tentativi: 3) {
try await api.suggeriti(id: id)
}
let dettaglio = try await DettaglioProdotto(
prodotto: prodotto,
recensioni: recensioni,
suggeriti: suggeriti
)
await cache.set(dettaglio, for: id)
return dettaglio
}
}
Le tre chiamate sono indipendenti, quindi partono insieme.
TaskGroup per lista dinamica
extension ProductDetailService {
func dettagli(ids: [String]) async throws -> [DettaglioProdotto] {
try await withThrowingTaskGroup(of: (Int, DettaglioProdotto).self) { group in
for (index, id) in ids.enumerated() {
group.addTask {
let dettaglio = try await dettaglio(id: id)
return (index, dettaglio)
}
}
var risultati: [(Int, DettaglioProdotto)] = []
risultati.reserveCapacity(ids.count)
for try await risultato in group {
risultati.append(risultato)
}
return risultati
.sorted { $0.0 < $1.0 }
.map { $0.1 }
}
}
}
Il gruppo carica una lista dinamica e preserva l’ordine.
ViewModel su MainActor
@MainActor
final class ProductDetailViewModel {
private let service: ProductDetailService
private var loadTask: Task<Void, Never>?
private(set) var dettaglio: DettaglioProdotto?
private(set) var isLoading = false
private(set) var errore: String?
init(service: ProductDetailService) {
self.service = service
}
func load(id: String) {
loadTask?.cancel()
loadTask = Task {
await loadValue(id: id)
}
}
private func loadValue(id: String) async {
isLoading = true
errore = nil
do {
dettaglio = try await service.dettaglio(id: id)
} catch is CancellationError {
return
} catch {
errore = String(describing: error)
}
isLoading = false
}
deinit {
loadTask?.cancel()
}
}
Il view model è isolato al MainActor. Il service no.
Piccolo bug da correggere
Nel catch is CancellationError, isLoading resta true se ritorni subito.
Refactor:
private func loadValue(id: String) async {
isLoading = true
errore = nil
defer {
isLoading = false
}
do {
dettaglio = try await service.dettaglio(id: id)
} catch is CancellationError {
return
} catch {
errore = String(describing: error)
}
}
Questo è il tipo di bug reale che la concurrency rende facile introdurre se non ragioni sugli stati finali.
Checklist laboratorio
Verifica:
- i modelli sono
Sendable; - la cache è un actor;
- il view model è
@MainActor; - le chiamate indipendenti usano
async let; - la lista dinamica usa task group;
- il retry rispetta cancellazione;
CancellationErrornon viene mostrato all’utente;isLoadingtorna sempre false;- non ci sono task non strutturati nel dominio;
- il task UI viene cancellato quando serve.
Estensioni
Per rendere il laboratorio più serio:
- aggiungi cache degli in-flight task per evitare doppio load dello stesso id;
- limita il parallelismo nei task group;
- separa DTO e dominio;
- aggiungi test con API finta;
- crea errori utente-friendly;
- aggiungi metrica dei retry effettuati.