Un aviso que no se puede perder
Conectar con notification-api termina en
requestWithRetry(): dos intentos y, si los dos fallan, se lo traga y lo registra. Esta página
es sobre cuándo eso no basta.
Dónde está el agujero
El stack de notificaciones es fiable a partir de la cola. Una vez el trabajo entra en BullMQ hay tres intentos, backoff, y un log que dice en qué acabó — eso lo cubre la vida de un log.
El tramo que no cubre nadie es el de antes:
tu use case ──HTTP──▶ notification-api ──▶ cola ──▶ worker ──▶ proveedor
└─ aquí ─┘ └──── cubierto por reintentos ────┘
Si esa llamada HTTP falla —el servicio caído, un timeout, la red— y tú ya has commiteado el cambio de estado que la motivaba, el aviso no existe en ninguna parte. No hay trabajo que reintentar porque nunca llegó a encolarse.
stack-viaje-email.mdlo dice desde el otro lado: encolado no es entregado. Esto es el escalón anterior: llamado no es encolado.
Cuándo importa y cuándo no
La mayoría de los avisos no necesitan esto, y por eso el patrón por defecto es el best-effort:
| El aviso | Si se pierde | Qué usar |
|---|---|---|
| “Tienes una cita mañana” | Se pierde uno, y hay más recordatorios detrás | requestWithRetry() |
| Aviso de nuevo login | Molesto, no roto | requestWithRetry() |
| Enlace de activación de cuenta | El usuario no puede entrar, y no hay reintento humano | Outbox |
| Restablecer contraseña | Lo mismo: sin el correo, el flujo queda muerto | Outbox |
El criterio no es la importancia del mensaje, es si existe otro camino para que el usuario acabe recibiéndolo. Un recordatorio perdido se repite solo; un enlace de activación perdido deja una cuenta que nadie puede usar.
El patrón outbox
La idea es no depender de que la llamada HTTP salga bien en ese instante. En lugar de llamar al servicio dentro del use case, escribes la intención en tu propia base de datos, en la misma transacción que el cambio de estado:
await this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { ... } });
// Misma transacción: o se guardan los dos, o ninguno.
await tx.notificationOutbox.create({
data: {
code: 'account-activation',
recipient: user.email,
variables: { activationUrl },
},
});
});
Y un proceso aparte lee las filas pendientes y las entrega, con reintentos, marcándolas cuando
notification-api acepta el trabajo.
Lo que compra es atomicidad: ya no puede pasar que el usuario exista y el aviso no. O falla la transacción entera y no hay usuario, o hay usuario y hay fila pendiente. Que la entrega tarde un minuto más es aceptable; que no ocurra nunca, no.
Lo que esto no es
No es una segunda cola. La cola sigue estando en notification-api y el outbox no la sustituye:
lo único que hace es garantizar que el trabajo llegue a entrar en ella. Son dos tramos distintos del
mismo camino, cada uno con su propio mecanismo de reintento.
Tampoco está implementado hoy en el template. Es el patrón a aplicar cuando aparezca el primer
aviso de la columna “Outbox” de la tabla de arriba; hasta entonces, requestWithRetry() es la
respuesta correcta.