Saltar al contenido
Development

try? convirtió una interrupción en "todo al día"

Por Victor Da Luz
iosswifterror-handlingdev-logdeep-cut-atlas

Justo después de agregar inyección de errores a mis mocks, fui a revisar el único lugar al que el nuevo seam no llegaba. Resultó estar escondiendo el peor bug de la app.

El problema

Deep Cut Atlas arma su feed obteniendo el catálogo de cada artista de la biblioteca, unos pocos artistas a la vez:

let tasks = chunk.map { artist in
    Task { () -> [LibraryAlbum] in
        (try? await self.service.fetchCatalogReleases(for: artist)) ?? []
    }
}

Ese try? ... ?? [] parecía inofensivo. Un artista con fallas intermitentes no debería tumbar un barrido de 200 artistas, ¿no? Resiliencia por artista. Hasta tenía un comentario explicando que el chunking era una protección de rate-limit.

Pero si se sigue el array vacío río abajo: el diff lo trata como “este artista no tiene lanzamientos”. El resultado queda cacheado con un TTL de 24 horas. Entonces, si la red se cae a mitad de un refresh: cada artista “no tiene lanzamientos”, el diff queda vacío, el feed vacío se cachea sobre el bueno, y la interfaz reporta alegremente “You’re all caught up!” durante el día siguiente.

Una interrupción no parecía una interrupción. Parecía un éxito sin datos, que es exactamente lo que hace try?: vuelve el fallo indistinguible de la ausencia.

La solución

Ahora cada fetch devuelve un Result en lugar de tragarse el error, los fallos se registran por artista, y el barrido reporta si estuvo completo:

private struct CatalogSweep {
    let albums: [LibraryAlbum]
    let isComplete: Bool
}

De ahí salen tres comportamientos. Si todos los artistas fallaron, lanzar un error, el camino de error existente lo maneja: una carga en frío muestra el fallo, un refresh en segundo plano sigue mostrando el feed cacheado. Si algunos fallaron, mostrar el feed parcial (fresco pero parcial le gana a nada) pero no cachearlo, el feed en pantalla se autocorrige en el siguiente refresh, mientras que uno cacheado sería la base durante un día. Si ninguno falló, cachear como antes.

El mock necesitó un seam más para probar esto: inyección de fallos por artista (failingArtistNames: Set<String>), porque la inyección a nivel de método de la ronda anterior solo podía hacer fallar a todos los artistas a la vez. Cuatro pruebas fijan la matriz: fallo total y parcial, en carga en frío y en refresh de segundo plano.

Lecciones

try? en un pipeline de datos significa que el fallo se convierte en dato. Si ese dato alimenta un caché, se construyó un mecanismo para persistir interrupciones como si fueran hechos.

Resiliencia y visibilidad son decisiones separadas. Mantener el barrido con vida más allá de un artista con fallas está bien; fingir que el artista no tenía nada no lo está. Result da ambas cosas.

Y escribir pruebas de fallo es cómo encontré esto. El seam de inyección de errores no solo cubrió las ramas existentes, hizo que notara el único error que nunca llegaba a ninguna rama.

Lecturas relacionadas