cat swift/errori-http-decoding-mapping.md

Lezione 52 50 min

Errori HTTP, decoding e mapping degli errori

Una buona API Swift non restituisce errori generici: distingue rete, status code, decoding, mapping e cancellazione.

Nel networking reale, “errore” può significare molte cose:

  • URL invalido;
  • connessione assente;
  • timeout;
  • risposta non HTTP;
  • status code 404;
  • status code 500;
  • JSON non decodificabile;
  • JSON valido ma dominio invalido;
  • cancellazione.

Se trasformi tutto in Error, perdi diagnostica.

APIError

enum APIError: Error, Sendable {
  case invalidURL
  case invalidResponse
  case statusCode(Int, data: Data)
  case remote(code: String, message: String)
  case transport(Error)
  case decoding(Error)
  case mapping(Error)
}

Questo errore distingue fasi diverse.

Status code

guard (200..<300).contains(http.statusCode) else {
  throw APIError.statusCode(http.statusCode, data: data)
}

Mantieni data se la API manda un payload errore utile.

Error response DTO

struct ErrorResponseDTO: Decodable {
  let code: String
  let message: String
}

Puoi provare a decodificarlo:

if let apiError = try? decoder.decode(ErrorResponseDTO.self, from: data) {
  throw APIError.remote(code: apiError.code, message: apiError.message)
}

Questo conserva l’informazione prodotta dal server senza fingere che sia un errore di decoding.

Decoding error

do {
  return try decoder.decode(T.self, from: data)
} catch {
  throw APIError.decoding(error)
}

Non sostituire subito con “impossibile caricare”. In fase di debug, DecodingError contiene il coding path e il motivo.

Mapping error

Se decodifichi DTO e poi converti:

func product(id: String) async throws -> Product {
  let dto: ProductDTO = try await send(Endpoint(path: "products/\(id)"))

  do {
    return try dto.toDomain()
  } catch {
    throw APIError.mapping(error)
  }
}

Decoding e mapping sono diversi:

  • decoding: il JSON non corrisponde al DTO;
  • mapping: il DTO non produce un dominio valido.

Cancellazione

Non trasformare CancellationError in APIError.

do {
  return try await operation()
} catch is CancellationError {
  throw CancellationError()
} catch {
  throw APIError.transport(error)
}

Se aggiungi:

case transport(Error)

mantieni separata la cancellazione.

Mapping per status code

Non tutti gli status code hanno lo stesso significato.

func validate(_ response: HTTPURLResponse, data: Data) throws {
  switch response.statusCode {
  case 200..<300:
    return
  case 401:
    throw APIError.remote(code: "unauthorized", message: "Accesso non autorizzato.")
  case 404:
    throw APIError.statusCode(404, data: data)
  case 429:
    throw APIError.remote(code: "rate_limited", message: "Troppe richieste.")
  default:
    throw APIError.statusCode(response.statusCode, data: data)
  }
}

Questa funzione è piccola, testabile e impedisce di spargere if statusCode == ... in tutto il client.

Errori retryable

Il modello errori può aiutare il retry:

extension APIError {
  var isRetryable: Bool {
    switch self {
    case .statusCode(let code, _):
      return (500..<600).contains(code)
    case .transport:
      return true
    case .invalidURL, .invalidResponse, .remote, .decoding, .mapping:
      return false
    }
  }
}

Non fare retry su errori deterministici come mapping o decoding. Ripetere la stessa richiesta non trasformerà un prezzo negativo in un prezzo valido.

User-facing error

Al confine UI puoi mappare:

extension APIError {
  var userMessage: String {
    switch self {
    case .invalidURL:
      return "Richiesta non valida."
    case .invalidResponse:
      return "Risposta non valida dal server."
    case .statusCode(404, _):
      return "Elemento non trovato."
    case .statusCode:
      return "Errore del server."
    case .decoding, .mapping:
      return "I dati ricevuti non sono validi."
    case .transport:
      return "Controlla la connessione e riprova."
    case .remote(_, let message):
      return message
    }
  }
}

Non usare messaggi tecnici direttamente nella UI.

Checklist

Un buon modello errori distingue:

  • trasporto;
  • risposta non valida;
  • status code;
  • errore remoto;
  • decoding;
  • mapping;
  • cancellazione.

E mantiene:

  • dettagli per debug;
  • messaggi adatti all’utente;
  • errori tipizzati per test.

Test consigliati

Per APIError, scrivi test piccoli:

@Test
func statusCode500ERetryable() {
  #expect(APIError.statusCode(500, data: Data()).isRetryable)
}

@Test
func decodingNonERetryable() {
  #expect(APIError.decoding(DummyError()).isRetryable == false)
}

E test sul messaggio utente:

@Test
func remoteErrorUsaMessaggioServer() {
  let error = APIError.remote(code: "quota", message: "Quota superata.")

  #expect(error.userMessage == "Quota superata.")
}

Il modello errori diventa parte del contratto del client.

Esercizio

Estendi APIError con:

  • transport(Error);
  • remote(code:message:);
  • statusCode;
  • decoding;
  • mapping.

Poi scrivi una funzione userMessage e testala su 404, 500, decoding e remote error.