Ir al contenido

Streams SSE y WebSocket

Un stream es una conexión larga por la que el motor te manda los eventos del ledger a medida que llegan. Sirve cuando tu proceso no puede recibir conexiones (un worker sin puerto, un proceso detrás de un NAT): en lugar de una URL de entrada, guardas un cursor y reanudas desde ahí.

El stream lee del ledger, no de una cola en memoria: un corte, un reinicio del motor o un cliente lento no pierden eventos. Reanudar con tu cursor no salta ni repite ninguno. Cada evento llega con el mismo sobre que un webhook, con la misma redacción por retención.

Ruta Transporte Qué eventos
GET /v1/events/stream SSE Los de todas las instancias que alcanza tu key.
GET /v1/events/ws WebSocket Ídem.
GET /v1/instances/{id}/events/stream SSE Los de una instancia.
GET /v1/instances/{id}/events/ws WebSocket Ídem.
GET /v1/events/streams JSON Los streams de tu app abiertos en este proceso y sus últimos cierres.

Todas piden el scope events:read.

Parámetro Qué es
desde (query) El cursor desde el que reanudar. Gana sobre Last-Event-ID. En el stream de una instancia también puede ser un seq.
Last-Event-ID (encabezado) El cursor del último evento recibido. Un cliente SSE lo manda solo al reconectar.
types Tipos separados por coma, hasta 20. Vacío: todos.
instances Solo en el stream de la app: IDs de instancia separados por coma. Vacío: todas las que alcanza la key.
contrato La versión del contrato de eventos que esperas (hoy, 1). Una que el motor no sirve es 422.

Sin cursor, el stream empieza desde ahora: lo que ya estaba confirmado no se manda.

La key va en el encabezado Authorization: Bearer TU_API_KEY, igual que en el resto de /v1; en WebSocket, en el handshake. No hay otra forma: el motor no acepta la key en la query.

El cuerpo es text/event-stream, con tres tipos de trama y comentarios de keepalive:

retry: 3000
id: mw1.AQ…
event: listo
data: {"contrato":"1","stream_id":"…","cursor":"mw1.AQ…","keepalive_segundos":15,"instancias":[{"instance_id":"…","seq":1186,"ultimo_seq":1186,"atraso":0}]}
id: mw1.AQ…
event: evento
data: {"schema_version":1,"event_id":"…","instance_id":"…","seq":1187,"type":"message.received",…}
: keepalive
event: cierre
data: {"motivo":"apagado","reanudable":true,"cursor":"mw1.AQ…"}
Trama Qué trae
listo La primera. Su id: es el cursor de partida; data dice el contrato, el stream_id, cada cuánto llega el keepalive y la posición de cada instancia (seq entregado, ultimo_seq del ledger y atraso).
evento Un evento. Su id: es el cursor después de este evento; data es el sobre.
: keepalive Un comentario, cuando no hubo nada durante el intervalo del keepalive.
cierre La última, cuando el motor corta: motivo, si es reanudable y el cursor.

Los mismos datos, como mensajes JSON de texto. El cliente no manda nada: el motor solo atiende los pings y el cierre.

{"tipo": "listo", "id": "mw1.AQ…", "listo": {"contrato": "1", "cursor": "mw1.AQ…", "keepalive_segundos": 15, "…": "…"}}
{"tipo": "evento", "id": "mw1.AQ…", "evento": {"schema_version": 1, "event_id": "…", "seq": 1187, "…": "…"}}
{"tipo": "keepalive"}
{"tipo": "cierre", "cierre": {"motivo": "apagado", "reanudable": true, "cursor": "mw1.AQ…"}}

El frame de cierre lleva el motivo: credencial_revocada y sin_scope cierran con el código 1008 (policy violation); apagado, cortado_por_operador y error_interno, con 1001 (going away); un cliente lento se corta sin frame de cierre.

El cursor es un texto opaco y firmado (empieza con mw1.): dice, por cada instancia, el último seq entregado. Es de tu app: uno de otra app es 403 y uno alterado o que adelanta al ledger es 422. No lo armes ni lo modifiques.

La regla para no perder ni repetir:

  1. Procesa el evento (idempotente por event_id).
  2. Después, guarda su cursor (el id de la trama).
  3. Al reconectar, manda el último guardado en Last-Event-ID o en ?desde=.

Si tu proceso se cae entre el paso 1 y el 2, al reanudar recibes ese evento otra vez: por eso el paso 1 es idempotente.

En el stream de una instancia, desde acepta también un seq simple (?desde=1186 empieza en el 1187), útil para enganchar el stream después de leer el ledger con GET /v1/instances/{id}/events.

El motor sirve un stream desde lo que queda dentro de la retención de reanudación (30 días por omisión; streams.dias_reanudacion). Si tu cursor tiene eventos sin leer más viejos que eso (o ya purgados), el motor no salta en silencio: responde 410 con el primer cursor disponible.

410 Gone
{
"title": "Gone",
"status": 410,
"detail": "El cursor tiene eventos sin leer más viejos que la retención del stream. Para seguir, reanuda con primer_cursor: los eventos anteriores ya no se pueden leer por el stream.",
"primer_cursor": "mw1.AQ…",
"instancias_vencidas": ["8a1d2c3e-0000-4000-8000-0000000000a1"]
}

Reanudar con primer_cursor es aceptar el salto en las instancias de instancias_vencidas. Antes, decide si necesitas lo que falta y cómo recuperarlo.

motivo ¿Reanudable? Qué pasó Qué hacer
apagado Sí El motor se reinicia. Reconecta con tu cursor.
cortado_por_operador Sí Un operador cortó el stream desde el panel. Reconecta con tu cursor.
error_interno Sí Un error del motor. Reconecta con backoff.
cliente_lento Sí No leíste a tiempo: tu buffer se llenó o una escritura se trabó. Se corta sin trama de cierre. Reconecta y lee más rápido (procesa fuera del bucle de lectura).
credencial_revocada No La key se revocó, venció o la app se suspendió. Usa otra key.
sin_scope No La key perdió events:read. Pide el scope.

Un corte de red, sin trama de cierre, también es reanudable. Reconecta con un backoff que crezca (el SDK de Go va de 1 s a 30 s). La trama listo de SSE trae retry: 3000, la espera que usa un cliente SSE estándar.

Un stream abierto vuelve a validar su key cada 5 segundos, y al instante si la revocación pasa en el mismo proceso. Si la key se revocó, venció, perdió el scope o la app se suspendió, el stream se corta con credencial_revocada o sin_scope, sin reanudación: reconectar con la misma key responde 401 o 403.

Si no hubo eventos durante el intervalo (15 s por omisión; streams.segundos_keepalive, de 5 a 120), el motor manda un keepalive. listo dice el intervalo en keepalive_segundos. Si pasan dos intervalos sin recibir nada, da la conexión por muerta y reconecta con tu cursor.

Límite De fábrica Clave en Ajustes Al pasarlo
Streams abiertos por app 5, en cada proceso del motor streams.conexiones_por_app 429 al abrir otro.
Buffer por conexión 1024 KB streams.kb_buffer Se corta con cliente_lento.
Espera a un cliente lento 10 s streams.segundos_cliente_lento Se corta con cliente_lento.
Retención de reanudación 30 días streams.dias_reanudacion 410 con primer_cursor.

Al abrir, un stream puede responder además 401 (la key), 403 (sin events:read o un cursor de otra app), 404 (la instancia no existe para tu app), 422 (un cursor inválido, un contrato que no existe, un ID de instances inválido o más de 20 tipos) y 503 (el motor se está reiniciando: vuelve a conectar con tu cursor).

GET /v1/events/streams muestra tus streams abiertos en ese proceso, con su posición y su atraso por instancia, y los últimos cierres con su motivo. Úsalo para saber por qué se cortó uno.

Dentro de una instancia, los eventos llegan en orden de seq. Entre instancias, en el orden en que el motor los recibió (received_at). Si filtras con types, verás saltos en seq: son los eventos de otros tipos.

Ventana de terminal
# -N: sin buffer, para ver las tramas a medida que llegan.
curl -N "https://motor.ejemplo.com/v1/events/stream?types=message.received,message.updated" \
-H "Authorization: Bearer TU_API_KEY" \
-H "Accept: text/event-stream"
# Reanudar desde el último cursor procesado:
curl -N https://motor.ejemplo.com/v1/events/stream \
-H "Authorization: Bearer TU_API_KEY" \
-H "Last-Event-ID: CURSOR_GUARDADO"

Para WebSocket no hay ejemplo con dependencias cero: la biblioteca estándar de Go no trae un cliente WebSocket y el WebSocket estándar no manda encabezados. Usa un cliente que permita Authorization en el handshake y trata cada mensaje según su tipo, con la misma regla del cursor.