try? convirtió una interrupción en "todo al día"
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
Solo arreglé la captura que me pidieron, no las otras que también estaban rotas
Una captura de marketing recapturada parecía terminada, hasta la pregunta desinflante: ¿de verdad esta es toda captura que se había pedido? Las otras tres del mismo conjunto también estaban desactualizadas, cada una a su manera.
Una sección de tracklist, y por qué tomó 30 minutos
Un método de protocolo, un enum de estado reutilizado, una convención de enrutamiento que se mantuvo, y un límite de lint que forzó una división que valía la pena hacer de todos modos.
Las capturas de pantalla de la App Store que nadie actualizó en dos semanas
Dos carpetas que parecen intercambiables y no lo son, un caché JSON que sobrevive la reinstalación, y tres rondas de 'parece terminado' que no lo estaban.