Cómo no romper tu sistema metiendo todo en un endpoint

Cómo no romper tu sistema metiendo todo en un endpoint

Primero lo primero: ¿qué carajos es un side-effect?

No, no hablamos de medicina. En programación, un side-effect es cualquier cosa que sucede como consecuencia de tu acción principal, pero que no es la acción principal en sí.

Ejemplo rápido: un usuario hace un pedido en tu app.
La acción principal es guardar el pedido en la base de datos.
Pero después vienen los “extras”:

  • Mandar un correo de confirmación ✉️
  • Restar el inventario 📦
  • Avisar al sistema de pagos 💳
  • Notificar a logística 🚚
  • Sumarte puntos de lealtad ⭐️
  • Y si andas de creativo, hasta disparar confetti en la oficina 🎉

Todo eso son side-effects. Necesarios, sí. Pero si los metes todos en tu endpoint, vas directo a un ataque de nervios.

“Endpoint burrito” (porque le metes de todo y ya ni cierra 🌯😂)

Cuando sobrecargas un endpoint de side-effects, pasa lo mismo que cuando tu tía quiere meter 30 tuppers en el refri chico: simplemente no cabe y algo va a tronar.

Cada side-effect tiene cierta probabilidad de fallar (digamos 0.5%). Poquito, ¿no? Sí, pero súmalos todos: 10 efectos y ya tienes un 5% de error en tu endpoint crítico. ¿Quieres que 5 de cada 100 pedidos se pierdan en el limbo? No lo creo.

Y el tiempo… uff. Si una API responde en 30ms, multiplícalo por 10 side-effects y ya vas en 300ms mínimo. Y eso si no usas Python con Flask, porque ahí se siente eterno para I/O pesado.

Aquí entran los eventos como superhéroes 🦸‍♂️

La solución clásica es: en lugar de meter todo en el endpoint, dispara un evento y que los sistemas alrededor hagan su magia.

Un pedido se crea → un evento vuela al aire → cada servicio escucha y actúa.

El endpoint sigue limpio, rápido, y tú no lloras en silencio.

Tu primera versión puede ser tan sencilla como esto:

async function placeOrder() {
  // acción principal
  await saveOrderToDatabase()

  // en lugar de hacer TODO aquí, lo mandamos a la cola
  await Promise.allSettled([
    schedule(effect1),
    schedule(effect2),
    schedule(effect3),
    // y los que vengan...
  ])
}

Cada schedule() mete una tarea en la cola. Y cada tarea tiene su propio consumidor. Si algo falla, la cola lo guarda y lo reintenta.

Queue processing: tu nuevo mejor amigo

La gran ventaja de meter tus side-effects a una cola: persistencia.
Tus tareas no se pierden si algo truena.

Celery, Kafka, RabbitMQ… elige tu veneno. Todos cumplen con la gracia de guardar, reintentar y darte paz mental.

Y ojo con los poison pills: esas tareas que nunca van a funcionar porque traen bug. Ahí sí necesitas alertas. “Si falló más de X veces, despierta al dev de guardia”.

¿El sistema completo se cayó por horas? No hay bronca. Cuando revive, la cola sigue procesando. Eso sí: cuidado con el stampede. Todas las tareas esperando para correr al mismo tiempo pueden ser peor que la caída inicial.

El stampede es justo lo que suena: una estampida 🐂.

En sistemas con colas pasa cuando tu aplicación estuvo caída un rato, la cola siguió acumulando tareas pendientes, y cuando el sistema revive… ¡zas!, todas las tareas quieren correr al mismo tiempo.

Ejemplo rápido:

  • Tu tienda online estuvo caída 3 horas.
  • Durante ese tiempo, se generaron 10,000 pedidos que la cola guardó.
  • Levantas los servidores y, de golpe, los workers arrancan a procesar esos 10,000 pedidos como si no hubiera mañana.
  • Resultado: tu base de datos y tus APIs de terceros reciben un tsunami de requests y se te cae todo otra vez.

Es como cuando en un concierto se abre la puerta de seguridad y toda la multitud entra corriendo al mismo tiempo. No falla por “un error de software” en sí, sino porque la carga se concentra en un instante.

Tareas: flaquitas pero inteligentes

La receta:

  • Scheduler anémico → no hace lógica, solo mete a la cola y loguea:
function scheduleTask(task) {
  logger.log("Scheduling task", task)
  queue.push(task)
}
  • Task mínima → solo datos clave:
const task = {
  event: "order_placed",
  order_id: 123,
}

Nada de meter objetos completos, solo la referencia.

  • Task function inteligente → hace el trabajo real, consulta DB, valida, ejecuta y lanza error si falla:
function doTheTask(task) {
  logger.log("Checking task", task)
  const order = db.query(`select * from orders where id=${task.order_id}`)
  
  if (order.task_not_done_yet) {
    logger.log("Doing task", task)
    // hacer la chamba
    // THROW en error para que la cola reintente
    
    logger.log("Finished task", task)
  } else {
    logger.log("Skipping task", task)
  }
}

¿Clave aquí? Loguea todo. Puede sonar ridículo, pero esos logs tontos son lo que te salva en una madrugada de producción.

Backup scheduling: el seguro de vida

Debes poder reconstruir la cola desde tu DB. Algo como:

function rebuildQueue() {
  const orders = db.query(
    `select id from orders where task_not_done_yet`
  )
  for (const order of orders) {
    await scheduleTask(order)
  }
}

Córrelo cada hora, incluso puedes programarlo como otra tarea en la cola. Eso es el patrón fan-out: una tarea que lanza otras tareas.

¿Sirve de qué?

  • Porque garantizar exactly-once delivery es imposible.
  • Porque si pierdes la cola, puedes reconstruirla.
  • Porque si algo no se programó, lo reprogramas.

Y para evitar la estampida de datos, es importante:

  • limitar cuántas tareas procesas en paralelo,
  • usar backoff exponencial en los reintentos,
  • o escalonar el arranque de tus workers.

¿Por qué sirve el Backoff exponencial?

Si un servicio externo está caído o saturado, reventarlo con miles de retries inmediatos solo lo hunde más (y de paso tira tu propio sistema). El backoff da “espacio para respirar”. El Backoff significa que, cuando algo falla y lo vas a reintentar, no lo hagas de inmediato como loco, sino que esperes un tiempo antes de intentarlo otra vez.

Lo básico es así:

  1. Intentas la tarea → falla.
  2. Esperas un ratito (ej. 1 segundo).
  3. Vuelves a intentar → falla otra vez.
  4. Ahora esperas más tiempo (ej. 2 segundos).
  5. Vuelves a intentar → si sigue fallando, esperas aún más (4s, 8s, 16s…).

Ese patrón se llama exponential backoff (porque el tiempo de espera se va multiplicando).

En resumen, compa

Los side-effects son inevitables. Son como los cuñados: aunque no los planeaste, siempre aparecen.

El truco no es eliminarlos, sino darles su espacio en el sistema.
Tu endpoint solo dispara el evento. La cola guarda, reparte, reintenta y organiza el desmadre.

Resultado: endpoints rápidos, sistemas menos frágiles y devs con menos ojeras.

Y recuerda: el día que todo falle, siempre puedes decir que fue culpa del becario.

Nos vemos pronto 👋