Pular para o conteúdo
caua.tolentino
Todos os cases
IntegraçãoObservabilidade

Erro da cotação de frete registrado sem o motivo

Quando a API de fretes recusava uma cotação, o log guardava só o status, e cada chamado exigia reproduzir a requisição à mão. Percebi o padrão, propus a correção e fiz o log mostrar o motivo da recusa, sem expor token e sem mudar o que o cliente vê.

Papel
Desenvolvedor responsável, da proposta à entrega
Contexto
Plataforma de e-commerce multi-tenant
Stack
PHP, Symfony, Guzzle, PHPUnit, PHPStan
Time
Solo, com validação de QA
Período
2026

Case anonimizado: nomes de empresa, cliente e pessoas foram omitidos ou trocados.

01. Situação

O log dizia que a cotação falhou, mas não dizia por quê

A plataforma cota frete em um agregador de transportadoras. Quando a API recusava a cotação com HTTP 422, o método de entrega sumia do checkout e o log guardava só isto:

Client error response
[status code] 422
[reason phrase] Unprocessable Entity

O motivo da recusa, como CEP, dimensões, peso, valor declarado ou permissão do token, vem no corpo da resposta, e esse corpo não era gravado. Para cada chamado de "o frete não aparece", alguém tinha que reproduzir a requisição à mão, fora do sistema.

Percebi o padrão ao atender esses chamados, vi que o mesmo trabalho manual se repetia e abri o chamado propondo resolver na origem.

02. Investigação

Quatro detalhes somados apagavam o motivo da recusa

A primeira ideia era só ler o corpo no catch que já existia. Em vez de aplicar direto, segui o código e li o comportamento do cliente HTTP. Encontrei quatro problemas que se somavam.

O cliente HTTP lança exceção para qualquer status fora de 2xx, antes do if que verificava o status. Esse if nunca rodava num 422.

Para ter certeza, escrevi um teste que percorre o método real com uma exceção 422 de verdade, em vez de confiar na leitura.

03. Solução

Ler a resposta no lugar certo e saneá-la antes de gravar

Troquei o catch genérico por dois específicos. A recusa da API grava status, mensagem extraída e corpo da resposta. A falha de rede grava o tipo e o código do erro. Os dois relançam a exceção original, então o checkout segue igual.

Gravar o corpo de uma resposta externa no log tem riscos, e eu tratei os dois que encontrei: o token é mascarado, e o texto é truncado em 2.000 caracteres, para que uma página HTML de erro de CDN não encha o log. O extrator de mensagem que já existia no serviço de etiquetas eu transformei em um trait usado pelas duas classes, sem duplicar lógica.

Escolha

Corpo no log, nunca na mensagem da exceção

A mensagem da exceção chega ao navegador do cliente. Colocar o corpo ali exporia CEP, dimensões e valores.

Trade-off

Não corrigir o logger compartilhado

Corrigiria o problema do arquivo para todos, mas é infraestrutura do sistema inteiro. Mantive o escopo e registrei como sugestão.

Cuidado

Não elevar o nível do log

O calculador é reaproveitado no mesmo request. Subir o nível faria todas as cotações seguintes, inclusive as de sucesso, gravarem como críticas.

Cotação de frete
try {
    $response = $request->send();
    if ($response->getStatusCode() !== 200) {
        throw new \Exception('Falha na requisição'); // nunca alcançado num 422
    }
} catch (\Exception $e) {
    $this->logger->logException($e);
    throw $e;
}

Desenvolvi com TDD e escrevi 17 testes novos. Para provar que mover o extrator não mudou nenhum comportamento, rodei a suíte do serviço de etiquetas sem alterar o arquivo de teste: os 104 testes continuaram passando.

04. Resultado

O motivo da recusa aparece no log, e o checkout continua igual

testes novos
17
arquivos de log por erro
2 → 1

Um chamado de frete que some do checkout passou a ser respondido lendo o log: status, validação recusada, loja e configuração. Timeout e recusa ficam separados. Nenhuma mensagem ao cliente mudou.

Entreguei também três melhorias mapeadas durante a análise, já descritas para virarem chamados: mensagem genérica ao cliente nessa falha, saneamento dos logs antigos de etiqueta e log no comando de sincronização.