Skip to main content

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

ServicioRolCómo se dispara
InboundProcessorServiceExplota bundles en eventos, corre handlers, encola al outboxConsumer Rabbit + poller fallback (runOnce)
OutboxRelayServiceLee la DB y publica a Rabbit los pending/failed (DB→Rabbit)Poller (runOnce)
DispatcherServiceConsume 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:

CapaClaveEfecto 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
EstadoSignificado
pendingEncolado, esperando que el relay lo publique
queuedPublicado a Rabbit
sendingEl dispatcher lo está enviando al backend
sentBackend respondió 2xx
deniedBackend respondió 4xx (regla de negocio) — terminal, no se reintenta
failed5xx o error de red — el relay lo reintenta
dead_letterAgotó los reintentos (OUTBOUND_MAX_ATTEMPTS)
Semántica del HTTP que devuelve el backend

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).

Un denied no se recupera re-enviando el mismo recurso

Como 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.

El finished del inbound_message no es el agregado

Un inbound_message explota en N eventos → N outbound_message (ej.: un Encounter con extensión authorizationencounter + 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 deniedrejected; cualquier dead_letterfailed; todos sentdone; 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).