Lección 06

¿Por qué un pico de webhooks tumba el servicio?

Un webhook es un aviso que llega por HTTP sin que lo pidas. Si el handler lo procesa antes de contestar, el pico no rompe la interfaz: rompe la tubería que la alimenta.

01Qué es un webhook y qué es aceptar

Un webhook es una petición HTTP que otro servicio te manda cuando algo pasa; no la pediste, llega. Del lado tuyo hay un handler, la función que atiende esa ruta, con dos trabajos que conviene separar: aceptar (verificar la firma y guardar el aviso tal cual) y procesar (interpretarlo, calcular, escribir en tus tablas).

Si hace las dos antes de responder, cada aviso ocupa un lugar mientras dura el trabajo. Con 5.000 avisos en un minuto (~83 por segundo) y ~40 ms por aviso hay ~4 en vuelo; un pico de diez veces pide ~33 y el servidor no los tiene. Los que no caben esperan: esa espera es la latencia del panel.

Aceptar no es procesar

Aceptar significa que el aviso está en un lugar durable y alguien lo va a trabajar; procesar, que el efecto ya ocurrió. Un 200 sin procesar es una mentira que el emisor no distingue de la verdad, y por eso no reintenta.

Es como el mesero que clava la comanda en el riel y vuelve a la mesa siguiente. Si cocinara cada plato antes de tomar la siguiente comanda, la fila se saldría a la calle aunque la cocina estuviera libre. El riel es la cola (lista durable de trabajo pendiente), la cocina es el worker (proceso aparte que saca trabajo y lo hace) y el "quedó anotado" es el 202: aceptado, todavía no hecho.

La tubería: el request termina en la etapa 1 y el trabajo sigue solo ingesta 202 cola workers almacén agregado API UI 1 2 3 4 5 6 7 pico: 5.000 avisos por minuto cuello: el request no termina hasta que el trabajo termina
La etapa 1 solo acepta; el recuadro rojo es el cuello que desaparece cuando la ingesta no procesa dentro del request.

02Por qué sucede: reintentos, duplicados y límites

Los webhooks se reintentan: el emisor no sabe si tu respuesta se perdió y, si tarda, asume lo peor. El reintento puede llegar después de que ya procesaste: la misma visita cuenta dos. La idempotencia evita eso: procesar dos veces el mismo event_id deja el mismo resultado que una. Se guardan los ids procesados (o una restricción única): el reintento se descarta.

Cuando llega más de lo que puedes procesar, algo cede: ese es el backpressure. Rechazar con 429 o 503, descartar lo menos valioso contando cuánto tiraste, o dejar crecer la cola. La tercera parece amable y es la peor: sin límite se come la memoria del servicio de colas y convierte el pico en una caída. Si entran 5.000 por minuto y drenan 800, la cola crece 4.200 cada minuto.

La misma entrada, dos políticas de cola cola sin límite 5.000/min entran, 800/min salen 252.000 esperando en una hora cola con drenaje 4 workers, límite explícito la cola vuelve cerca de cero
Misma entrada, distinta política: cuántos drenan y si la cola tiene techo.
El reintento se reconoce por event_id y no repite el efecto dos entregas del mismo aviso tabla de event_id un solo efecto event_id 7f3a primera entrega event_id 7f3a reintento id nuevo → procesa id repetido → descarta guarda cada id antes de aplicar efecto aplicado una sola vez reintento descartado
La segunda entrega no se distingue a ojo: se reconoce por su id. Guardar los ids procesados convierte el reintento en un evento que ya no cambia nada.

La otra decisión es cuándo sumas. Sumar 40 millones de filas en cada consulta paga el costo siempre; con un contador por minuto, el panel de 24 horas lee 1.440 filas. Eso es agregar al escribir. Pierdes libertad para rebanar después, así que se combina con retención por capas: crudo 7 días, agregados por minuto 90 días, por día 2 años.

03Cómo se ataca: aceptar, encolar y decir la verdad

El handler mínimo no procesa: firma, valida lo justo para encolar y contesta 202. El worker hace el resto, fuera del request.

// POST /webhooks/pedidos: acepta y encola.
app.post('/webhooks/pedidos', async (req, res) => {
  // Firma: sin esto, cualquiera inventa avisos.
  if (!verificarFirma(req)) {
    return res.status(401).json({ error: 'firma' });
  }

  // Validación mínima: lo justo para encolar.
  const { event_id, tipo, datos } = req.body || {};
  if (!event_id || !tipo) {
    return res.status(400).json({ error: 'faltan datos' });
  }

  // Clave de deduplicación: un reintento no duplica.
  await cola.enviar('pedidos', { event_id, tipo, datos }, {
    clave: event_id,
  });

  // 202: aceptado, todavía no hecho.
  return res.status(202).json({ ok: true, event_id });
});

El contrato es lo que el frontend hereda: la cola, los workers y el agregado son backend y no corren en el navegador. Si la ingesta devuelve id y estado, la UI puede mostrar "en cola" en vez de mentir con un "listo"; si el agregado trae as_of y lag_ms, puede decir "actualizado hace 2 minutos". Un frontend senior lo necesita porque el contrato decide qué puede hacer la interfaz.

04Cómo diagnosticarlo

HerramientaQué se miraCómo se ve el síntoma
Métricas del endpoint p50 y p99 por separado. p99 alto con p50 estable: cola delante. Si suben los dos, saturación.
Panel de la cola Profundidad y edad del mensaje más viejo. Sube con el pico y no baja; la edad pasa del minuto.
Logs y trazas 429, 503 y 5xx propios. 429 frena al emisor; 503 con cola vacía: sin capacidad.
Tabla de deduplicación Conteo por event_id. El mismo id dos o tres veces: el efecto se aplicó varias.
Traza del request Si el span de proceso vive en el handler. Dura lo que el trabajo: no hay 202 real, hay un 200 lento.

p50 en ~80 ms con p99 en 8 s no es código lento para todos: hay una cola delante.

Para decirlo en la entrevista

“The handler checks the signature, validates the shape and enqueues; it returns 202 in milliseconds.”

“Every retry is deduplicated on event_id, so processing twice has the same effect as processing once.”

“Under backpressure we shed low-value events and return 429 with Retry-After instead of growing an unbounded queue.”

Comprueba que lo tienes

Cuatro preguntas. Al comprobar se marca la lección como completada.

Tu endpoint responde 202 y encola. ¿Qué significa ese 202 para el emisor?

El emisor reintenta por timeout, pero ya habías procesado. ¿Qué evita que la lectura se cuente dos veces?

La cola crece más rápido de lo que drena y la memoria llega al límite. ¿Cuál es la peor salida?

El panel pide "15 minutos" y la tabla cruda tiene 40 millones de filas. ¿Qué cambia el contrato?