Flujo inbox/outbox
Una vez que POST /fhir responde 202, el mensaje viaja por un pipeline asíncrono con la DB
como fuente de verdad (RabbitMQ es sólo el mecanismo de entrega). Cada etapa persiste su
estado, así nada se pierde si el broker o Proteus se caen.
POST /fhir ──► inbound_message (received)
│
(1) InboundProcessorService
│ explota el bundle → inbound_event
│ corre handlers de dominio (upsert en la DB del gateway)
│ encola en outbound_message (pending)
▼
(2) OutboxRelayService ── lee la DB (pending/failed) → publica a Rabbit (queued)
│
(3) DispatcherService ── consume de Rabbit → POST /inbound del backend
▼
sent / denied / failed → dead_letter
Las tres etapas
| Servicio | Rol | Cómo se dispara |
|---|---|---|
InboundProcessorService | Explota bundles en eventos, corre handlers, encola al outbox | Consumer Rabbit + poller fallback (runOnce) |
OutboxRelayService | Lee la DB y publica a Rabbit los pending/failed (DB→Rabbit) | Poller (runOnce) |
DispatcherService | Consume de Rabbit y hace el HTTP al backend (Rabbit→backend) | Consumer Rabbit |
El relay existe para que Rabbit no sea la fuente de verdad: si el broker se cae, la DB conserva el estado y el relay re-publica cuando vuelve.
Un Encounter explota en varios eventos
El bundle-exploder deriva eventos a partir de cada recurso. Un Encounter con extensión
authorization produce dos eventos: el episodio (EncounterOpen/Close/Deactivate) y
un evento Authorization aparte. Cada evento se convierte en un outbound_message propio, así
que el backend recibe dos POST /inbound: aggregateType: "encounter" y
aggregateType: "authorization". El backend hace no-op del segundo (ver
El endpoint /inbound).
Dos capas de idempotencia
Son independientes; entender la diferencia evita confusiones al re-enviar:
| Capa | Clave | Efecto al repetir |
|---|---|---|
| Inbound message | (clientId, x-source-message-id) | 202 con duplicate: true, no reprocesa |
| Inbound event | (clientId, eventType, identidad-de-negocio) | El evento se saltea (skipped), no se re-encola |
La identidad de negocio del evento es el número externo (episodio, OP, etc.), no el
x-source-message-id. Por eso, para reprocesar de verdad hay que cambiar la identidad de
negocio, no sólo el header.
Estados del outbound_message
pending ──relay──► queued ──consumer──► sending ──► sent
failed ──relay──► queued ──► dead_letter
| Estado | Significado |
|---|---|
pending | Encolado, esperando que el relay lo publique |
queued | Publicado a Rabbit |
sending | El dispatcher lo está enviando al backend |
sent | Backend respondió 2xx |
denied | Backend respondió 4xx (regla de negocio) — terminal, no se reintenta |
failed | 5xx o error de red — el relay lo reintenta |
dead_letter | Agotó los reintentos (OUTBOUND_MAX_ATTEMPTS) |
El /inbound del backend devuelve status HTTP real (a diferencia del resto de Proteus, que
responde 200 con {success:false}), precisamente para que el dispatcher lo interprete: 2xx
= enviado, 4xx = rechazo de negocio (no reintentar), 5xx = transitorio (reintentar).
denied no se recupera re-enviando el mismo recursoComo denied es terminal y la capa de idempotencia de eventos saltea la misma identidad de
negocio, re-postear el mismo recurso no lo reprocesa. Recuperar un denied (ej.: se
arregló el catálogo de planes que faltaba) requiere re-encolar el outbound_message o limpiar
la dedup del evento. Es un caso operativo pendiente de resolver con un mecanismo de reintento.
Estado agregado para el cliente (GET /fhir/{id})
El cliente consulta el desenlace de un envío con GET /fhir/{id} (scopeado por clientId; ver
la doc de cliente en docs-api). El estado público (accepted/processing/done/rejected/
failed/duplicate) se deriva de los hijos outbound_message, no del inbound_message.
finished del inbound_message no es el agregadoUn inbound_message explota en N eventos → N outbound_message (ej.: un Encounter con
extensión authorization → encounter + authorization). El inbound_message pasa a finished
en cuanto el primer hijo se envía (dispatcher.service.ts), así que no garantiza que todos
los hermanos hayan salido bien. El agregado real reduce sobre los hijos: cualquier denied →
rejected; cualquier dead_letter → failed; todos sent → done; si no, processing.
El motivo de un rejected sale de outbound_message.lastError, que el dispatcher completa con el
message de negocio del backend (no el body completo → sin PHI).