Em sistemas de software, especialmente aqueles que dependem de serviços externos, redes ou recursos compartilhados, erros são inevitáveis. A diferença entre um sistema frágil e um resiliente está na forma como lidamos com essas falhas. Nesta aula, vamos explorar estratégias de recuperação de erros que permitem que seu programa continue operando de forma útil mesmo quando algo dá errado. Em Rust, a combinação de tipos como Result e Option com crates especializados nos dá ferramentas poderosas para implementar essas estratégias de forma segura e idiomática.

Veremos quatro pilares essenciais: retry (tentar novamente), fallback (fornecer uma alternativa), circuit breaker (interromper operações que estão falhando repetidamente) e logging (registrar eventos para diagnóstico). Cada uma dessas estratégias resolve um problema específico, e juntas formam uma abordagem completa para lidar com falhas temporárias, degradação de serviços e erros inesperados.

Retry

A estratégia de retry consiste em tentar executar uma operação que falhou novamente, na esperança de que a falha seja transitória (por exemplo, uma queda momentânea de rede ou um serviço sobrecarregado). No entanto, repetir a operação imediatamente pode sobrecarregar ainda mais o sistema. Por isso, é comum implementar backoff, ou seja, um intervalo crescente entre as tentativas.

Em Rust, podemos implementar retry manualmente com um loop e std::thread::sleep, ou usar crates como backoff ou retry. Vamos criar um exemplo simples usando a crate backoff, que oferece políticas de backoff exponencial com jitter (aleatoriedade) para evitar rajadas de requisições.

Primeiro, adicione a dependência ao seu Cargo.toml:

[dependencies]
backoff = "0.4"
reqwest = { version = "0.11", features = ["blocking"] }

Agora, um exemplo de retry para uma chamada HTTP:

use backoff::ExponentialBackoff;
use backoff::future::retry;
use std::time::Duration;

async fn fetch_data() -> Result<String, reqwest::Error> {
    let response = reqwest::get("https://api.example.com/data").await?;
    response.text().await
}

#[tokio::main]
async fn main() {
    let backoff = ExponentialBackoff {
        max_elapsed_time: Some(Duration::from_secs(30)),
        ..Default::default()
    };

    match retry(backoff, || async { fetch_data().await }).await {
        Ok(data) => println!("Dados obtidos: {}", data),
        Err(err) => eprintln!("Falha após todas as tentativas: {}", err),
    }
}

Neste código, retry tenta executar a closure async várias vezes, respeitando a política de backoff. Se todas as tentativas falharem, retorna o último erro. É importante notar que a operação deve ser idempotente ou segura para repetir, pois pode ser executada múltiplas vezes.

Fallback

Fallback é uma estratégia em que, quando uma operação falha, usamos um valor padrão ou uma alternativa funcional. Isso é especialmente útil quando a falha não é crítica e podemos degradar graciosamente o serviço. Em Rust, podemos usar unwrap_or, unwrap_or_else para Option e Result, ou implementar um fluxo mais complexo com match.

Por exemplo, suponha que você precise buscar uma configuração de um servidor remoto, mas se isso falhar, use uma configuração local padrão:

use std::collections::HashMap;

fn fetch_remote_config() -> Result<HashMap<String, String>, String> {
    // Simula uma falha na rede
    Err("Falha de conexão".to_string())
}

fn local_config() -> HashMap<String, String> {
    let mut map = HashMap::new();
    map.insert("theme".to_string(), "dark".to_string());
    map.insert("language".to_string(), "pt-BR".to_string());
    map
}

fn main() {
    let config = fetch_remote_config().unwrap_or_else(|_| local_config());
    println!("Configuração: {:?}", config);
}

Fallback também pode envolver chamar um serviço alternativo (ex.: um CDN de backup) ou usar dados em cache. O princípio é sempre ter um plano B.

Circuit breaker (conceito)

O padrão circuit breaker (disjuntor) é usado para evitar que um sistema continue fazendo chamadas a um serviço que está falhando repetidamente, prevenindo sobrecarga e falhas em cascata. Ele funciona como um interruptor elétrico: quando o número de falhas excede um limite, o circuito “abre” e as chamadas falham imediatamente (ou usam fallback), sem tentar a operação real. Após um período de espera, o circuito entra em estado “half-open”, permitindo um teste para ver se o serviço se recuperou.

Em Rust, podemos implementar um circuit breaker simples usando um enum de estados e um contador de falhas. Crates como circuit-breaker ou resilient4j existem, mas vamos criar um exemplo didático:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

enum CircuitState {
    Closed,   // normal, permite chamadas
    Open,     // falha imediatamente
    HalfOpen, // tenta uma chamada de teste
}

struct CircuitBreaker {
    failure_threshold: usize,
    timeout: Duration,
    state: std::sync::Mutex<CircuitState>,
    failure_count: AtomicUsize,
    last_failure_time: std::sync::Mutex<Option<Instant>>,
}

impl CircuitBreaker {
    fn new(failure_threshold: usize, timeout: Duration) -> Self {
        Self {
            failure_threshold,
            timeout,
            state: std::sync::Mutex::new(CircuitState::Closed),
            failure_count: AtomicUsize::new(0),
            last_failure_time: std::sync::Mutex::new(None),
        }
    }

    fn call<F, T, E>(&self, f: F) -> Result<T, E> where F: Fn() -> Result<T, E> {
        let mut state = self.state.lock().unwrap();
        match *state {
            CircuitState::Open => {
                // Verifica se o timeout expirou
                if let Some(last) = self.last_failure_time.lock().unwrap().as_ref() {
                    if last.elapsed() > self.timeout {
                        *state = CircuitState::HalfOpen;
                    } else {
                        return Err("circuit open".into());
                    }
                }
            }
            CircuitState::HalfOpen => {
                // Vamos tentar uma chamada de teste
                // Se falhar, volta para Open; se sucesso, fecha
                *state = CircuitState::Closed;
            }
            CircuitState::Closed => {}
        }
        drop(state);

        match f() {
            Ok(v) => {
                self.failure_count.store(0, Ordering::SeqCst);
                Ok(v)
            }
            Err(e) => {
                let count = self.failure_count.fetch_add(1, Ordering::SeqCst) + 1;
                *self.last_failure_time.lock().unwrap() = Some(Instant::now());
                if count >= self.failure_threshold {
                    *self.state.lock().unwrap() = CircuitState::Open;
                }
                Err(e)
            }
        }
    }
}

Este é um esboço conceitual; em produção, você usaria uma crate testada. Mas o padrão é claro: monitore falhas e corte o circuito quando necessário.

Logging

Logging é fundamental para diagnosticar problemas e entender o comportamento do sistema. Em Rust, a crate log define uma fachada de logging, e env_logger ou tracing são implementações comuns. Vamos usar log e env_logger para registrar eventos de erro, aviso e informação.

Adicione as dependências:

[dependencies]
log = "0.4"
env_logger = "0.10"

Exemplo de uso:

use log::{info, warn, error};

fn main() {
    env_logger::init();

    info!("Iniciando aplicação");

    match perform_operation() {
        Ok(_) => info!("Operação concluída"),
        Err(e) => {
            error!("Falha na operação: {}", e);
            // Pode enviar alertas, etc.
        }
    }
}

fn perform_operation() -> Result<(), String> {
    warn!("Esta operação pode falhar");
    Err("algo deu errado".to_string())
}

É importante logar com níveis apropriados: error para falhas, warn para situações anormais, info para eventos importantes, e debug/trace para detalhes. Em sistemas assíncronos, considere usar tracing para correlação entre spans.

Boas práticas e observações finais

  • Combine estratégias: use retry com fallback; por exemplo, tente novamente algumas vezes e, se ainda falhar, use um valor padrão.
  • Seja idempotente: operações repetidas não devem ter efeitos colaterais indesejados.
  • Monitore e meça: registre métricas de falhas e sucessos para ajustar limites do circuit breaker.
  • Evite retry infinito: sempre estabeleça um limite máximo de tentativas e um tempo total.
  • Teste os cenários de falha: use injeção de falhas para garantir que suas estratégias funcionem.

Referências

Exercícios

  1. Implemente uma função de retry manual sem usar crates, que tenta uma operação que retorna Result até 3 vezes com um delay fixo de 1 segundo entre tentativas. Use std::thread::sleep.

    ✓ Resposta:
    use std::thread;
    use std::time::Duration;
    
    fn retry<T, E, F>(mut f: F, max_retries: u32) -> Result<T, E>
    where
        F: FnMut() -> Result<T, E>,
    {
        let mut attempt = 0;
        loop {
            match f() {
                Ok(value) => return Ok(value),
                Err(e) => {
                    attempt += 1;
                    if attempt >= max_retries {
                        return Err(e);
                    }
                    thread::sleep(Duration::from_secs(1));
                }
            }
        }
    }
    
    fn main() {
        let mut calls = 0;
        let result = retry(
            || {
                calls += 1;
                if calls < 3 {
                    Err("falha".to_string())
                } else {
                    Ok(calls)
                }
            },
            3,
        );
        println!("{:?}", result); // Ok(3)
    }
    
  2. Crie um fallback para uma função que retorna Option: se a função retornar None, use um valor padrão String como "default".
  3. ✓ Resposta:
    fn get_optional() -> Option<String> {
        None
    }
    
    fn main() {
        let value = get_optional().unwrap_or_else(|| "default".to_string());
        println!("{}", value);
    }
    
  4. Implemente um circuit breaker simples que, após 2 falhas consecutivas, abre o circuito por 5 segundos. Use um AtomicUsize para contar falhas e um Mutex<Instant> para o timestamp.
  5. ✓ Resposta:
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Mutex;
    use std::time::{Duration, Instant};
    
    struct SimpleCircuitBreaker {
        failure_count: AtomicUsize,
        open_since: Mutex<Option<Instant>>,
    }
    
    impl SimpleCircuitBreaker {
        fn new() -> Self {
            Self {
                failure_count: AtomicUsize::new(0),
                open_since: Mutex::new(None),
            }
        }
    
        fn is_open(&self) -> bool {
            if let Some(since) = *self.open_since.lock().unwrap() {
                if since.elapsed() > Duration::from_secs(5) {
                    // Reset
                    self.failure_count.store(0, Ordering::SeqCst);
                    *self.open_since.lock().unwrap() = None;
                    false
                } else {
                    true
                }
            } else {
                false
            }
        }
    
        fn record_failure(&self) {
            let count = self.failure_count.fetch_add(1, Ordering::SeqCst) + 1;
            if count >= 2 {
                *self.open_since.lock().unwrap() = Some(Instant::now());
            }
        }
    
        fn call<F, T, E>(&self, f: F) -> Result<T, E>
        where
            F: Fn() -> Result<T, E>,
        {
            if self.is_open() {
                Err("circuit open".into())
            } else {
                match f() {
                    Ok(v) => Ok(v),
                    Err(e) => {
                        self.record_failure();
                        Err(e)
                    }
                }
            }
        }
    }
    
  6. Adicione logging a um programa simples que simula uma operação que pode falhar. Use env_logger e registre mensagens de info, warn e error. Inicialize o logger em main.
  7. ✓ Resposta:
    use log::{info, warn, error};
    
    fn main() {
        env_logger::init();
    
        info!("Iniciando programa");
        let success = std::env::args().nth(1).map(|s| s == "ok").unwrap_or(false);
        if success {
            info!("Operação bem-sucedida");
        } else {
            warn!("Operação pode falhar");
            error!("Falha na operação");
        }
    }
    
  8. Combine retry e fallback: escreva uma função que tenta executar uma operação até 3 vezes com 500ms de espera e, se falhar, retorna um valor padrão 0.
  9. ✓ Resposta:
    use std::thread;
    use std::time::Duration;
    
    fn fallible() -> Result<i32, String> {
        Err("falha".to_string())
    }
    
    fn main() {
        let result = retry_with_fallback(3, Duration::from_millis(500), fallible, 0);
        println!("{}", result); // 0
    }
    
    fn retry_with_fallback<F>(max_retries: u32, delay: Duration, f: F, fallback: i32) -> i32
    where
        F: Fn() -> Result<i32, String>,
    {
        let mut attempts = 0;
        loop {
            match f() {
                Ok(value) => return value,
                Err(_) => {
                    attempts += 1;
                    if attempts >= max_retries {
                        return fallback;
                    }
                    thread::sleep(delay);
                }
            }
        }
    }