Estratégias de recuperação de erros
LidoNesta aula, exploramos estratégias robustas de recuperação de erros em Rust: retry com backoff, fallback para valores ou funções alternativas, circuit breaker para evitar falhas em cascata e logging estruturado com crates como log e env_logger. Através de exemplos práticos, você aprenderá a implementar essas técnicas em seus programas Rust para aumentar a resiliência e a confiabilidade.
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
- The Rust Programming Language - Error Handling
- backoff crate documentation
- reqwest crate documentation
- log crate documentation
- env_logger crate documentation
- Martin Fowler - Circuit Breaker
- tracing crate documentation
Exercícios
- Implemente uma função de retry manual sem usar crates, que tenta uma operação que retorna
Resultaté 3 vezes com um delay fixo de 1 segundo entre tentativas. Usestd::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) } - Crie um fallback para uma função que retorna
Option: se a função retornarNone, use um valor padrãoStringcomo "default". - Implemente um circuit breaker simples que, após 2 falhas consecutivas, abre o circuito por 5 segundos. Use um
AtomicUsizepara contar falhas e umMutex<Instant>para o timestamp. - Adicione logging a um programa simples que simula uma operação que pode falhar. Use
env_loggere registre mensagens de info, warn e error. Inicialize o logger emmain. - 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.
fn get_optional() -> Option<String> {
None
}
fn main() {
let value = get_optional().unwrap_or_else(|| "default".to_string());
println!("{}", value);
}
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)
}
}
}
}
}
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");
}
}
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);
}
}
}
}