Criando exceções customizadas

1. Por que criar exceções customizadas?

1.1. Limitações das exceções nativas do Python

Python oferece um conjunto robusto de exceções nativas como ValueError, TypeError, KeyError e RuntimeError. No entanto, em aplicações complexas, essas exceções genéricas não são suficientes para expressar nuances do domínio do problema. Por exemplo, um ValueError pode ser lançado por diversos motivos em diferentes partes do sistema, tornando difícil identificar a causa raiz apenas pelo tipo da exceção.

1.2. Clareza semântica: nomes que refletem o domínio do problema

Exceções customizadas permitem que você nomeie os erros de acordo com o contexto da sua aplicação. Em vez de ValueError("saldo insuficiente"), você pode ter SaldoInsuficienteError, que comunica imediatamente a natureza do problema, sem necessidade de ler a mensagem.

1.3. Facilidade de tratamento seletivo em blocos except

Com exceções customizadas, você pode capturar erros específicos de forma precisa:

try:
    processar_pagamento(conta, valor)
except SaldoInsuficienteError:
    notificar_usuario("Saldo insuficiente para realizar a operação.")
except LimiteDiarioExcedidoError:
    notificar_usuario("Você excedeu o limite diário de transações.")

2. Estrutura básica de uma exceção customizada

2.1. Herdando da classe Exception (ou subclasses)

A maneira mais simples de criar uma exceção customizada é herdar da classe Exception:

class MeuErro(Exception):
    pass

2.2. Exemplo mínimo: class MinhaExcecao(Exception): pass

class SaldoInsuficienteError(Exception):
    pass

# Uso
try:
    raise SaldoInsuficienteError("Saldo insuficiente para saque de R$ 500,00")
except SaldoInsuficienteError as e:
    print(e)  # Saída: Saldo insuficiente para saque de R$ 500,00

2.3. Diferença entre herdar de Exception vs BaseException

A hierarquia de exceções em Python é:

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ... (todas as exceções comuns)
    └── SuaExcecaoCustomizada

Herde sempre de Exception ou de suas subclasses, nunca diretamente de BaseException. Capturar BaseException pode suprimir SystemExit e KeyboardInterrupt, impedindo o encerramento adequado do programa.

3. Adicionando atributos e mensagens personalizadas

3.1. Sobrescrevendo o método __init__

class ErroDeAutenticacao(Exception):
    def __init__(self, usuario, mensagem="Falha na autenticação"):
        self.usuario = usuario
        self.mensagem = mensagem
        super().__init__(self.mensagem)

3.2. Armazenando dados extras (códigos de erro, valores inválidos)

class ErroDeValidacao(Exception):
    def __init__(self, campo, valor, mensagem=None):
        self.campo = campo
        self.valor = valor
        self.mensagem = mensagem or f"Campo '{campo}' com valor inválido: {valor}"
        super().__init__(self.mensagem)

# Uso
raise ErroDeValidacao("email", "invalido@")

3.3. Personalizando a representação com __str__ e __repr__

class ErroDeAPI(Exception):
    def __init__(self, status_code, mensagem):
        self.status_code = status_code
        self.mensagem = mensagem
        super().__init__(self.mensagem)

    def __str__(self):
        return f"[{self.status_code}] {self.mensagem}"

    def __repr__(self):
        return f"ErroDeAPI(status_code={self.status_code!r}, mensagem={self.mensagem!r})"

# Uso
erro = ErroDeAPI(404, "Recurso não encontrado")
print(str(erro))   # [404] Recurso não encontrado
print(repr(erro))  # ErroDeAPI(status_code=404, mensagem='Recurso não encontrado')

4. Hierarquia de exceções customizadas

4.1. Criando exceções base para um módulo ou pacote

# modulo_excecoes.py
class ErroDoSistema(Exception):
    """Exceção base para todos os erros do sistema de pagamentos."""
    pass

4.2. Exceções específicas herdando da exceção base

class ErroDeValidacao(ErroDoSistema):
    pass

class CampoObrigatorio(ErroDeValidacao):
    def __init__(self, campo):
        self.campo = campo
        super().__init__(f"O campo '{campo}' é obrigatório")

class ValorInvalido(ErroDeValidacao):
    def __init__(self, campo, valor):
        self.campo = campo
        self.valor = valor
        super().__init__(f"Valor '{valor}' inválido para o campo '{campo}'")

4.3. Exemplo prático: ErroDeValidacaoCampoObrigatorio, ValorInvalido

def validar_usuario(dados):
    if "nome" not in dados or not dados["nome"]:
        raise CampoObrigatorio("nome")
    if "email" in dados and "@" not in dados["email"]:
        raise ValorInvalido("email", dados["email"])
    return dados

try:
    validar_usuario({"email": "invalido"})
except CampoObrigatorio as e:
    print(f"Campo obrigatório: {e.campo}")
except ValorInvalido as e:
    print(f"Valor inválido: {e.valor} para campo {e.campo}")
except ErroDeValidacao as e:
    print(f"Erro de validação: {e}")

5. Boas práticas de design

5.1. Nomenclatura: sufixo Error ou Exception?

A convenção em Python é usar o sufixo Error para exceções que representam erros, e Exception apenas quando o nome já é amplamente conhecido (como em bibliotecas específicas). Prefira SaldoInsuficienteError a SaldoInsuficienteException.

5.2. Documentação com docstrings e type hints

class ErroDeConexao(Exception):
    """Exceção lançada quando a conexão com o servidor falha.

    Attributes:
        host: Nome do host que falhou
        porta: Número da porta utilizada
        causa_original: Exceção original que causou o erro
    """

    def __init__(self, host: str, porta: int, causa_original: Exception = None):
        self.host = host
        self.porta = porta
        self.causa_original = causa_original
        mensagem = f"Falha ao conectar em {host}:{porta}"
        super().__init__(mensagem)

5.3. Evitando herança múltipla desnecessária

Herança múltipla em exceções pode complicar o tratamento. Use com moderação e apenas quando fizer sentido semântico:

# Evite isso a menos que seja realmente necessário
class ErroCritico(ErroDoSistema, KeyboardInterrupt):
    pass

6. Integração com tratamento de exceções existente

6.1. Relançando exceções customizadas a partir de exceções nativas

def converter_moeda(valor, taxa):
    try:
        return float(valor) * float(taxa)
    except (ValueError, TypeError) as e:
        raise ErroDeConversao(f"Falha na conversão: {e}")

6.2. Uso de raise ... from para encadeamento de exceções

def processar_pagamento(conta, valor):
    try:
        conta.debitar(valor)
    except ValueError as e:
        raise SaldoInsuficienteError(
            f"Saldo insuficiente para debitar R$ {valor:.2f}"
        ) from e

O encadeamento com from preserva a pilha de chamadas original, facilitando o debugging.

6.3. Exemplo: convertendo ValueError em SaldoInsuficienteError

class ContaBancaria:
    def __init__(self, saldo=0):
        self.saldo = saldo

    def debitar(self, valor):
        if valor <= 0:
            raise ValueError("Valor deve ser positivo")
        if valor > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= valor

def transferir(conta_origem, conta_destino, valor):
    try:
        conta_origem.debitar(valor)
        conta_destino.creditar(valor)
    except ValueError as e:
        if "Saldo insuficiente" in str(e):
            raise SaldoInsuficienteError(
                f"Saldo disponível: R$ {conta_origem.saldo:.2f}"
            ) from e
        raise

7. Exceções customizadas em projetos reais

7.1. Em APIs: retornando exceções para o framework web

# Flask
@app.errorhandler(ErroDeValidacao)
def handle_validacao(error):
    return {"erro": str(error), "campo": error.campo}, 400

# FastAPI
from fastapi import HTTPException

class RecursoNaoEncontradoError(Exception):
    def __init__(self, recurso_id):
        self.recurso_id = recurso_id
        super().__init__(f"Recurso {recurso_id} não encontrado")

@app.get("/usuarios/{usuario_id}")
def get_usuario(usuario_id: int):
    usuario = buscar_usuario(usuario_id)
    if not usuario:
        raise RecursoNaoEncontradoError(usuario_id)
    return usuario

7.2. Em bibliotecas: documentando exceções na interface pública

class MinhaBiblioteca:
    """API pública da biblioteca.

    Raises:
        ErroDeConexao: Se não for possível conectar ao servidor
        ErroDeAutenticacao: Se as credenciais forem inválidas
        ErroDeTimeout: Se a requisição exceder o tempo limite
    """
    pass

7.3. Testando exceções customizadas com pytest.raises

import pytest

def test_validacao_campo_obrigatorio():
    with pytest.raises(CampoObrigatorio) as exc_info:
        validar_usuario({})

    assert exc_info.value.campo == "nome"
    assert "nome" in str(exc_info.value)

def test_hierarquia_excecoes():
    with pytest.raises(ErroDoSistema):
        raise CampoObrigatorio("email")

    with pytest.raises(ErroDeValidacao):
        raise ValorInvalido("idade", -1)

def test_encadeamento_excecoes():
    with pytest.raises(SaldoInsuficienteError) as exc_info:
        transferir(ContaBancaria(100), ContaBancaria(0), 200)

    assert exc_info.value.__cause__ is not None
    assert isinstance(exc_info.value.__cause__, ValueError)

Referências