Explorando a Biblioteca Retrying do Python: Mecanismos de Reintento em Código

A automação de reitnentos para operações suscetíveis a falhas transitórias é uma prática essencial no desenvolvimento de software robusto. Em Python, a biblioteca de terceiros retrying oferece uma maneira declarativa e flexível de implementar essa lógica. Este artigo aborda a estrutura e o funcionamento interno desta biblioteca, revelando como ela simplifica a gestão de tentativas repetidas de execução de código.

Para começar a utilizar a biblioteca retrying, a instalação é direta:

pip install retrying

A seguir, são apresentados diversos exemplos práticos que ilustram as capacidades de configuração do decorador @retry:

Exemplo 1: Reintento Contínuo

Configura uma função para ser reintentada indefinidamente em caso de falha, sem qualquer atraso entre as tentativas.

from retrying import retry

@retry
def operacao_infinitas_tentativas():
    """Tenta executar esta função repetidamente sem atrasos."""
    print("Tentando continuamente até ter sucesso...")
    # Lógica que pode falhar

Exemplo 2: Limite de Tentativas

Define um número máximo de tentativas antes de desistir. Neste caso, a função será reintentada no máximo sete vezes.

from retrying import retry

@retry(stop_max_attempt_number=7)
def parar_apos_sete_tentativas():
    """Executa a função, parando após a sétima tentativa."""
    print("Tentativa limitada a sete chamadas.")
    # Lógica que pode falhar

Exemplo 3: Limite de Tempo Total

Especifica um tempo máximo total em milissegundos para todas as tentativas. A reexecução será interrompida após 10 segundos.

from retrying import retry

@retry(stop_max_delay=10000)
def parar_apos_dez_segundos():
    """Para de tentar após dez segundos."""
    print("O ciclo de reintento durará no máximo dez segundos.")
    # Lógica que pode falhar

Exemplo 4: Atraso Fixo entre Tentativas

Determina um período de espera fixo entre cada tentativa falha, aqui configurado para 2 segundos.

from retrying import retry

@retry(wait_fixed=2000)
def esperar_dois_segundos():
    """Aguarde dois segundos entre cada reintento."""
    print("Esperando 2 segundos antes de reintentar.")
    # Lógica que pode falhar

Exemplo 5: Atraso Aleatório

Introduz um atraso aleatório entre tentativas, útil para evitar sobrecarregar um recurso externo com reintentos simultâneos.

from retrying import retry

@retry(wait_random_min=1000, wait_random_max=2000)
def esperar_aleatorio_um_a_dois_segundos():
    """Aguarde entre 1 e 2 segundos aleatoriamente."""
    print("Espera aleatória de 1 a 2 segundos antes de reintentar.")
    # Lógica que pode falhar

Exemplo 6: Espera Exponencial

Implementa um atraso exponencial crescente entre as tentativas, com um multiplicador e um limite máximo.

from retrying import retry

@retry(wait_exponential_multiplier=1000, wait_exponential_max=10000)
def atraso_exponencial():
    """Atraso exponencial: 2^n * 1000ms, limitado a 10 segundos."""
    print("Atraso exponencial entre tentativas, com teto de 10 segundos.")
    # Lógica que pode falhar

Exemplo 7: Reintento Condicional por Exceção

Permite especificar quais tipos de exceções devem acionar um reintento. Outras exceções são propagadas.

from retrying import retry
import io

def re_tentar_se_io_erro(excecao):
    """Retorna True para reintentar se for um IOError, False caso contrário."""
    return isinstance(excecao, io.IOError)

@retry(retry_on_exception=re_tentar_se_io_erro)
def operacao_pode_dar_io_erro():
    """Reintenta apenas se um IOError for levantado."""
    print("Tentando, reintentando apenas em caso de IOError.")
    # Lógica que pode levantar IOError
    raise io.IOError("Simulando um erro de I/O")

@retry(retry_on_exception=re_tentar_se_io_erro, wrap_exception=True)
def levantar_erro_retry_se_nao_io():
    """Reintenta em IOError, encapsula outras exceções em RetryError."""
    print("Tentando, outras exceções são encapsuladas em RetryError.")
    # Lógica que pode levantar outras exceções
    raise ValueError("Simulando outro tipo de erro")

Exemplo 8: Reintento Condicional por Resultado da Função

Define uma condição baseada no valor de retorno da função para determinar se um reintento é necessário.

from retrying import retry

def re_tentar_se_resultado_nulo(resultado):
    """Retorna True para reintentar se o resultado for None, False caso contrário."""
    return resultado is None

@retry(retry_on_result=re_tentar_se_resultado_nulo)
def operacao_pode_retornar_nulo():
    """Reintenta se a função retornar None."""
    print("Tentando, reintentando se o resultado for None.")
    # Lógica que pode retornar None
    import random
    if random.random() < 0.5:
        return None
    return "Sucesso!"

Os exemplos acima demonstram a facilidade de uso do decorador @retry. Mas como ele funciona internamente para fornecer essa funcionalidade não-intrusiva?

O Funcionamento Interno do Decorador @retry

O decorador @retry é uma implementação do padrão de decorador em Python. Ele intercepta a definição da função e a envolve com a lógica de reintento. Existem duas formas principais de usá-lo: @retry (sem parênteses) e @retry(...) (com parênteses e possíveis argumentos).

Considere a implementação simplificada do decorador retry:

import six
import time
import random
import sys
import traceback

# Importação de classes e exceções fictícias para o contexto de análise
class RetryError(Exception):
    def __init__(self, attempt):
        super().__init__(f"Falha na execução após {attempt.attempt_number} tentativas.")
        self.last_attempt = attempt

# A classe Retrying será detalhada mais tarde, mas é essencial para o decorador.
class Retrying:
    def __init__(self, *dargs, **dkw):
        # Simplificado para a análise do decorador.
        # Em uma implementação real, dargs e dkw seriam usados para configurar
        # as estratégias de parada, espera, etc.
        self._config = dkw
        self._config_args = dargs

    def call(self, func_alvo, *args, **kwargs):
        # Esta é a parte central onde a lógica de reintento real aconteceria.
        # Por agora, apenas chamamos a função alvo diretamente para ilustrar o fluxo do decorador.
        print(f"Chamando o método 'call' da instância Retrying para {func_alvo.__name__} com config: {self._config}")
        return func_alvo(*args, **kwargs)

def retry(*decorator_args, **decorator_kwargs):
    """
    Decorador que configura um objeto Retrying e delega a ele a execução da função.
    Suporta @retry e @retry().
    """
    # Caso 1: Uso como @retry (sem parênteses)
    # Neste cenário, decorator_args[0] é a função que está sendo decorada.
    if len(decorator_args) == 1 and callable(decorator_args[0]):
        def envolver_simples(funcao_original):
            @six.wraps(funcao_original)
            def funcao_envolta_simples(*args, **kwargs):
                # Cria uma instância padrão de Retrying sem argumentos
                print(f"DEBUG: @retry (sem args) - Chamando {funcao_original.__name__}")
                return Retrying().call(funcao_original, *args, **kwargs)
            return funcao_envolta_simples
        return envolver_simples(decorator_args[0])
    # Caso 2: Uso como @retry(...) (com parênteses e/ou argumentos)
    # Neste cenário, decorator_args e decorator_kwargs contêm os argumentos de configuração.
    else:
        def envolver_com_config(funcao_original):
            @six.wraps(funcao_original)
            def funcao_envolta_config(*args, **kwargs):
                # Cria uma instância de Retrying com os argumentos passados ao decorador
                print(f"DEBUG: @retry(args) - Chamando {funcao_original.__name__} com args de config: {decorator_kwargs}")
                return Retrying(*decorator_args, **decorator_kwargs).call(funcao_original, *args, **kwargs)
            return funcao_envolta_config
        return envolver_com_config

Quando uma função é decorada com @retry (sem argumentos), como em @retry def minha_funcao(): ..., o Python a interpreta como minha_funcao = retry(minha_funcao). Isso aciona o "Caso 1" no código do decorador, onde a função minha_funcao é passada diretamente para retry. O decorador retorna então uma nova função (funcao_envolta_simples) que, quando chamada, cria uma instância padrão de Retrying e usa seu método call para executar a função original.

Por outro lado, quando usado com argumentos, como em @retry(stop_max_attempt_number=7) def outra_funcao(): ..., o Python executa retry(stop_max_attempt_number=7) primeiro. Isso aciona o "Caso 2", que retorna uma função (envolver_com_config). Essa função é então chamada com outra_funcao como argumento: outra_funcao = envolver_com_config(outra_funcao). O resultado final é uma funcao_envolta_config que, ao ser executada, instancia Retrying com os argumentos fornecidos (stop_max_attempt_number=7, etc.) e chama seu método call.

Em ambos os casos, a chamada da função original é encapsulada dentro do método call de uma instância de Retrying, que é onde a lógica central de reintento reside.

Análise da Classe Retrying e o Método call

O método call dentro da classe Retrying é o motor da biblioteca. Ele orquestra o ciclo de tentativas, verificando condições de parada, aplicando atrasos e lidando com exceções e resultados. A seguir, uma representação do fluxo principal:

class Attempt:
    """
    Uma Attempt encapsula o resultado de uma chamada de função:
    seja um valor retornado ou uma exceção.
    """
    def __init__(self, valor, num_tentativa, tem_excecao):
        self.valor = valor # O resultado ou sys.exc_info() se for exceção
        self.attempt_number = num_tentativa
        self.has_exception = tem_excecao

    def get(self, encapsular_excecao=False):
        """
        Retorna o valor de sucesso ou levanta a exceção capturada.
        Se encapsular_excecao for True, a exceção é envolvida em RetryError.
        """
        if self.has_exception:
            if encapsular_excecao:
                raise RetryError(self)
            else:
                # Re-levanta a exceção original com seu traceback original
                six.reraise(self.valor[0], self.valor[1], self.valor[2])
        else:
            return self.valor

    def __repr__(self):
        if self.has_exception:
            return f"Tentativas: {self.attempt_number}, Erro:\n{''.join(traceback.format_tb(self.valor[2]))}"
        else:
            return f"Tentativas: {self.attempt_number}, Valor: {self.valor}"


class Retrying:
    # ... (Parte do __init__ com configurações de parada e espera) ...
    def __init__(self, *args, **kwargs):
        self._config = kwargs
        # Configuração padrão ou com base nos kwargs
        self._stop_max_attempt_number = kwargs.get('stop_max_attempt_number')
        self._stop_max_delay = kwargs.get('stop_max_delay')
        self._wait_fixed = kwargs.get('wait_fixed')
        self._wait_random_min = kwargs.get('wait_random_min')
        self._wait_random_max = kwargs.get('wait_random_max')
        self._wait_exponential_multiplier = kwargs.get('wait_exponential_multiplier')
        self._wait_exponential_max = kwargs.get('wait_exponential_max')
        self._retry_on_exception = kwargs.get('retry_on_exception', lambda e: True) # Reintenta em qualquer exceção por padrão
        self._retry_on_result = kwargs.get('retry_on_result', lambda r: False) # Não reintenta por resultado por padrão
        self._wrap_exception = kwargs.get('wrap_exception', False)
        self._before_attempts = kwargs.get('before_attempts')
        self._after_attempts = kwargs.get('after_attempts')
        self._wait_jitter_max = kwargs.get('wait_jitter_max')

        # Funções de parada
        stop_criterios = []
        if self._stop_max_attempt_number is not None:
            stop_criterios.append(self.parar_apos_tentativa)
        if self._stop_max_delay is not None:
            stop_criterios.append(self.parar_apos_atraso)
        self.parar_execucao = lambda num, atraso: any(c(num, atraso) for c in stop_criterios) if stop_criterios else lambda *a: False

        # Funções de espera
        wait_criterios = [lambda *a: 0] # Default: sem espera
        if self._wait_fixed is not None:
            wait_criterios.append(self.espera_fixa)
        if self._wait_random_min is not None or self._wait_random_max is not None:
            wait_criterios.append(self.espera_aleatoria)
        if self._wait_exponential_multiplier is not None or self._wait_exponential_max is not None:
            wait_criterios.append(self.espera_exponencial)
        self.calcular_espera = lambda num, atraso: max(c(num, atraso) for c in wait_criterios)


    def call(self, fn, *args, **kwargs):
        inicio_tempo = int(round(time.time() * 1000))
        contador_tentativas = 1
        while True:
            # Gancho para executar uma ação antes de cada tentativa
            if self._before_attempts:
                self._before_attempts(contador_tentativas)

            try:
                # Tenta executar a função alvo
                resultado_funcao = fn(*args, **kwargs)
                tentativa_atual = Attempt(resultado_funcao, contador_tentativas, False)
            except:
                # Captura qualquer exceção
                info_exc = sys.exc_info() # Retorna (tipo, valor, traceback)
                tentativa_atual = Attempt(info_exc, contador_tentativas, True)

            # Verifica se devemos reintentar com base no resultado ou exceção
            if not self.deve_rejeitar(tentativa_atual):
                return tentativa_atual.get(self._wrap_exception)
            
            # Gancho para executar uma ação após cada tentativa
            if self._after_attempts:
                self._after_attempts(contador_tentativas)
            
            atraso_desde_primeira_tentativa_ms = int(round(time.time() * 1000)) - inicio_tempo
            
            # Verifica se devemos parar com base nas condições de parada configuradas
            if self.parar_execucao(contador_tentativas, atraso_desde_primeira_tentativa_ms):
                if not self._wrap_exception and tentativa_atual.has_exception:
                    # Se não deve encapsular e houve exceção, re-levanta a original
                    raise tentativa_atual.get()
                else:
                    # Caso contrário, levanta RetryError
                    raise RetryError(tentativa_atual)
            else:
                # Calcula o tempo de espera antes da próxima tentativa
                tempo_espera_ms = self.calcular_espera(contador_tentativas, atraso_desde_primeira_tentativa_ms)
                if self._wait_jitter_max:
                    # Adiciona um "jitter" (aleatoriedade) ao tempo de espera
                    jitter_val = random.random() * self._wait_jitter_max
                    tempo_espera_ms = tempo_espera_ms + max(0, jitter_val)
                
                # Pausa a execução pelo tempo calculado
                time.sleep(tempo_espera_ms / 1000.0)

            contador_tentativas += 1 # Prepara para a próxima tentativa

O método call opera em um loop infinito (while True) que só é interrompido por um sucesso ou por uma condição de parada. Cada iteração representa uma tentativa. Os objetos Attempt encapsulam o resultado (seja um valor retornado ou uma exceção) e o número da tentativa, fornecendo um estado consistente para as decisões de reintento.

Lógica de Decisão: deve_rejeitar

Após cada tentativa, a biblioteca determina se deve reintentar ou não, usando o método deve_rejeitar (equivalente a should_reject). Este método combina as condições definidas por retry_on_exception e retry_on_result:

class Retrying:
    # ... (outros métodos) ...
    def deve_rejeitar(self, tentativa):
        """
        Determina se a tentativa atual deve ser rejeitada (ie, deve-se reintentar).
        Retorna True se a operação deve ser reintentada, False para sucesso.
        """
        reintentar_cond = False
        if tentativa.has_exception:
            # Se houver exceção, verifica se a exceção está nas condições de reintento
            reintentar_cond |= self._retry_on_exception(tentativa.valor[1])
        else:
            # Se não houver exceção, verifica o resultado da função
            reintentar_cond |= self._retry_on_result(tentativa.valor) 
        return reintentar_cond

A função _retry_on_exception (por padrão, reintenta em qualquer exceção) é chamada se uma exceção ocorre. Se a função não levanta uma exceção, _retry_on_result (por padrão, não reintenta em nenhum resultado) é chamada para avaliar o valor de retorno.

Estratégias de Parada

As condições de parada são configuradas no construtor da classe Retrying. Funções como parar_apos_tentativa e parar_apos_atraso são combinadas em parar_execucao. A execução é interrompida se qualquer uma dessas condições for atendida.

class Retrying:
    # ... (outros métodos) ...

    def parar_apos_tentativa(self, numero_tentativa_anterior, atraso_total_ms):
        """Para após o número de tentativas atingir o limite."""
        return numero_tentativa_anterior >= self._stop_max_attempt_number

    def parar_apos_atraso(self, numero_tentativa_anterior, atraso_total_ms):
        """Para após o atraso total desde a primeira tentativa atingir o limite."""
        return atraso_total_ms >= self._stop_max_delay

Estratégias de Espera

De forma similar às estratégias de parada, as estratégias de espera são configuradas no __init__. A função calcular_espera combina diferentes lógicas de atraso (fixo, aleatório, exponencial) e retorna o maior valor de espera calculado por elas.

class Retrying:
    # ... (outros métodos) ...

    def espera_fixa(self, numero_tentativa_anterior, atraso_total_ms):
        """Retorna um tempo de espera fixo."""
        return self._wait_fixed

    def espera_aleatoria(self, numero_tentativa_anterior, atraso_total_ms):
        """Retorna um tempo de espera aleatório."""
        return random.randint(self._wait_random_min, self._wait_random_max)

    def espera_exponencial(self, numero_tentativa_anterior, atraso_total_ms):
        """
        Calcula um tempo de espera exponencialmente crescente.
        O atraso é _wait_exponential_multiplier * 2^(número da tentativa),
        limitado por _wait_exponential_max.
        """
        multiplicador = self._wait_exponential_multiplier
        limite_maximo = self._wait_exponential_max

        # Calcula 2 elevado ao número da tentativa
        fator_exponencial = 2 ** numero_tentativa_anterior 
        
        # Calcula o resultado do atraso
        resultado_atraso = multiplicador * fator_exponencial
        
        # Aplica o limite máximo
        if resultado_atraso > limite_maximo:
            resultado_atraso = limite_maximo
        
        # Garante que o atraso não seja negativo
        if resultado_atraso < 0:
            resultado_atraso = 0
            
        return resultado_atraso

Tags: Python Retrying decorators RetryLogic ExceptionHandling

Publicado em 10-30 00:34