¿Cómo orquestás múltiples llamadas asíncronas que necesitan abrirse en abanico (fan-out) y luego converger (fan-in), como `Promise.all`?
El fan-out despacha llamadas asíncronas independientes a la vez; el fan-in las espera y las combina — y la verdadera pregunta de diseño es qué pasa cuando solo algunas tienen éxito.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo orquestás múltiples llamadas asíncronas que necesitan abrirse en abanico (fan-out) y luego converger (fan-in), como `Promise.all`?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#El Patrón
"Fan-out, fan-in" describe cualquier punto de un sistema donde despachás varias operaciones asíncronas independientes de forma concurrente (fan-out) y después esperás a que todas terminen antes de seguir, típicamente combinando sus resultados en un único valor (fan-in). Aparece constantemente en capas de backend-for-frontend y API gateway: armar una página de producto significa llamar a un servicio de inventario, uno de precios y uno de reseñas — ninguno depende del resultado de los otros — y nada de eso debería pasar secuencialmente si las tres llamadas pueden correr al mismo tiempo.
#Promise.all Falla Rápido — A Veces Eso Está Mal
Promise.all (y sus equivalentes en otros lenguajes — awaitAll de Kotlin, un asyncio.gather simple en Python) rechaza en cuanto cualquiera de las promesas de entrada rechaza, y descarta lo que las demás promesas hubieran resuelto. Ese es el comportamiento correcto cuando cada resultado realmente hace falta para seguir — no tiene sentido construir una respuesta si falta una pieza obligatoria. Es el comportamiento incorrecto cuando un resultado parcial igual es útil: una página de producto que puede renderizarse con un placeholder de "reseñas temporalmente no disponibles" es estrictamente mejor para el usuario que una página de producto que directamente no carga porque el servicio de reseñas tuvo un problema momentáneo.
#Promise.allSettled para Tolerancia a Fallas Parciales
Promise.allSettled siempre resuelve — nunca rechaza — una vez que cada promesa de entrada se cumplió o rechazó, y te devuelve el resultado de cada una individualmente para que decidas, llamada por llamada, qué debería significar una falla para el resultado combinado:
interface ServiceResult<T> {ok: boolean;value?: T;error?: string;}async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {let timer: ReturnType<typeof setTimeout>;const timeout = new Promise<T>((_, reject) => {timer = setTimeout(() => reject(new Error('timed out after ' + ms + 'ms')), ms);});try {return await Promise.race([promise, timeout]);} finally {clearTimeout(timer!);}}async function getProductPage(productId: string) {const [inventory, pricing, reviews] = await Promise.allSettled([withTimeout(inventoryService.get(productId), 300),withTimeout(pricingService.get(productId), 300),withTimeout(reviewsService.get(productId), 300),]);return {inventory: inventory.status === 'fulfilled' ? inventory.value : null,pricing: pricing.status === 'fulfilled' ? pricing.value : null,reviews: reviews.status === 'fulfilled'? reviews.value: { items: [], note: 'temporarily unavailable' },degraded: [inventory, pricing, reviews].some((r) => r.status === 'rejected'),};}
Acá importan dos detalles más allá de allSettled en sí. Primero, cada llamada está envuelta individualmente con su propio timeout — sin eso, una dependencia lenta (ni siquiera una que falle, solo una lenta) estancaría todo el fan-in más allá de cualquier presupuesto de latencia que tenga el endpoint, porque allSettled igual espera a que cada promesa se resuelva de una forma u otra. Segundo, el flag degraded expone la falla parcial al llamador y al monitoreo, en lugar de devolver silenciosamente una página que parece completa pero le falta información — un entrevistador suele preguntar específicamente si dejarías pasar una falla parcial en silencio.
#Acotando el Fan-Out
El fan-out es seguro cuando el número de llamadas concurrentes es chico y fijo — tres servicios para una página. Deja de ser seguro en cuanto el tamaño del fan-out depende de los datos: traer registros de detalle para 5.000 IDs de orden disparando 5.000 requests a la vez muy probablemente va a saturar el servicio downstream (o tu propio pool de conexiones salientes) aunque cada llamada individual esté bien de forma aislada. La solución es un limitador de concurrencia — un número fijo y chico de workers que van tomando trabajo de una cola compartida, en lugar de una Promise por elemento:
async function mapWithConcurrency<T, R>(items: T[],limit: number,fn: (item: T) => Promise<R>,): Promise<R[]> {const results: R[] = new Array(items.length);let nextIndex = 0;async function worker(): Promise<void> {while (nextIndex < items.length) {const current = nextIndex++;results[current] = await fn(items[current]);}}const workerCount = Math.min(limit, items.length);await Promise.all(Array.from({ length: workerCount }, () => worker()));return results;}
Cada worker toma el siguiente índice de forma atómica (JavaScript es single-threaded, así que incrementar nextIndex entre puntos await no puede tener carrera), escribe en su propia posición del arreglo de resultados, y todo el conjunto resuelve recién cuando cada elemento fue procesado — fan-out acotado a limit llamadas concurrentes, fan-in a través del arreglo compartido.
#La Versión de Go: Fail-Fast Explícito vs. Tolerancia Parcial Explícita
Go no tiene un Promise.all incorporado, lo que obliga a que la decisión entre fail-fast y tolerancia parcial sea explícita en lugar de implícita según qué función llamás. golang.org/x/sync/errgroup te da una semántica fail-fast cercana a Promise.all: la primera goroutine que devuelve un error cancela un contexto compartido y Wait() devuelve ese error, descartando el resto. Para tolerancia a fallas parciales, en cambio, capturás un resultado por elemento (incluyendo su propio error) y usás un sync.WaitGroup simple, así ninguna falla individual corta al grupo:
type result struct {value stringerr error}func fetchAllDetails(ids []string) []result {results := make([]result, len(ids))var wg sync.WaitGroupfor i, id := range ids {wg.Add(1)go func(i int, id string) {defer wg.Done()ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)defer cancel()value, err := fetchDetail(ctx, id)results[i] = result{value: value, err: err}}(i, id)}wg.Wait()return results}
Cada goroutine escribe solo en su propio índice de results, así que el fan-in no necesita ningún mutex — no hay contención de escritura compartida porque cada escritor es dueño de un elemento disjunto del slice. Esta es la forma de allSettled: nada acá cancela a una goroutine hermana cuando otra falla, y el llamador decide después, elemento por elemento, qué debería significar un err no nulo para la respuesta final.