Idempotent Receiver no Mule: cobrar o pedido uma vez só
- 12 min de leitura
Nesta página
- O pedido que chega duas vezes
- O padrão no livro: lembrar o que já foi processado
- O receptor diante da mesma mensagem duas vezes
- No Mule: o validador com a chave do pedido
- O flow de cobrança com o validador
- Antes e depois: do correlationId ao orderId
- A armadilha: o ID fica guardado quando a cobrança falha
- Escolher a chave, o tempo e o store
- Quando não usar: o destino já é idempotente
- Rodar o exemplo e os testes
Código
Esta página implementa o padrão Idempotent Receiver no Mule 4 com o Idempotent Message Validator, num exemplo que roda e com testes MUnit que fixam o comportamento. É para quem já monta flows e precisa garantir que um pedido repetido não seja cobrado duas vezes.
O pedido que chega duas vezes
O livro Enterprise Integration Patterns parte de uma constatação: mesmo quando o remetente envia uma mensagem uma única vez, o receptor pode recebê-la mais de uma vez. A pergunta do padrão é como o receptor lida com essas duplicatas (Idempotent Receiver).
No cenário de pedidos da série, quem sente isso é o consumidor de pagamento. Um cliente sem resposta tenta de novo, um gateway repete a chamada, alguém reprocessa mensagens depois de um incidente. É sempre o mesmo pedido, e ele tem de ser cobrado uma vez só.
O padrão no livro: lembrar o que já foi processado
O livro chama de idempotente a mensagem que produz o mesmo efeito recebida uma ou várias vezes, e aponta dois caminhos: remover as duplicatas de forma explícita, ou definir a mensagem de um jeito que repeti-la não cause problema (Idempotent Receiver). O exemplo segue o primeiro: o receptor guarda o ID de cada mensagem processada e ignora a que chega com um ID conhecido.
O receptor diante da mesma mensagem duas vezes
O diagrama mostra o que o receptor faz quando o canal entrega duas vezes uma mensagem que o remetente enviou uma vez.
No Mule: o validador com a chave do pedido
No Mule, o padrão é um componente do core, o Idempotent Message Validator. Ele deixa seguir só as mensagens com um ID ainda não visto, guarda os IDs num object store e lança MULE:DUPLICATE_MESSAGE para as repetidas. O ID sai de idExpression e o store, de objectStore (Idempotent Message Validator).
No exemplo, o receptor é o endpoint POST /eip/payments/after/charges, que recebe { orderId, amount }. No cenário da série, o pagamento consome uma fila do Anypoint MQ; o exemplo usa HTTP para rodar sem broker. Nos dois casos o validador fica no flow, depois da fonte.
O flow de cobrança com o validador
O diagrama mostra o caminho de uma requisição de cobrança em cada saída do flow.
Com um ID novo, o flow cobra, envia o pedido de pagamento ao provedor (assunto da página do Correlation Identifier) e responde accepted. A cobrança é simulada: cada uma vira uma entrada, com um chargeId novo, num extrato que os testes contam. Com um ID repetido, o on-error-continue do Try registra a duplicata e responde 409. Outros erros vão ao tratamento global da aplicação, que responde 500.
Antes e depois: do correlationId ao orderId
O endpoint “antes”, em idempotent-receiver-before.xml, usa o validador sem configuração:
<try doc:name="Charge once">
<idempotent-message-validator doc:name="Reject duplicates (default: by correlation ID)" />
<flow-ref doc:name="Charge" name="payment-charge-order" />
<set-variable doc:name="Accepted" variableName="chargeResult" value="accepted" />
<error-handler>
<on-error-continue doc:name="Duplicate: 409" type="MULE:DUPLICATE_MESSAGE" logException="false">
<flow-ref doc:name="Record duplicate" name="payment-record-duplicate" />
<set-variable doc:name="Duplicate" variableName="chargeResult" value="duplicate" />
<set-variable doc:name="409" variableName="httpStatus" value="409" />
</on-error-continue>
</error-handler>
</try>
No repositório: src/main/mule/eip/idempotent-receiver/idempotent-receiver-before.xml
O “depois”, em idempotent-receiver-after.xml, declara um store e usa o número do pedido como ID. O tratamento da duplicata é o mesmo:
<os:object-store name="eip-processed-orders-store" doc:name="Processed order IDs"
persistent="false" entryTtl="24" entryTtlUnit="HOURS" expirationInterval="10" expirationIntervalUnit="MINUTES" />
...
<try doc:name="Charge once">
<idempotent-message-validator doc:name="Reject duplicates by orderId" idExpression="#[payload.orderId]" objectStore="eip-processed-orders-store" />
<flow-ref doc:name="Charge" name="payment-charge-order" />
<flow-ref doc:name="Request payment from provider" name="payment-request-from-provider" />
<set-variable doc:name="Accepted" variableName="chargeResult" value="accepted" />
<error-handler>
...
</error-handler>
</try>
No repositório: src/main/mule/eip/idempotent-receiver/idempotent-receiver-after.xml
Sem idExpression, o ID é #[correlationId], e o artigo da série explica por que ele erra. A suíte idempotent-receiver-test-suite.xml mostra os dois erros e a correção, com Mule 4.9.17 e MUnit 3.7.1:
| Situação | Antes: ID = correlationId |
Depois: ID = orderId |
|---|---|---|
| Três pedidos diferentes enviados por HTTP de dentro de um For Each | accepted,duplicate,duplicate (idempotent-before-for-each-over-http-rejects-different-orders) |
accepted,accepted,accepted (idempotent-after-for-each-over-http-accepts-different-orders) |
O mesmo pedido em duas requisições com X-CORRELATION-ID diferentes |
Cobrado duas vezes (idempotent-before-same-order-from-two-client-requests-is-charged-twice) |
Cobrado uma vez; a segunda requisição volta como duplicate (idempotent-after-same-order-from-two-client-requests-is-charged-once) |
A primeira linha usa o cliente em lote de payment-common.xml, um For Each que chama o endpoint uma vez por pedido. Todas as chamadas saem com o mesmo ID no header X-CORRELATION-ID, o correlationId do lote, e o listener adota esse ID. Na segunda, o teste simula o cliente que tenta de novo definindo o ID no HTTP Request:
<http:request doc:name="Client request CLIENT-TRY-2" config-ref="eip-self-http-request-config" method="POST" path="/eip/payments/before/charges" sendCorrelationId="ALWAYS" correlationId="ORD-IR-B-100-CLIENT-TRY-2" target="second">
No repositório: src/test/munit/eip/idempotent-receiver-test-suite.xml
O ID padrão só barra uma duplicata num caso: a mesma requisição repetida com o mesmo X-CORRELATION-ID é barrada na segunda vez (idempotent-before-same-request-repeated-is-rejected). Só que isso depende de o cliente reenviar o header.
A armadilha: o ID fica guardado quando a cobrança falha
O validador guarda o ID quando aceita a mensagem, antes de o resto do flow rodar, e não o remove se o flow falhar depois. A documentação não fala disso; o comportamento foi observado no Mule 4.9.17 e está fixado pelo teste idempotent-after-failed-charge-keeps-the-id-so-the-retry-is-a-duplicate, que faz a gravação da cobrança falhar com OS:STORE_NOT_AVAILABLE, depois que o validador já aceitou o pedido, e tenta de novo:
<munit-tools:assert-that doc:name="First attempt failed with 500" expression="#[vars.firstStatus as Number]" is="#[MunitTools::equalTo(500)]" />
<munit-tools:assert-that doc:name="orderId still stored" expression="#[vars.idStoredAfterFailure]" is="#[MunitTools::equalTo(true)]" />
<munit-tools:assert-that doc:name="Retry answered 409" expression="#[vars.retryStatus as Number]" is="#[MunitTools::equalTo(409)]" />
<munit-tools:assert-that doc:name="Retry is a duplicate" expression="#[vars.retryResult]" is="#[MunitTools::equalTo('duplicate')]" />
<munit-tools:assert-that doc:name="Charges" expression="#[payload.charges]" is="#[MunitTools::equalTo(0)]" />
<munit-tools:assert-that doc:name="Duplicates rejected" expression="#[payload.duplicatesRejected]" is="#[MunitTools::equalTo(1)]" />
No repositório: src/test/munit/eip/idempotent-receiver-test-suite.xml
A primeira tentativa responde 500, o orderId continua no store, a nova tentativa recebe 409 e o extrato termina com zero cobranças: o pedido nunca é cobrado. É um erro mais silencioso que a cobrança dupla, porque quem tentou de novo recebeu um 409, duplicate.
Pela mesma regra, uma falha no pedido ao provedor, depois de a cobrança dar certo, também deixa o ID guardado; o comentário do idempotent-receiver-after.xml registra esse caso.
No cenário da série, a armadilha não depende de um cliente que tenta de novo. No modo de confirmação AUTO do Subscriber do Anypoint MQ, a mensagem só é confirmada (ACK) se o flow termina com sucesso; se der erro, ela não é confirmada (NACK) e volta à fila para nova entrega (ACK e NACK). Junte isso à regra do teste: a cobrança falha, o erro propagado devolve a mensagem à fila, e a nova entrega chega como duplicata. O on-error-continue trata o MULE:DUPLICATE_MESSAGE e o flow termina como se tivesse dado certo (On Error Continue), o modo AUTO confirma a mensagem e ela sai da fila sem cobrança. Também não chega à DLQ, que só recebe a mensagem depois de esgotadas as tentativas de entrega (filas). Pelo mesmo motivo, reprocessar o pedido depois de um incidente, devolvendo a mensagem da DLQ para a fila ou publicando de novo, só gera a cobrança se o ID tiver sido removido ou já tiver expirado. Esse caminho é uma consequência da regra observada e do comportamento documentado do modo AUTO, não um resultado: o repositório roda sem broker e não o testa.
Há saídas, cada uma com um custo para avaliar no seu cenário. Nenhuma delas está no repositório de exemplo, e nenhuma foi testada lá.
- Remover o ID no tratamento de erro, com a operação
os:removedo Object Store Connector. Ela apaga o valor da chave e lançaOS:KEY_NOT_FOUNDse a chave não existir (Object Store Connector Reference). No teste acima, a chave que o validador gravou é oorderIdpuro. O risco está em onde a falha aconteceu: se a cobrança já tinha sido feita e o erro veio do provedor, apagar o ID libera uma segunda cobrança, justamente o que o validador devia impedir. - Guardar o ID só depois que tudo deu certo, trocando o validador por um
os:containsno início e umos:storeno fim. Entre a consulta e a gravação, duas cópias do mesmo pedido podem passar juntas. A documentação do Object Store diz que ele não tem acesso transacional e recomenda outra solução quando a mesma chave pode ser alterada em paralelo (Object Store Connector).
Escolher a chave, o tempo e o store
A chave define “a mesma mensagem”. Aqui é o orderId: uma duplicata é o mesmo pedido, venha na requisição que vier. A documentação também mostra um hash do payload como ID (Idempotent Message Validator); com hash, qualquer campo que mude entre as tentativas, como um horário de envio, faz a duplicata parecer mensagem nova. Se um pedido pode ter mais de uma cobrança legítima, a chave precisa incluir o que distingue uma da outra.
O tempo de vida precisa cobrir toda a janela em que uma duplicata ainda pode chegar. Depois que o ID expira, o mesmo orderId é aceito de novo. Sem store configurado, o validador guarda os IDs em memória por 5 minutos e verifica a expiração a cada 6 segundos (Idempotent Message Validator). O exemplo usa 24 horas e explica a escolha no XML: o retry de um cliente ou de um gateway demora minutos, e um reprocessamento depois de um incidente pode demorar horas. O TTL vale para os dois lados: enquanto o ID não expira, ele barra o reprocessamento de um pedido já cobrado e também o de um pedido cuja cobrança falhou, pela armadilha da seção anterior.
A persistência. O store do exemplo é em memória para cada execução dos testes começar limpa. Um store transiente perde os dados se o runtime cair; um persistente os mantém (Object Store Connector). Fora de um cluster do Mule, cada réplica tem o seu store: um runtime que cai com o store em memória esquece os pedidos já cobrados, e com duas réplicas cada uma aceita o mesmo pedido uma vez. A documentação avisa que a idempotência não funciona corretamente com escala horizontal quando o mesmo pedido chega a instâncias diferentes e o conteúdo do object store é local a cada uma. Num cluster do Mule, os object stores, persistentes ou não, ficam na memória compartilhada entre os nós, e todos conhecem os mesmos IDs; esse conteúdo sobrevive a redeploys e restarts enquanto o cluster mantém o quórum, e só some quando todos os nós param (clusters do Mule). Em produção, então, o store precisa ser persistente e, com mais de uma réplica, compartilhado entre elas.
No CloudHub, o store persistente pode ser o Object Store v2: com a opção dele ligada no deploy, ele substitui o object store persistente padrão da aplicação. Ele guarda os dados por no máximo 30 dias a partir da criação, o que limita o TTL. Com vários workers, ele pode ter divergências de dados ou conflitos de chave, porque não há distributed locking com ele no CloudHub nem no CloudHub 2.0; a documentação sugere um key-value store distribuído como lock (Object Store v2). É o caso de duas cópias do mesmo pedido chegando juntas em réplicas diferentes.
Quando não usar: o destino já é idempotente
O segundo caminho do livro dispensa o validador: se a operação de destino já é idempotente pela chave de negócio, como um upsert do pagamento pelo orderId, a duplicata não muda nada, desde que a operação não tenha outros efeitos, como um e-mail enviado ou um evento publicado. Aí o validador só acrescenta um store para dimensionar, um TTL para escolher e a armadilha da falha. A cobrança deste exemplo não é assim: cada chamada cria uma cobrança nova, e por isso o validador está ali.
Rodar o exemplo e os testes
git clone https://github.com/brunosouzas/mulesoft-patterns-examples.git
cd mulesoft-patterns-examples
mvn clean test
O projeto pede Java 17, Maven 3.9+ e o runtime Mule 4.9. O MUnit roda no runtime Enterprise, então o Maven precisa de acesso ao repositório Enterprise da MuleSoft (credenciais no ~/.m2/settings.xml).
Para chamar os endpoints, rode a aplicação no Anypoint Studio (porta 8081) e abra o requests.http no VS Code (REST Client) ou no IntelliJ. As entradas do Idempotent Receiver:
### EIP idempotent receiver — same order twice: "before" charges it twice, "after" answers 409 the second time
POST {{base}}/eip/payments/after/charges
Content-Type: application/json
{ "orderId": "ORD-2001", "amount": 100 }
###
POST {{base}}/eip/payments/before/charges
Content-Type: application/json
{ "orderId": "ORD-2002", "amount": 100 }
### EIP idempotent receiver — three different orders from one For Each: before gives accepted,duplicate,duplicate
POST {{base}}/eip/payments/before/charges/batch
Content-Type: application/json
[ { "orderId": "ORD-2101", "amount": 10 }, { "orderId": "ORD-2102", "amount": 20 }, { "orderId": "ORD-2103", "amount": 30 } ]
### ...and after accepts all three
POST {{base}}/eip/payments/after/charges/batch
Content-Type: application/json
[ { "orderId": "ORD-2201", "amount": 10 }, { "orderId": "ORD-2202", "amount": 20 }, { "orderId": "ORD-2203", "amount": 30 } ]
### Charges and rejected duplicates for one order
GET {{base}}/eip/payments/ORD-2001/charges
No repositório: requests.http
Mande as duas primeiras requisições duas vezes cada e consulte o extrato de cada pedido no GET, trocando o orderId no caminho: ele conta as cobranças e as duplicatas barradas.
Os outros padrões da série, com o componente do Mule que implementa cada um, estão no catálogo de padrões de integração no Mule.