Inicio / Artículos

Dentro de un servicio de notificaciones multi-proveedor: colas, soluciones alternativas y confianza

Un recorrido por el diseño de un servicio de notificaciones fiable: almacenamiento de credenciales, solución alternativa de proveedores, reintentos con BullMQ y listas de espera para mensajes defectuosos, equidad entre usuarios y verificación de firmas de webhook.

Editor: REACTAPP.TOP12 min de lectura2659 palabras

Fuentes: Building NotifyKit: The Engineering Behind Reliable Notifications

Enviar un correo electrónico o un SMS parece ser una tarea sencilla: se llama a la API del proveedor y listo. En entornos de producción, esa única llamada a send() se convierte silenciosamente en un problema de sistemas distribuidos, ya que los proveedores establecen tiempos de espera, imponen límites de frecuencia, se desconectan, entregan los mensajes fuera de orden y las reintentos pueden generar duplicados. Este artículo analiza la arquitectura de un servicio de notificaciones de código abierto y multiinquilino desarrollado con Node.js, BullMQ y Redis, abordando siete áreas: secretos, fallos del proveedor, diseño de colas, ordenamiento, capacidad de entrega, equidad entre inquilinos y verificación de webhook. Para cada una de ellas se explica la decisión tomada, el razonamiento detrás de ella y las limitaciones que se aceptan conscientemente, para que pueda reutilizar ese razonamiento en sus propios sistemas.

Dos tipos de secretos, dos estrategias de almacenamiento

Una plataforma de notificaciones alberga varias credenciales, y la pregunta clave para cada una es sencilla: ¿necesitará el sistema alguna vez recuperar el valor original?

Claves del proveedor: encriptarlas, ya que deben poder recuperarse

Los clientes aportan sus propias claves API del proveedor de correo electrónico, y la plataforma debe presentar esas claves al proveedor en cada envío. Esto descarta el uso de hashing; el valor debe poder recuperarse. El diseño primero valida cada clave con su proveedor y luego la encripta con AES-256-GCM antes de almacenarla. GCM es un modo autenticado, por lo que además de garantizar confidencialidad detecta modificaciones: un texto encriptado que haya sido alterado no se puede descifrar, en lugar de generar una credencial plausible pero incorrecta.

El texto en claro nunca entra en contacto con la base de datos, los registros ni las cachés. Se descifra en exactamente dos lugares: para mostrar una vista previa enmascarada en el panel de control, y dentro del proceso de envío justo antes de la solicitud al proveedor externo. Limitar el descifrado a un par de rutas de código bien definidas facilita enormemente la auditoría en caso de fugas accidentales.

Claves API emitidas por la plataforma: hash, ya que solo es necesario verificarlas

Las claves API que la plataforma emite a sus propios clientes siguen una lógica opuesta. El sistema nunca necesita recuperarlas, solo confirmar que la clave presentada es auténtica. Por lo tanto, la clave se muestra una sola vez al crearla, y solo su hash SHA-256 se guarda. En cada solicitud, la clave recibida se procesa con hash y se compara con el valor almacenado. Una clave perdida no puede recuperarse y debe generarse de nuevo.

Un hash rápido como SHA-256 es adecuado aquí porque las claves son cadenas aleatorias largas con alta entropía. Esa misma elección sería incorrecta para contraseñas humanas, las cuales necesitan un algoritmo deliberadamente lento como bcrypt, scrypt o Argon2 para resistir intentos de adivinación.

Secretos de firma de webhooks: algo intermedio

El secreto utilizado para firmar los webhooks salientes es generado por la plataforma, al igual que una clave API, pero su valor en bruto se necesita cada vez que se firma un webhook, similar a una clave de proveedor. Por lo tanto, se cifra con AES-256-GCM cuando está inactivo, se descifra justo a tiempo para cada operación de firma y solo se mantiene en memoria mientras está en uso.

Sobrevivir a proveedores poco fiables con un orden de respaldo

Cualquier sistema vinculado a un único proveedor hereda las interrupciones, limitaciones de velocidad y tiempos de espera de dicho proveedor. El servicio reduce esa exposición mediante una lista de prioridades por cliente. Un cliente puede registrar varios proveedores, como SendGrid, Resend y Postmark, y asignarles un orden de prioridad. Cuando una solicitud de envío no especifica ningún proveedor, se utiliza esa lista de prioridades, comenzando por el de mayor prioridad.

El sistema recorre la lista. Si un proveedor falla debido a que está inactivo, tiene limitaciones de velocidad o es demasiado lento, el fallo se registra y se prueba el siguiente proveedor. El primer éxito pone fin al proceso, y el cliente nunca se da cuenta de la solución alternativa a menos que todos los proveedores fallen.

También es posible asignar un mensaje específico a un proveedor, opcionalmente con una solución alternativa designada; en ese caso solo se intenta esa combinación. No todos los errores se tratan de la misma manera:

  • Fallas permanentes, como credenciales rechazadas o solicitudes mal formadas, provocan un fallo inmediato, ya que intentar de nuevo no puede solucionarlas.
  • Fallas transitorias, como tiempos de espera, límites de frecuencia y respuestas 5xx del proveedor, se intentan nuevamente con retrocesos temporales.

Cuando toda la lista falla, el error asciende hasta la cola, cuya propia política de intentos múltiples toma el control. La solución alternativa no hace que desaparezcan las fallas del proveedor; simplemente brinda a cada mensaje más oportunidades de tener éxito antes de que el fallo sea visible para el cliente.

Un detalle importante a tener en cuenta: un tiempo de espera no demuestra que el proveedor rechazó el mensaje. Un proveedor puede aceptar una solicitud y luego no responder a tiempo, lo que podría hacer que la misma notificación se envíe dos veces. Utilizar una clave de idempotencia cuando el proveedor la soporta, o aceptar duplicados ocasionales en mensajes de bajo riesgo, son las formas habituales de abordar este problema. Nuestro artículo sobre claves de idempotencia para endpoints POST en Node.js explica este patrón en profundidad.

Arquitectura de colas

Entregar una notificación dentro de la solicitud API que la activó vincula la latencia del solicitante con el proveedor más lento. Un proveedor lento o no disponible mantendrá la llamada HTTP del cliente en espera hasta que finalmente tenga éxito o se agote el tiempo de espera. El servicio evita esto separando la aceptación de la entrega.

Desacoplar la solicitud de la entrega

La API solo valida la solicitud, crea una fila Job en la base de datos y pone el trabajo en cola. BullMQ en Redis se encarga de la programación, con una cola para cada tipo de trabajo:

  • notifications-email para correos electrónicos de los clientes
  • notifications-webhook para webhooks de los clientes
  • platform-email para las alertas internas del propio servicio
  • notifications-failed como cola de correos no entregados para trabajos que han agotado los intentos

Las colas separadas permiten que un tipo de tarea se retrase o se ajuste sin afectar a las demás. Para un análisis más detallado sobre el diseño de tareas en BullMQ, consulte construyendo sistemas de tareas en fondo confiables con BullMQ y Redis.

Las reintentos cuentan los pasos completos, no los proveedores

Cada tarea cuenta con tres intentos con retroceso exponencial, y el detalle importante es qué significa un “intento”. No se trata de un intento por proveedor. Un único intento consiste en probar todos los proveedores del cliente en orden de prioridad, deteniéndose en el primer éxito. Cuando todos los proveedores probados fallan, la tarea ha agotado un intento, y el siguiente intento comienza nuevamente con el proveedor de mayor prioridad. Solo después de tres intentos fallidos se copia la tarea, incluido el error, a la cola de correos no entregados, y la original se marca como FAILED.

La base de datos es la fuente de verdad incluso cuando la cola en sí falla. Un Job del cliente, o un PlatformEmailLog para correos internos, se registra con el estado PENDING antes de que nada sea colocado en la cola. Si Redis no es accesible o al agregar el trabajo se produce un error, este se registra, se informa del fallo y la fila se actualiza a FAILED con el motivo. Ninguna solicitud desaparece silenciosamente entre la API y la cola.

Para qué sirve la cola de correos no entregados

Cuando un trabajo agota sus tres intentos, se crea una copia en notifications-failed que contiene el contenido completo, el error y una marca de tiempo, mientras que el registro en la base de datos se establece en FAILED con el mismo motivo. Registrar el fallo dos veces es intencional, ya que cada registro está dirigido a un público diferente:

  • La fila de la base de datos está orientada al cliente y respalda a GET /jobs/:id.
  • La entrada de DLQ está orientada al operador; es una copia desechable destinada a ser monitoreada y sobre la cual se actuar.

Un DLQ solo es útil si hay algo que lo vigila. Cada tarea que llega dispara una alerta en Slack, reutilizando la ruta de notificación ya establecida para los fallos en el correo electrónico de la plataforma. Eso convierte la cola en una señal de alerta temprana: un aumento repentino de tareas en estado de carta muerta en un corto período suele indicar algún problema sistémico, como una interrupción del proveedor o un despliegue defectuoso.

Nada sale automáticamente del DLQ. Una tarea fallida solo se vuelve a intentar cuando el cliente llama a POST /jobs/:id/retry, lo que la restablece en estado PENDING y reinicia el flujo.

Prioridad y concurrencia

Las tareas tienen una de tres prioridades: CRITICAL, NORMAL o LOW, que se corresponden con el soporte nativo de prioridades de BullMQ. El tráfico del plan gratuito siempre se asigna a LOW para que no compita con el tráfico de pago. Los correos electrónicos de la plataforma ignoran por completo esa lógica: siempre se ingresan como CRITICAL y utilizan un tiempo de espera más corto.

Los grupos de trabajadores extraen tareas continuamente, siendo cada uno ejecutado con una concurrencia de cinco, por lo que hasta cinco tareas por grupo se procesan al mismo tiempo.

Ordenamiento: una brecha intencionada

El servicio no garantiza el procesamiento de los pedidos por cliente. La concurrencia y la prioridad se ajustan para maximizar el rendimiento, y con cinco procesadores en paralelo además de intentos repetidos, dos mensajes enviados en secuencia pueden entregarse fácilmente en orden inverso. Para la mayoría de las notificaciones esto no supone un problema, ya que son independientes. Solo se convierte en un problema para pares relacionados causalmente donde el orden tiene importancia, como un mensaje que lógicamente debería seguir a otro.

Existen dos soluciones generales, cada una con un costo:

  • Serializar por cliente. Dirigir las tareas de un cliente a través de un único canal para garantizar el orden, a costa del paralelismo de dicho cliente.
  • Hacer que los mensajes sean tolerantes al orden. Adjuntar marcas de tiempo y dejar que los consumidores interpreten ellos mismos el orden, lo cual traslada la responsabilidad a cómo los clientes leen las entregas.

La limitación es conocida y fue aceptada en favor del rendimiento. El uso actual aún no justifica ninguno de los costos, por lo que la decisión se pospone hasta que los datos reales de los clientes muestren qué equilibrio es más adecuado. Documentar explícitamente una brecha como esta suele ser mejor que resolverla solo a medias.

Entrega: prevención de fallos antes y después del envío

Los correos electrónicos pueden rechazarse, terminar en la carpeta de spam o desaparecer dependiendo de la reputación del remitente y de las señales que se acumulan con el tiempo. El servicio aborda esto en dos etapas.

Antes del envío: verificación de dominio

Cuando un cliente desea enviar desde su propio dominio, el servicio registra dicho dominio en el proveedor elegido por el cliente y recibe los registros DNS que demuestran la propiedad. Estos registros varían según el proveedor, por lo que se muestran al cliente para que los agregue en su servidor DNS. El envío desde ese dominio permanece bloqueado hasta que se complete la verificación.

Después del envío: acción ante los eventos del proveedor

Los proveedores informan sobre entregas, aperturas, clics, rechazos y quejas por spam a través de webhooks. El servicio almacena estos eventos y responde según su tipo:

  • Un rechazo definitivo (la dirección no existe o se rechaza de forma permanente) suprime automáticamente al destinatario, y los envíos posteriores a esa dirección se bloquean antes de contactar a cualquier proveedor.
  • Una queja por spam provoca la misma supresión.
  • Un bounce suave, como un buzón lleno o un servidor temporalmente inaccesible, solo se registra porque el problema es probablemente temporal.
  • La regla es que solo las señales que indican un problema permanente provocan un bloqueo. Esto no resuelve por completo la capacidad de entrega, ya que la reputación aumenta o disminuye según factores que van más allá de un único mensaje, pero evita que el sistema intente enviar repetidamente a direcciones conocidas por fallar, lo cual es uno de los factores que dañan la reputación.

    Impedir que los usuarios se perjudiquen entre sí

    Todos los clientes comparten las mismas colas, grupos de trabajadores e instancia de Redis; nada está físicamente aislado. La equidad proviene de tres controles aplicados en diferentes etapas.

    Primera barrera: límites de tasa por cliente en la API

    Antes de que una solicitud se convierta en un trabajo, pasa por un límite de velocidad respaldado por Redis por cliente durante un período de 60 segundos, cuyo valor depende del plan:

    • FREE: 5 solicitudes por minuto
    • INDIE: 50 solicitudes por minuto
    • STARTUP: 200 solicitudes por minuto

    Cualquier aumento repentino en las solicitudes desde una cuenta es rechazado de inmediato y nunca sobrecarga la cola.

    Segunda barrera: prioridad una vez aceptado

    Los trabajos aceptados se procesan según su prioridad, y los trabajos del plan gratuito siempre tienen prioridad BAJA. Los procesadores siempre atienden primero los trabajos de mayor prioridad, por lo que incluso si una cuenta gratuita acumula solicitudes pendientes, las notificaciones de los clientes de pago se procesan antes.

    Tercera barrera: cuotas mensuales

    A largo plazo, las cuotas limitan el volumen de solicitudes. El plan gratuito permite enviar un total de 100 correos electrónicos y webhooks al mes. Los planes de pago solo tienen límite en los webhooks; los correos electrónicos no tienen límite para ellos.

    Qué falta aún

    No existe una aislación estricta de recursos: no hay trabajadores dedicados por cliente. Dos clientes del mismo nivel de prioridad comparten la misma capacidad, y dentro de un mismo nivel las tareas se procesan aproximadamente por orden de llegada. Con el volumen actual esto no ha supuesto un problema, ya que el tráfico cabe cómodamente dentro de lo que puede manejar la infraestructura compartida y ningún cliente ha ralentizado significativamente al otro. A mayor escala, sería necesario contar con aislamiento por cliente o un esquema de programación justo (por ejemplo, sistema round-robin entre clientes dentro de un mismo nivel de prioridad).

    Verificación de webhooks en ambas direcciones

    Cada proveedor integrado envía eventos de vuelta a través de webhooks: confirmaciones de entrega, rechazos, informes de spam y cambios en el estado de pago. Cada punto final receptor es una URL pública. Sin verificar al remitente, cualquiera podría publicar un evento falso de “entrega”, falsificar un rechazo para que se suprima la dirección de otra persona, o, lo que es más dañino, falsificar un evento de pago para marcar una suscripción como pagada.

    Entrante: un esquema por proveedor

    No existe un estándar común, por lo que cada integración representa un trabajo independiente de seguridad:

    • SendGrid firma los datos con ECDSA, y la firma se verifica contra la clave pública de SendGrid antes de considerar confiables los datos.
    • Resend envía webhooks a través de Svix, el cual utiliza un esquema HMAC con marca de tiempo que protege contra manipulaciones y retransmisiones no autorizadas.
  • Postmark no firma los webhooks, por lo que su punto de extremo depende de la autenticación básica HTTP con un secreto específico para cada cliente.
  • Los webhooks de pago de Paystack se verifican con HMAC utilizando SHA-512.
  • Los webhooks de pago de Polar se validan con la biblioteca standardwebhooks.
  • El caso de Postmark presenta una trampa fácil de pasar por alto: el secreto debe compararse en tiempo constante, por ejemplo con crypto.timingSafeEqual de Node, y no con una comprobación normal de igualdad de cadenas. Una comparación ordinaria puede terminar en cuanto haya una diferencia de carácter, y esas diferencias de tiempo pueden permitir que un atacante recupere el secreto carácter por carácter en lugar de intentar descifrar todo el valor por fuerza bruta.

    Ninguno de estos esquemas es intercambiable, y cada integración debe ser aprendida e implementada desde cero. La constante que rige en todos ellos es que nada de lo que llega a un endpoint de webhook se procesa hasta que se verifica su origen.

    Salida: hacer que los clientes confíen en la plataforma

    El mismo problema existe al revés cuando el servicio envía webhooks a los endpoints de los clientes. Si un cliente ha configurado una clave de firma, cada entrega incluye un encabezado X-Webhook-Timestamp y un encabezado X-Webhook-Signature. La firma es un HMAC-SHA256 calculado sobre la cadena timestamp.payload, lo que vincula el cuerpo del mensaje a un momento específico. El receptor vuelve a calcular la firma y también rechaza las marcas de tiempo demasiado antiguas, lo que impide que una solicitud capturada sea reproducida posteriormente.

    Los clientes tienen un control total sobre su secreto: pueden generarlo, verificar su estado, rotarlo o eliminarlo. Incluso sin un secreto configurado, los webhooks siguen siendo entregados, aunque sin firmar. Esto favorece la compatibilidad retroactiva, pero también implica que los receptores deben optar por la verificación para estar protegidos. Para una perspectiva complementaria desde el lado receptor, consulte Comprender las claves de idempotencia en los puntos finales POST de Node.js.

    Un fallback mínimo y luego la firma

    deliver recorre los proveedores en orden y devuelve el primero que acepta el mensaje. Reintentar todo el trabajo es decisión de la cola.

    type Provider = {
      name: string;
      send: (to: string, body: string) => Promise<void>;
    };
    
    const deliver = async (providers: Provider[], to: string, body: string) => {
      const errors: string[] = [];
      for (const provider of providers) {
        try {
          await provider.send(to, body);
          return provider.name;
        } catch (err) {
          errors.push(`${provider.name}: ${(err as Error).message}`);
        }
      }
      throw new Error(`all providers failed\n${errors.join('\n')}`);
    };

    La firma cubre timestamp.payload. El receptor rechaza un MAC incorrecto y una marca de más de cinco minutos.

    import { createHmac, timingSafeEqual } from 'node:crypto';
    
    const sign = (secret: string, timestamp: string, payload: string) =>
      createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
    
    const verify = (
      secret: string,
      timestamp: string,
      payload: string,
      signature: string,
    ) => {
      const ageMs = Math.abs(Date.now() - Number(timestamp));
      if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;
      const expected = Buffer.from(sign(secret, timestamp, payload));
      const received = Buffer.from(signature);
      if (expected.length !== received.length) return false;
      return timingSafeEqual(expected, received);
    };

    Puntos clave

    • Elija entre cifrado y hasho al decidir si es necesario recuperar algún momento el valor original.
    • Trate las soluciones de respaldo del proveedor y los intentos de reintentar en colas como capas separadas, y defina con precisión qué significa un “intento”.
    • Escriba un registro duradero en la base de datos antes de insertar el elemento en la cola, para que una interrupción en ella no cause la pérdida de una solicitud.
  • Una cola de correos no entregados solo es útil cuando se generan alertas en ella y existe un camino claro para intentar la entrega nuevamente.
  • Solo suprima las notificaciones en casos de señales permanentes como rebotes definitivos y quejas por spam.
  • Aplique controles de equidad (límites de tasa, prioridades, cuotas) y sea claro respecto a qué tipo de aislamiento aún no se proporciona.
  • Verifique cada webhook entrante utilizando el propio esquema del proveedor, realice comparaciones en tiempo constante y firme los webhooks salientes con una marca de tiempo para evitar su retransmisión.
  • Nombré sus limitaciones conocidas, como el orden de procesamiento, y posponga las soluciones costosas hasta que los datos muestren qué compromiso es adecuado.
  • Lecturas relacionadas

  • Diseñando un pipeline de notificaciones libre de duplicados en SQS, SES y Redis — Cómo un diseño con dos colas en SQS, una solución alternativa de SES a SendGrid y un bloqueo atómico en Redis garantizan que los documentos empresariales lleguen a los destinatarios exactamente una vez, incluso bajo carga.
  • Seis estilos de API comparados: REST, GraphQL, WebSockets, Webhooks, gRPC, SOAP — Aprenda cómo REST, GraphQL, WebSockets, webhooks, gRPC y SOAP resuelven problemas diferentes de intercambio de datos, además de un mapa de decisiones para elegir el más adecuado.
  • Construyendo sistemas fiables de tareas en segundo plano con BullMQ y Redis — Aprenda a diseñar pipelines de tareas en segundo plano resilientes con Node.js utilizando BullMQ y Redis, abordando temas como las reintentos, la concurrencia, la idempotencia y el monitoreo.
  • De módulo a anillo de hash: Escalando una flota de cachés Node.js sin enfrentar tormentas imprevistas — Aprenda por qué el sharding hash-mod-N hace que las bases de datos fallen cuando cambian los nodos, cómo se comparan el hashing por encuentro, salto y anillo, y cómo crear un anillo ponderado equilibrado en Node.js.
  • Envío y recepción de SMS en Node.js con Twilio Webhooks y Express — Crea un pequeño servidor Express que envíe textos a través de Twilio, reciba respuestas mediante un webhook expuesto con ngrok y las responda automáticamente con TwiML.
  • REST frente a GraphQL: Las verdaderas concesiones detrás de cada arquitectura — Explica los problemas específicos que resuelven REST y GraphQL, sus mecanismos internos, y los compromisos ocultos que hay que considerar antes de elegir uno para su API.
  • Notificaciones de React Native: permisos, canales y el ciclo de vida de FCM — Aprende cómo las permisos, los canales de Android, los tokens FCM y los manejadores de estado en primer plano, segundo plano y de cierre se integran en un sistema de notificaciones de React Native con Notifee.