Tratamento de erros que não engana: exceções, resultados e falhas que você consegue depurar
Erros esperados não são bugs, e código que engole exceção esconde a causa. Um guia prático de tratamento de erros que você consegue depurar às 3 da manhã.
Neste artigo
Quase todo tutorial ensina a escrever o caminho feliz. O usuário digita dados válidos, o banco responde na hora, a rede não cai, o disco tem espaço, e o programa segue reto até o final. O problema é que software de verdade passa a maior parte da vida fora desse caminho: a conexão expira, o JSON vem malformado, o saldo é insuficiente, o serviço externo devolve 503. O que separa um sistema confiável de um pesadelo de plantão não é a ausência de erros — é o que o código faz quando eles chegam.
Tratar erro bem é uma disciplina de design, não um detalhe que se resolve com um try/catch no final. Envolve decidir o que é erro esperado e o que é bug, escolher como a falha se propaga, preservar o contexto que você vai precisar para investigar, e falhar de um jeito que não corrompa dados nem minta para o usuário. Este artigo percorre essas decisões na ordem em que elas aparecem quando você constrói algo que precisa aguentar o mundo real.
Erro esperado não é bug#
A primeira confusão que atrapalha o resto é tratar toda anormalidade como se fosse a mesma coisa. Existem dois tipos de "erro" que pedem respostas opostas.
O erro esperado é parte do domínio. Um formulário submetido com e-mail inválido, um saque maior que o saldo, um cupom expirado, um arquivo que o usuário não tem permissão de ler — nada disso é bug. São resultados previsíveis de um fluxo normal, e o programa deve tratá-los como cidadãos de primeira classe: validar, responder com uma mensagem clara, e seguir funcionando. Um erro esperado que derruba a aplicação é um defeito de design.
O bug é uma violação de invariante. Um null onde o código garantia que sempre haveria um objeto, um índice fora do array, uma divisão por zero que a lógica jurava ser impossível — isso sinaliza que uma suposição do programa está errada. Aqui você quase nunca quer "tratar e continuar". Você quer falhar visivelmente, registrar tudo, e corrigir a causa. Continuar rodando sobre um estado que você já sabe estar corrompido só transforma um bug pequeno em uma investigação enorme.
A consequência prática dessa distinção é que erro esperado e bug merecem mecanismos diferentes. Confundir os dois — usar exceção para controle de fluxo normal, ou engolir um bug como se fosse um caso de negócio — é a raiz da maioria dos códigos de tratamento de erro que ninguém entende depois.
Exceções ou valores de retorno?#
Existem duas grandes formas de uma função dizer "não consegui": lançar uma exceção ou devolver um valor que representa a falha. As duas funcionam; a escolha muda a legibilidade e a segurança do código.
Exceções separam o caminho feliz do tratamento. A vantagem é que o fluxo principal fica limpo: você escreve a sequência normal e trata os problemas num bloco à parte. A desvantagem é que a exceção é invisível na assinatura — nada no tipo de retorno avisa que aquela chamada pode explodir, e é fácil esquecer de tratar. Exceções também têm custo quando usadas para controle de fluxo comum, e transformam "esse caso normal do negócio" em algo que parece catástrofe.
Valores de retorno tornam a falha explícita. Linguagens como Rust e Go, e o estilo funcional em geral, preferem devolver um Result/Either ou um par (valor, erro). O tipo obriga quem chama a lidar com a possibilidade de falha antes de usar o resultado. O preço é mais cerimônia: você propaga o erro manualmente a cada nível.
``go // Valor de retorno explícito: o erro faz parte do contrato func buscarUsuario(id string) (Usuario, error) { u, err := repo.Get(id) if err != nil { return Usuario{}, fmt.Errorf("buscar usuário %s: %w", id, err) } return u, nil } ``
Uma boa regra de bolso: use valores de retorno para erros esperados de domínio (o que o chamador quase sempre quer decidir na hora) e reserve exceções para situações realmente excepcionais, das quais o código local não tem como se recuperar. O importante é a consistência dentro de uma base de código — misturar os dois estilos sem critério faz cada função virar uma surpresa.
O pecado capital: engolir o erro#
Não existe atalho mais tentador e mais destrutivo do que capturar uma exceção e não fazer nada com ela. O bloco vazio, o catch que só loga e continua, o except: pass, o _ = err que descarta o valor de erro em Go — todos escondem a informação exatamente no momento em que ela era mais valiosa.
``javascript // O crime perfeito: o erro acontece e desaparece sem deixar rastro try { await salvarPedido(pedido); } catch (e) { // TODO: tratar depois } ``
O código acima parece inofensivo em uma revisão apressada, mas ele é uma bomba-relógio. Quando salvarPedido falhar em produção, o pedido não será salvo, o usuário verá "sucesso", e não haverá uma única linha de log apontando o que aconteceu. Você vai descobrir o problema semanas depois, por uma reclamação, sem nenhuma pista de onde começou.
Há três formas legítimas de responder a um erro capturado, e "nenhuma delas" nunca é uma delas:
- Recuperar de verdade. Você sabe lidar com aquela falha específica: tenta de novo, usa um valor padrão sensato, cai para um caminho alternativo. Recuperar significa que o programa segue em um estado correto e conhecido, não que ele finge que nada aconteceu.
- Traduzir e relançar. O erro não é seu para resolver aqui; você adiciona contexto e o propaga para uma camada que sabe decidir. Relançar preservando a causa original é quase sempre melhor do que tratar cedo demais.
- Registrar e falhar. Se você não pode recuperar nem propagar, registre com todo o contexto e deixe a falha subir. Um erro visível é um problema; um erro silencioso é um mistério.
Falhe rápido, falhe alto#
Quando um bug de verdade aparece — uma invariante quebrada, um estado impossível —, a reação certa é o contrário de esconder. É parar cedo, no ponto mais próximo da causa, antes que o estado inválido se espalhe pelo sistema.
Validar na fronteira evita o veneno lá dentro. Toda entrada que cruza a borda do seu sistema — corpo de requisição, parâmetro de query, mensagem de fila, resposta de um serviço externo — deve ser validada assim que chega. Se um campo obrigatório está ausente ou um número veio como texto, rejeite ali, com uma mensagem precisa. Deixar o dado malformado penetrar até três camadas abaixo transforma um erro trivial de validação em um NullPointerException obscuro no meio de uma transação, longe da origem.
Um estado impossível não deve seguir vivo. Se o código chega a um ramo que a lógica jurava ser inalcançável, essa é a hora de estourar, não de retornar um valor qualquer para "não travar". O default de um switch que cobre todos os casos conhecidos, o else de uma condição exaustiva — quando eles são alcançados, algo está profundamente errado, e continuar apenas adia e disfarça o diagnóstico. Falhar rápido dói no momento, mas economiza as horas que você gastaria rastreando o efeito colateral três funções adiante.
A mensagem de erro é para o humano das 3 da manhã#
Todo erro registrado tem um leitor específico em mente: a pessoa cansada que vai investigar o incidente de madrugada, sem contexto, com o sistema fora do ar. Escreva a mensagem para ela.
Uma mensagem útil responde três perguntas. O que estava sendo feito, com quais dados, e o que deu errado. "Erro ao processar" não ajuda ninguém. "Falha ao debitar R$ 50,00 da conta 8842: saldo insuficiente (saldo atual R$ 30,00)" resolve o incidente antes mesmo de você abrir o código. Inclua identificadores — id do pedido, do usuário, da transação — e nunca segredos: senha, token e número de cartão jamais entram em log.
Preserve a cadeia de causas ao propagar. Quando você captura um erro de baixo nível e relança em um nível mais alto, embrulhe-o em vez de substituí-lo. O %w do Go, o cause em JavaScript, o from no Python, o thiserror/anyhow no Rust — todos existem para manter o rastro completo, da falha original de rede até o erro de domínio que o usuário viu. Uma mensagem nova que apaga a causa técnica te obriga a adivinhar o que realmente aconteceu. A pilha inteira, do topo à raiz, é o que transforma uma investigação de horas em uma de minutos.
Erros de domínio e erros de borda#
À medida que o sistema cresce, vale separar dois vocabulários de erro que costumam se misturar.
Erros de domínio são tipados e significam algo no negócio. SaldoInsuficiente, CupomExpirado, PermissaoNegada — são tipos explícitos que a lógica de negócio conhece e trata de forma específica. Eles têm identidade, permitem que o chamador reaja de maneiras diferentes a cada um, e não vazam detalhes de infraestrutura. Modelar esses erros como tipos, em vez de strings genéricas, deixa o compilador (ou os testes) garantirem que cada caso foi considerado.
Erros de borda vêm da infraestrutura e quase sempre sobem. Timeout de banco, DNS que não resolve, disco cheio, conexão recusada. Nas camadas internas, você geralmente não sabe o que fazer com eles além de anotar contexto e propagar. É na borda — o handler HTTP, o consumidor de mensagem — que a decisão final acontece: traduzir para um código de status, decidir se vale um retry, disparar um alerta. Manter essa fronteira clara evita que a lógica de negócio fique poluída de detalhes de rede e que a camada de rede tome decisões de negócio que não são dela.
Retry, timeout e idempotência#
Sistemas distribuídos falham de um jeito particular: de forma intermitente. A mesma operação que falhou agora funciona daqui a dois segundos. Isso abre a porta para o retry — e para as armadilhas dele.
Nem toda falha merece nova tentativa. Repetir faz sentido para erros transitórios: timeout, indisponibilidade momentânea, um 503. Não faz sentido nenhum para erros determinísticos: um 400 por dado inválido vai falhar igual quantas vezes você tentar, e insistir só desperdiça recursos e mascara o bug. Antes de repetir, pergunte se a causa tem chance de ter mudado.
Toda espera precisa de um teto. Uma operação de rede sem timeout é uma promessa de travar para sempre quando o outro lado sumir. Defina limites, e ao repetir use recuo exponencial com um pouco de aleatoriedade (jitter) — se mil clientes falharem juntos e todos tentarem de novo no mesmo instante, eles derrubam o serviço que estava se recuperando. Espalhar as tentativas no tempo é o que evita a avalanche.
Retry seguro exige idempotência. Se você repete uma cobrança que na verdade tinha funcionado — a resposta é que se perdeu, não a operação —, o cliente paga duas vezes. Operações que podem ser repetidas precisam ser idempotentes: uma chave de idempotência, uma verificação de "isso já foi feito?" antes de agir, um identificador que o servidor reconhece como duplicata. Sem isso, o mecanismo que deveria aumentar a confiabilidade vira a origem de um problema pior.
Fechando#
Tratar erro bem não é escrever mais try/catch; é levar a falha a sério como parte do design. Separe o erro esperado do bug e dê a cada um o mecanismo certo. Nunca engula uma exceção — recupere, propague com contexto ou registre e falhe, mas jamais silencie. Falhe cedo e perto da causa, escreva mensagens para quem vai investigar sem contexto, e trate retry como uma operação delicada que só é segura com timeout e idempotência. O código que faz isso não é mais bonito no caminho feliz; ele é o único que você consegue consertar quando o caminho feliz acaba — e ele sempre acaba.