Timeout e retry logic em automações

1. Por que Timeout e Retry são Essenciais em Automações

Automações em Bash frequentemente interagem com recursos externos: APIs web, servidores remotos, bancos de dados ou sistemas de arquivos em rede. Esses componentes estão sujeitos a falhas temporárias — rede instável, serviços momentaneamente indisponíveis, comandos que demoram mais que o esperado.

Sem proteções adequadas, um script pode:
- Entrar em loop infinito aguardando resposta de um servidor morto
- Consumir CPU e memória desnecessariamente em tentativas repetidas
- Travar completamente a pipeline de automação

A abordagem defensiva assume que falhas ocorrerão e prepara mecanismos de contenção (timeout + retry limitado). Já a abordagem otimista executa sem verificações, adequada apenas para ambientes controlados com garantia de disponibilidade.

2. Implementando Timeout com timeout e alarm

Usando o comando timeout (coreutils)

O comando timeout é a forma mais direta de limitar a execução de um processo:

#!/bin/bash

# Executa com timeout de 5 segundos
timeout 5 ping -c 1 google.com

# Verifica se houve timeout (código 124)
if [ $? -eq 124 ]; then
    echo "Comando excedeu o tempo limite"
fi

Alternativa com trap e SIGALRM

Em ambientes sem timeout (ex: macOS mais antigo ou shells minimalistas), podemos implementar timeout manual:

#!/bin/bash

exec_com_timeout() {
    local timeout=$1
    shift
    ( 
        trap 'exit 124' SIGALRM
        (sleep "$timeout" && kill -ALRM $$) &
        "$@"
        kill $! 2>/dev/null
    )
}

exec_com_timeout 5 ping -c 1 google.com

Capturando saída parcial

Para preservar a saída gerada antes do timeout:

#!/bin/bash

saida=$(timeout 3 curl -s http://exemplo.com 2>&1)
codigo=$?

if [ $codigo -eq 124 ]; then
    echo "Timeout - saída parcial capturada:"
    echo "$saida"
fi

3. Estratégias de Retry: Loops Simples e Backoff

Retry fixo com intervalo constante

#!/bin/bash

MAX_TENTATIVAS=3
INTERVALO=2
COMANDO="curl -s http://api.exemplo.com/status"

for tentativa in $(seq 1 $MAX_TENTATIVAS); do
    if $COMANDO; then
        echo "Sucesso na tentativa $tentativa"
        break
    fi
    echo "Tentativa $tentativa falhou. Aguardando ${INTERVALO}s..."
    sleep $INTERVALO
done

Backoff exponencial com teto máximo

#!/bin/bash

MAX_TENTATIVAS=5
TETO_BACKOFF=30

for tentativa in $(seq 0 $((MAX_TENTATIVAS - 1))); do
    if curl -s http://api.exemplo.com; then
        echo "Concluído na tentativa $((tentativa + 1))"
        exit 0
    fi

    espera=$((2 ** tentativa))
    [ $espera -gt $TETO_BACKOFF ] && espera=$TETO_BACKOFF
    echo "Aguardando ${espera}s antes da próxima tentativa..."
    sleep $espera
done

echo "Falha após $MAX_TENTATIVAS tentativas"
exit 1

Jitter para evitar thundering herd

#!/bin/bash

BASE=5
MAX_JITTER=5

for tentativa in $(seq 1 3); do
    if curl -s http://api.exemplo.com; then
        exit 0
    fi

    espera=$((BASE + RANDOM % MAX_JITTER))
    sleep $espera
done

4. Combinando Timeout e Retry com Funções Reutilizáveis

Função genérica que encapsula toda a lógica:

#!/bin/bash

exec_with_retry() {
    local comando="$1"
    local max_tentativas=${2:-3}
    local timeout_cmd=${3:-10}
    local intervalo=${4:-2}
    local tentativa=0

    while [ $tentativa -lt $max_tentativas ]; do
        tentativa=$((tentativa + 1))
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] Tentativa $tentativa de $max_tentativas"

        timeout $timeout_cmd bash -c "$comando"
        local codigo=$?

        if [ $codigo -eq 0 ]; then
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] Comando executado com sucesso"
            return 0
        fi

        if [ $codigo -eq 124 ]; then
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] Timeout na tentativa $tentativa"
        else
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] Erro (código $codigo) na tentativa $tentativa"
        fi

        [ $tentativa -lt $max_tentativas ] && sleep $intervalo
    done

    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Todas as $max_tentativas tentativas falharam"
    return 1
}

# Uso:
exec_with_retry "curl -s http://api.exemplo.com/health" 5 10 3

5. Tratamento de Saída e Logging em Falhas

Diferenciando timeout de erro do comando

#!/bin/bash

timeout 5 ping -c 1 servidor.local
case $? in
    0) echo "Sucesso" ;;
    124) echo "Timeout - servidor não respondeu em 5s" ;;
    *) echo "Erro no comando ping" ;;
esac

Log estruturado com redirecionamento

#!/bin/bash

LOG_FILE="/var/log/automacao.log"
exec 2>> "$LOG_FILE"

exec_with_retry_logged() {
    local comando="$1"
    local saida

    saida=$(timeout 10 bash -c "$comando" 2>&1)
    local codigo=$?

    echo "$(date '+%Y-%m-%d %H:%M:%S') | Código: $codigo | Saída: $saida" >> "$LOG_FILE"

    return $codigo
}

6. Casos de Uso Reais: APIs, Downloads e Comandos Remotos

Retry em requisições HTTP com curl

O curl possui retry nativo, mas combiná-lo com nossa função oferece mais controle:

#!/bin/bash

# Usando retry nativo do curl
curl --retry 3 --retry-delay 5 --retry-max-time 30 http://api.exemplo.com

# Implementação manual com mais controle
exec_with_retry "curl --connect-timeout 5 --max-time 10 -s http://api.exemplo.com" 3 15 2

Timeout em conexões SSH

#!/bin/bash

# Evita conexão SSH pendurada
timeout 15 ssh -o ConnectTimeout=5 usuario@servidor "uptime"

if [ $? -eq 124 ]; then
    echo "Conexão SSH excedeu o tempo limite"
fi

Script completo de monitoramento com ping

#!/bin/bash

monitorar_host() {
    local host=$1
    local max_tentativas=3

    for tentativa in $(seq 1 $max_tentativas); do
        if timeout 3 ping -c 1 "$host" &>/dev/null; then
            echo "$(date) - $host: OK"
            return 0
        fi

        local espera=$((2 ** tentativa))
        echo "$(date) - $host: Tentativa $tentativa falhou. Aguardando ${espera}s"
        sleep $espera
    done

    echo "$(date) - $host: FORA DO AR após $max_tentativas tentativas"
    return 1
}

monitorar_host "8.8.8.8"
monitorar_host "servidor.interno"

7. Boas Práticas e Armadilhas Comuns

Evitando retry infinito

Sempre defina um limite máximo explícito:

# ERRADO - loop infinito se comando sempre falhar
while ! comando; do sleep 1; done

# CORRETO - limite de tentativas
for i in $(seq 1 5); do comando && break; done

Cuidado com subshells e variáveis

#!/bin/bash

# Variáveis em subshell não afetam o shell pai
contador=0
for i in 1 2 3; do
    (contador=$((contador + 1)))  # Não funciona!
done

# CORRETO - alteração direta
contador=0
for i in 1 2 3; do
    contador=$((contador + 1))
done

Testando a lógica com mocks

Para testar sua lógica de retry, crie comandos mock que simulam falhas:

#!/bin/bash

# Mock de comando que falha nas primeiras tentativas
mock_comando() {
    local arquivo_tentativa="/tmp/tentativa_$$"

    if [ ! -f "$arquivo_tentativa" ]; then
        echo 1 > "$arquivo_tentativa"
        return 1  # Falha na primeira tentativa
    fi

    local tentativa=$(cat "$arquivo_tentativa")
    if [ "$tentativa" -lt 2 ]; then
        echo $((tentativa + 1)) > "$arquivo_tentativa"
        return 1  # Falha na segunda tentativa
    fi

    rm -f "$arquivo_tentativa"
    return 0  # Sucesso na terceira tentativa
}

# Teste
exec_with_retry "mock_comando" 5 5 1
echo "Resultado: $?"

Referências