URLSession con async/await en Swift: descargas, uploads, WebSocket y streaming

URLSession es el framework de red de Apple, y desde Swift 5.5 tiene soporte completo para async/await. Las APIs basadas en callbacks y completionHandler siguen disponibles, pero los nuevos métodos async simplifican enormemente el código de networking: sin anidamiento de closures, sin riesgo de olvidar llamar al completion handler, y con cancelación automática integrada en la concurrencia estructurada.

data(from:): petición básica

El método más utilizado. Devuelve los datos y la respuesta HTTP de forma asíncrona:

struct APIClient {
    let sesion = URLSession.shared

    func fetchUsuario(id: Int) async throws -> Usuario {
        let url = URL(string: "https://api.example.com/usuarios/(id)")!
        let (datos, respuesta) = try await sesion.data(from: url)

        guard let http = respuesta as? HTTPURLResponse else {
            throw NetworkError.respuestaInvalida
        }
        guard 200...299 ~= http.statusCode else {
            throw NetworkError.statusCode(http.statusCode)
        }

        return try JSONDecoder().decode(Usuario.self, from: datos)
    }
}

URLRequest: peticiones con cabeceras y método

func crearUsuario(_ usuario: NuevoUsuario, token: String) async throws -> Usuario {
    var request = URLRequest(url: URL(string: "https://api.example.com/usuarios")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
    request.httpBody = try JSONEncoder().encode(usuario)
    request.timeoutInterval = 30

    let (datos, respuesta) = try await sesion.data(for: request)
    try verificarRespuesta(respuesta)
    return try JSONDecoder().decode(Usuario.self, from: datos)
}

func verificarRespuesta(_ respuesta: URLResponse) throws {
    guard let http = respuesta as? HTTPURLResponse,
          200...299 ~= http.statusCode else {
        let codigo = (respuesta as? HTTPURLResponse)?.statusCode ?? -1
        throw NetworkError.statusCode(codigo)
    }
}

download(from:): descargar a fichero

Para descargar archivos grandes, download(from:) escribe directamente a un fichero temporal sin cargar todo en memoria:

func descargarArchivo(url: URL, destino: URL) async throws {
    let (archivoTemporal, respuesta) = try await sesion.download(from: url)
    try verificarRespuesta(respuesta)

    // Mover desde el temporal al destino
    if FileManager.default.fileExists(atPath: destino.path) {
        try FileManager.default.removeItem(at: destino)
    }
    try FileManager.default.moveItem(at: archivoTemporal, to: destino)
}

upload(for:from:): subir datos

func subirImagen(_ imagen: UIImage, para usuarioId: Int) async throws -> URL {
    guard let datos = imagen.jpegData(compressionQuality: 0.8) else {
        throw UploadError.compresionFallida
    }

    var request = URLRequest(url: URL(string: "https://api.example.com/imagenes")!)
    request.httpMethod = "POST"
    request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")

    let (respuestaDatos, respuesta) = try await sesion.upload(for: request, from: datos)
    try verificarRespuesta(respuesta)

    struct RespuestaUpload: Decodable { let url: URL }
    return try JSONDecoder().decode(RespuestaUpload.self, from: respuestaDatos).url
}

bytes(from:): streaming de datos

Para procesar respuestas largas sin cargarlas enteras en memoria, bytes(from:) devuelve un AsyncSequence de bytes:

func leerLineasSSE(url: URL) async throws {
    let (bytes, _) = try await URLSession.shared.bytes(from: url)

    for try await linea in bytes.lines {
        guard linea.hasPrefix("data: ") else { continue }
        let payload = String(linea.dropFirst(6))

        if let evento = try? JSONDecoder().decode(EventoSSE.self,
                                                   from: Data(payload.utf8)) {
            await procesarEvento(evento)
        }
    }
}

bytes.lines divide el stream en líneas de texto, perfecto para Server-Sent Events o APIs de streaming como las de modelos de IA.

WebSocket con webSocketTask

Las conexiones WebSocket mantienen un canal bidireccional persistente. Con async/await, el loop de recepción es directo:

class WebSocketManager {
    private var tarea: URLSessionWebSocketTask?

    func conectar(url: URL) async throws {
        let sesion = URLSession(configuration: .default)
        tarea = sesion.webSocketTask(with: url)
        tarea?.resume()

        // Enviar un mensaje de autenticación
        try await tarea?.send(.string("""
            {"tipo": "auth", "token": "(tokenActual)"}
        """))

        // Loop de recepción — se ejecuta hasta que la conexión se cierra
        await recibirMensajes()
    }

    private func recibirMensajes() async {
        guard let tarea = tarea else { return }

        do {
            while true {
                let mensaje = try await tarea.receive()
                switch mensaje {
                case .string(let texto):
                    await procesarMensaje(texto)
                case .data(let datos):
                    await procesarDatos(datos)
                @unknown default:
                    break
                }
            }
        } catch {
            print("WebSocket cerrado: (error)")
        }
    }

    func enviar(_ mensaje: String) async throws {
        try await tarea?.send(.string(mensaje))
    }

    func desconectar() {
        tarea?.cancel(with: .goingAway, reason: nil)
        tarea = nil
    }
}

Sesiones ephemeral: sin caché ni cookies

// Para peticiones que no deben dejar rastro (modo privado, pagos, etc.)
let sesionPrivada = URLSession(configuration: .ephemeral)
let (datos, _) = try await sesionPrivada.data(from: urlSensible)

El antipatrón más frecuente: mezclar callbacks y async

// ? Antipatrón: envolver el método async en un closure de completion
func fetchDatos(completion: @escaping (Data?) -> Void) {
    Task {
        let (datos, _) = try? await URLSession.shared.data(from: url)
        completion(datos)
    }
}

// Consecuencias: pierdes la propagación de errores, la cancelación,
// y la concurrencia estructurada. Si el llamador cancela la tarea,
// la petición de red sigue ejecutándose.

// Solución: exponer directamente la función async
func fetchDatos() async throws -> Data {
    let (datos, _) = try await URLSession.shared.data(from: url)
    return datos
}

Cancelación correcta de peticiones

class BuscadorViewModel {
    private var tareaActual: Task?

    func buscar(termino: String) {
        tareaActual?.cancel() // Cancela la búsqueda anterior

        tareaActual = Task {
            do {
                let resultados = try await api.buscar(termino: termino)
                // Verificar que la tarea no fue cancelada durante la petición
                guard !Task.isCancelled else { return }
                await MainActor.run { self.resultados = resultados }
            } catch is CancellationError {
                // Cancelación normal, no mostrar error
            } catch {
                await MainActor.run { self.error = error }
            }
        }
    }
}

Resumen

URLSession con async/await transforma el código de networking: las peticiones simples son dos líneas, el streaming de respuestas grandes usa bytes.lines con un for await, y los WebSocket tienen un loop de recepción tan claro como un bucle normal. La cancelación se integra en la concurrencia estructurada sin código adicional. El único antipatrón a evitar es envolver APIs async en callbacks, que anula todas estas ventajas.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP