cat swift/retry-timeout-cancellazione-networking.md

Lezione 53 55 min

Retry, timeout e cancellazione nel networking

Una rete instabile richiede retry e timeout, ma il codice deve rispettare cancellazione e non riprovare errori non recuperabili.

Il networking fallisce. Una richiesta può fallire per rete instabile, timeout, server temporaneamente non disponibile, rate limit o dati corrotti.

Non tutti gli errori meritano retry.

Retry generico

func retry<T: Sendable>(
  attempts: Int,
  delay: Duration,
  operation: () async throws -> T
) async throws -> T {
  var lastError: Error?

  for attempt in 0..<attempts {
    try Task.checkCancellation()

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

      if attempt < attempts - 1 {
        try await Task.sleep(for: delay)
      }
    }
  }

  throw lastError ?? APIError.invalidResponse
}

Questo è un punto di partenza. Ma riprova tutto, anche errori non recuperabili.

Decidere se ritentare

func shouldRetry(_ error: Error) -> Bool {
  guard let apiError = error as? APIError else {
    return true
  }

  switch apiError {
  case .statusCode(500..<600, _):
    return true
  case .transport:
    return true
  default:
    return false
  }
}

Swift non permette pattern 500..<600 direttamente così su associated value se non lo modelli bene. Una forma più esplicita:

case .statusCode(let code, _):
  return (500..<600).contains(code)

Non ritentare:

  • 400 validation error;
  • 401 auth;
  • 404 not found;
  • decoding error;
  • mapping error.

Backoff

Invece di delay fisso:

let delay = Duration.milliseconds(200 * (attempt + 1))

O exponential backoff:

let milliseconds = 200 * Int(pow(2.0, Double(attempt)))
try await Task.sleep(for: .milliseconds(milliseconds))

Mantieni limiti ragionevoli. Non martellare il server.

Timeout

Puoi impostare timeout su URLRequest:

request.timeoutInterval = 15

Oppure configurare URLSessionConfiguration.

Il timeout deve essere coerente col contesto:

  • ricerca: breve;
  • upload grande: più lungo;
  • background sync: diverso.

Timeout con task group

Puoi costruire timeout concorrendo tra operazione è sleep, ma è facile complicare. Parti dalle API di URLSession e configura correttamente.

Se implementi timeout manuale, rispetta cancellazione.

Cancellazione

URLSession con async/await rispetta cancellazione del task.

Nel tuo retry:

catch is CancellationError {
  throw CancellationError()
}

Non devi trasformarla in errore di rete.

Retry nel client

func sendWithRetry<T: Decodable & Sendable>(_ endpoint: Endpoint) async throws -> T {
  try await retry(attempts: 3, delay: .milliseconds(250)) {
    try await send(endpoint)
  }
}

Poi raffina retry con shouldRetry.

Idempotenza

Ritentare GET è spesso sicuro.

Ritentare POST può creare effetti doppi se il server non è idempotente.

Non fare retry cieco su operazioni che modificano stato:

  • creare ordine;
  • pagare;
  • inviare messaggio;
  • confermare prenotazione.

Serve supporto server: idempotency key o semantica chiara.

Checklist

Prima di aggiungere retry:

  • l’operazione è idempotente?
  • quali errori sono recuperabili?
  • quanti tentativi?
  • quale delay/backoff?
  • il retry rispetta cancellazione?
  • il timeout è configurato?
  • gli errori finali restano diagnostici?

Esercizio

Implementa:

func retry<T: Sendable>(
  attempts: Int,
  delay: Duration,
  shouldRetry: (Error) -> Bool,
  operation: () async throws -> T
) async throws -> T

Poi usalo solo per richieste GET.

Scrivi tre casi:

  • 500: ritenta;
  • 404: non ritenta;
  • cancellazione: non ritenta e rilancia CancellationError.