InícioDesafios › Desafio 05

Desafio 05 Nível: Avançado

Avisar o ERP quando a venda fecha, sem sequestrar a transação

A operação pediu algo simples: quando uma oportunidade é ganha, criar o pedido no ERP automaticamente. Você faz o callout no trigger, testa com uma venda e funciona. Aí sobe para produção e, no primeiro dia de movimento, metade das vendas trava com uma exceção que ninguém tinha visto antes: You have uncommitted work pending. O usuário só vê que não conseguiu salvar a oportunidade. Integração é onde o Apex bem escrito e o Apex de demonstração se separam de vez.

Callout HTTP Queueable Idempotência Timeout e retry Named Credential HttpCalloutMock

O cenário

Quando a oportunidade entra em Closed Won, o time quer um pedido criado no ERP externo. A primeira versão faz o callout direto no trigger, e ela tem um problema que só aparece com movimento de verdade:

trigger OpportunityTrigger on Opportunity (after update) {
    for (Opportunity opp : Trigger.new) {
        if (opp.StageName == 'Closed Won') {
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:ERP/orders');
            req.setMethod('POST');
            req.setBody('{"opp":"' + opp.Id + '"}');
            // CalloutException: You have uncommitted work pending
            new Http().send(req);
        }
    }
}

O trigger roda depois de um DML (o próprio update que ganhou a oportunidade), então a transação já tem trabalho não confirmado. A plataforma proíbe abrir um callout com DML pendente, porque a chamada externa pode demorar e ela não vai segurar um lock de banco esperando um servidor de terceiro responder. Some a isso o resto: callout dentro do laço estoura o limite de chamadas, um timeout do ERP derruba a oportunidade inteira, e se você reprocessar depois do timeout corre o risco de criar o pedido duas vezes.

Pré-requisitos na org. Para o cenário fechar, assuma que existem: um Named Credential chamado ERP apontando para o serviço externo; um campo ERP_Pedido_Id__c (Texto, External Id) na Opportunity, vazio enquanto o pedido não foi criado; e um objeto Log_Integracao__c com os campos Referencia__c (Texto) e Detalhe__c (Área de texto longo) para registrar falhas.

O que você deve construir

Uma classe ErpPedidoQueueable que enfileira o envio para fora da transação do trigger e faz o callout de forma assíncrona. O contrato mínimo, que o teste vai usar, é este:

public with sharing class ErpPedidoQueueable implements Queueable, Database.AllowsCallouts {

    // Ponto de entrada do trigger: NAO faz callout, so enfileira o trabalho.
    public static void enfileirar(Set<Id> oppIds)

    // O trabalho assincrono: aqui o callout e permitido.
    public void execute(QueueableContext ctx)
}

O trigger vira uma linha (ErpPedidoQueueable.enfileirar(idsGanhas);) e toda a integração mora na classe. O ERP expõe um endpoint de lote que recebe vários pedidos de uma vez e responde por item.

Regras de aceite

  • O callout nunca roda na transação do trigger. Chamar enfileirar de um after update não pode lançar You have uncommitted work pending. O trabalho sai da transação síncrona.
  • Um callout por lote, não um por registro. Um conjunto com 150 oportunidades resolve com uma consulta e uma chamada HTTP, não 150. Nada de SOQL, DML ou callout dentro do laço.
  • Falha não perde o dado. Se o ERP responde erro ou o callout estoura por timeout, a oportunidade não pode ser marcada como integrada, e o registro precisa entrar para reprocessamento.
  • Toda falha vira log. Cada erro, do lote inteiro ou de um item específico, é gravado em Log_Integracao__c com o motivo, para o time saber o que reenviar.
  • Idempotência (o caso de borda que reprova a maioria): executar o mesmo envio duas vezes não pode criar dois pedidos no ERP. Uma oportunidade que já tem ERP_Pedido_Id__c é ignorada, e cada item carrega uma chave de idempotência para o caso do reenvio após um timeout em que o ERP na verdade recebeu.

O que este desafio avalia

  • Se você entende por que a plataforma proíbe callout com DML pendente, e não só como contornar o erro.
  • Se você escolhe o mecanismo assíncrono certo e sabe por que Queueable cabe melhor que @future aqui.
  • Se o seu tratamento de erro preserva o dado em vez de engolir a exceção.
  • Se você percebe que timeout não é a ausência de resposta: pode ser uma resposta que chegou tarde, e que a idempotência existe exatamente para esse caso.
Travei. Me dá uma direção (sem entregar a resposta)

Quatro perguntas que destravam:

  • Por que a plataforma deixa um Queueable fazer callout, mas o trigger síncrono não? O que muda entre as duas transações?
  • O que a interface Database.AllowsCallouts autoriza, e o que acontece se você a esquecer?
  • Se o ERP der timeout, como você distingue "não recebeu" de "recebeu e a resposta se perdeu"? Qual das duas duplica o pedido no reenvio?
  • Onde você guarda "esta oportunidade já foi integrada" de um jeito que sobreviva ao reprocessamento e não dependa de rodar de novo na mesma transação?

Termos de busca que ajudam: apex you have uncommitted work pending callout, queueable database allowscallouts apex, idempotency key http retry, httpcalloutmock test queueable apex.

Como testar

Callout não se testa em Execute Anonymous: precisa de HttpCalloutMock, que só vive em contexto de teste. Cole esta classe de teste na org, rode-a e leia os asserts. Ela cobre o caminho feliz em lote, a idempotência do reenvio e a falha por timeout que não pode perder o dado.

@isTest
private class ErpPedidoQueueableTest {

    // Mock que responde sucesso para cada pedido recebido no corpo do lote.
    private class ErpSucessoMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            Map<String, Object> corpo =
                (Map<String, Object>) JSON.deserializeUntyped(req.getBody());
            List<Object> pedidos = (List<Object>) corpo.get('pedidos');
            List<Map<String, Object>> resultados = new List<Map<String, Object>>();
            Integer n = 1;
            for (Object p : pedidos) {
                Map<String, Object> item = (Map<String, Object>) p;
                resultados.add(new Map<String, Object>{
                    'oppId'    => item.get('idempotencia'),
                    'pedidoId' => 'PED-' + n++,
                    'ok'       => true
                });
            }
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody(JSON.serialize(new Map<String, Object>{ 'resultados' => resultados }));
            return res;
        }
    }

    // Mock que simula o ERP fora do ar: o send() estoura.
    private class ErpTimeoutMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            CalloutException e = new CalloutException();
            e.setMessage('Read timed out');
            throw e;
        }
    }

    private static List<Opportunity> ganhas(Integer quantas) {
        List<Opportunity> opps = new List<Opportunity>();
        for (Integer i = 0; i < quantas; i++) {
            opps.add(new Opportunity(Name = 'Deal ' + i, StageName = 'Closed Won',
                                     CloseDate = Date.today()));
        }
        insert opps;
        return opps;
    }

    @isTest
    static void enviaLoteEMarcaTodas() {
        List<Opportunity> opps = ganhas(150);
        Set<Id> ids = new Map<Id, Opportunity>(opps).keySet();

        Test.setMock(HttpCalloutMock.class, new ErpSucessoMock());
        Test.startTest();
        ErpPedidoQueueable.enfileirar(ids);
        Test.stopTest(); // o Queueable roda aqui, com o mock ativo

        Integer marcadas = [SELECT count() FROM Opportunity
                            WHERE Id IN :ids AND ERP_Pedido_Id__c != null];
        System.assertEquals(150, marcadas, 'todas as 150 deveriam ter o id do ERP');

        // Idempotencia: reprocessar nao acha pendente, nao faz callout, nao muda nada.
        new ErpPedidoQueueable(ids).execute(null);
        Integer aindaMarcadas = [SELECT count() FROM Opportunity
                                 WHERE Id IN :ids AND ERP_Pedido_Id__c != null];
        System.assertEquals(150, aindaMarcadas, 'o reenvio nao pode duplicar nem alterar');
    }

    @isTest
    static void falhaNaoMarcaELoga() {
        Opportunity opp = ganhas(1)[0];

        Test.setMock(HttpCalloutMock.class, new ErpTimeoutMock());
        Test.startTest();
        ErpPedidoQueueable.enfileirar(new Set<Id>{ opp.Id });
        Test.stopTest();

        opp = [SELECT ERP_Pedido_Id__c FROM Opportunity WHERE Id = :opp.Id];
        System.assertEquals(null, opp.ERP_Pedido_Id__c, 'falha nao pode marcar como integrada');
        System.assert([SELECT count() FROM Log_Integracao__c] > 0, 'a falha tem que virar log');
    }
}

Gabarito comentado

A solução completa destrinchada: por que o callout sai da transação, por que o lote resolve o limite de chamadas, e as duas camadas de idempotência que impedem o pedido duplicado quando o timeout mente. Tente resolver antes de abrir.

Ver o gabarito

Resolveu? O que fica não é o Queueable: é a pergunta que toda integração cobra e que nenhuma tela mostra. "O que acontece se a outra ponta demorar, cair no meio, ou receber a mesma chamada duas vezes?". Quem só testa o caminho feliz descobre a resposta em produção.