cat swift/laboratorio-client-api-concorrente.md

Lezione 38 90 min

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;
  • CancellationError non viene mostrato all’utente;
  • isLoading torna 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.