apibrasil 1.0.0

SDK oficial Rust da plataforma APIBrasil: WhatsApp, SMS, consultas de CPF/CNPJ, veiculos, CEP, correios, pagamentos PIX/boleto e mais.
Documentation
//! Política de retry: o que é refeito, o que não é, e o backoff.

mod common;

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

use apibrasil::{Config, FnHooks, RetryConfig};
use common::{build_api_with, http_error, ok};
use serde_json::json;

fn retry_rapido(retries: u32) -> RetryConfig {
    RetryConfig {
        retries,
        min_delay: Duration::from_millis(1),
        max_delay: Duration::from_millis(5),
        ..Default::default()
    }
}

#[tokio::test]
async fn refaz_em_429_ate_o_limite_de_tentativas() {
    let (api, transport) = build_api_with(Config::new().retry(retry_rapido(2)));
    transport
        .respond_with(http_error(429, json!({ "message": "devagar" }), &[]))
        .respond_with(http_error(429, json!({ "message": "devagar" }), &[]))
        .respond_with(ok(json!({ "error": false })));

    api.consulta().cpf(json!({ "cpf": "0" })).await.unwrap();

    assert_eq!(transport.count(), 3);
}

#[tokio::test]
async fn desiste_depois_de_esgotar_as_tentativas() {
    let (api, transport) = build_api_with(Config::new().retry(retry_rapido(1)));
    transport.set_fallback(http_error(429, json!({ "message": "devagar" }), &[]));

    let error = api.consulta().cpf(None).await.unwrap_err();

    assert!(error.is_rate_limit());
    assert_eq!(transport.count(), 2);
}

#[tokio::test]
async fn refaz_em_falhas_de_conexao() {
    let (api, transport) = build_api_with(Config::new().retry(retry_rapido(2)));
    transport
        .fail_with(apibrasil::Error::network("conexão recusada"))
        .respond_with(ok(json!({ "ok": true })));

    api.consulta().cpf(None).await.unwrap();

    assert_eq!(transport.count(), 2);
}

#[tokio::test]
async fn nao_refaz_em_timeout_para_nao_duplicar_cobranca() {
    let (api, transport) = build_api_with(Config::new().retry(retry_rapido(3)));
    transport.fail_with(apibrasil::Error::timeout("tempo limite"));

    let error = api.consulta().cpf(None).await.unwrap_err();

    assert!(error.is_timeout());
    assert_eq!(transport.count(), 1);
}

#[tokio::test]
async fn nao_refaz_em_erros_de_negocio() {
    for status in [400, 401, 402, 403, 404, 500] {
        let (api, transport) = build_api_with(Config::new().retry(retry_rapido(3)));
        transport.set_fallback(http_error(status, json!({ "message": "x" }), &[]));

        api.consulta().cpf(None).await.unwrap_err();

        assert_eq!(transport.count(), 1, "status {status} não deve ser refeito");
    }
}

#[tokio::test]
async fn retry_desligado_faz_uma_unica_tentativa() {
    let (api, transport) = build_api_with(Config::new().retry(RetryConfig::none()));
    transport.set_fallback(http_error(429, json!({}), &[]));

    api.consulta().cpf(None).await.unwrap_err();

    assert_eq!(transport.count(), 1);
}

#[tokio::test]
async fn status_extras_podem_ser_refeitos() {
    let (api, transport) = build_api_with(Config::new().retry(RetryConfig {
        retry_on_statuses: vec![429, 503],
        ..retry_rapido(1)
    }));
    transport
        .respond_with(http_error(503, json!({}), &[]))
        .respond_with(ok(json!({ "ok": true })));

    api.consulta().cpf(None).await.unwrap();

    assert_eq!(transport.count(), 2);
}

#[tokio::test]
async fn hook_de_retry_informa_tentativa_e_motivo() {
    let retries = Arc::new(AtomicUsize::new(0));
    let hooks = {
        let retries = retries.clone();
        FnHooks::new().on_retry(move |info| {
            assert_eq!(info.reason, "HTTP 429");
            assert_eq!(info.attempt, 1);
            assert_eq!(info.delay, Duration::from_millis(50));
            retries.fetch_add(1, Ordering::SeqCst);
        })
    };

    let (api, transport) = build_api_with(
        Config::new()
            .retry(RetryConfig {
                retries: 1,
                min_delay: Duration::from_millis(1),
                max_delay: Duration::from_millis(1),
                ..Default::default()
            })
            .hooks(hooks),
    );
    transport
        // Retry-After tem prioridade sobre o backoff calculado.
        .respond_with(http_error(429, json!({}), &[("Retry-After", "0.05")]))
        .respond_with(ok(json!({ "ok": true })));

    api.consulta().cpf(None).await.unwrap();

    assert_eq!(retries.load(Ordering::SeqCst), 1);
}

#[test]
fn backoff_cresce_e_respeita_o_teto() {
    let retry = RetryConfig {
        retries: 5,
        min_delay: Duration::from_millis(100),
        max_delay: Duration::from_millis(500),
        retry_on_statuses: vec![429],
    };

    // min_delay * 2^attempt com jitter em [0.5, 1.0), limitado ao teto.
    assert!(retry.backoff_delay(0) >= Duration::from_millis(50));
    assert!(retry.backoff_delay(0) < Duration::from_millis(100));
    assert!(retry.backoff_delay(1) >= Duration::from_millis(100));
    assert!(retry.backoff_delay(1) < Duration::from_millis(200));
    assert_eq!(retry.backoff_delay(20), Duration::from_millis(500));
}

#[test]
fn politica_padrao_refaz_so_em_429() {
    let retry = RetryConfig::default();

    assert_eq!(retry.retries, 2);
    assert_eq!(retry.max_attempts(), 3);
    assert!(retry.retries_status(429));
    assert!(!retry.retries_status(500));
    assert!(!retry.retries_status(402));
    assert_eq!(RetryConfig::none().max_attempts(), 1);
}