Volver al blog

API de tipos de cambio de divisas en Rust — Tutorial completo de integración

V
Vlado Grigirov
August 04, 2026
Rust Currency API Exchange Rates Tutorial reqwest Tokio Fintech

Si estás construyendo infraestructura fintech, un motor de precios, una herramienta de línea de comandos o un backend de alto rendimiento, tarde o temprano necesitarás datos de divisas en tiempo real. Conectar una API de tipos de cambio de divisas en Rust es una de las tareas de red más limpias que ofrece el lenguaje: con reqwest, tokio y serde obtienes un cliente HTTP asíncrono, deserialización automática de JSON y garantías en tiempo de compilación de que tu código de manejo de tipos de cambio es correcto antes incluso de ejecutarse. Este tutorial recorre todo el camino, desde tu primera petición GET hasta un conversor de divisas con forma de producción con un cliente reutilizable, modelos tipados, manejo de errores personalizado, aritmética monetaria segura con decimales y una caché en memoria que te mantiene dentro del plan gratuito.

Al final tendrás un módulo de Rust pequeño y reutilizable construido sobre la documentación de la API de Finexly que convierte entre cualquiera de más de 170 divisas con tipos en tiempo real. Si ya has leído nuestro tutorial de la API de divisas en Go, este es el equivalente en Rust con la misma arquitectura: un cliente tipado, una caché y una propagación de errores limpia.

Por qué Rust encaja tan bien con los datos de divisas

Los datos de divisas están limitados por E/S, son sensibles a la latencia y —en cualquier sistema que maneje dinero— críticos en cuanto a exactitud. Rust está inusualmente bien preparado para las tres cosas:

  • Asíncrono por defecto. reqwest está construido sobre hyper y tokio, así que las consultas concurrentes de tipos son baratas y no bloqueantes. Puedes solicitar decenas de pares de divisas en paralelo sin generar hilos del sistema operativo.
  • El sistema de tipos detecta errores pronto. Una struct derivada con serde significa que una respuesta malformada o un campo ausente es un error en tiempo de compilación o de deserialización, no un misterioso null tres capas más abajo en producción.
  • Sin recolector de basura, latencia predecible. Para un servicio que cotiza tipos de cambio bajo carga, la ausencia de pausas del GC importa.
  • Dinero seguro con decimales. Con el crate rust_decimal obtienes aritmética de punto fijo, así que nunca lanzas un error de redondeo que convierta 100,00 € en 99,999999 €.

La contrapartida es una configuración algo más compleja que la de un lenguaje de scripting, pero el resultado es un cliente de divisas que puedes incorporar a un servicio y en el que puedes confiar.

Lo que vas a construir

Un FinexlyClient reutilizable que:

  1. Obtiene los últimos tipos para una divisa base desde una API de tipos de cambio de divisas.
  2. Deserializa el JSON en structs de Rust tipadas con serde.
  3. Convierte entre cualquier par de divisas, incluso cuando ninguna es la base (tipos cruzados).
  4. Maneja errores —fallos de red, respuestas distintas de 200, límites de tasa— con un tipo de error personalizado.
  5. Almacena en caché las respuestas en memoria con un TTL para que te mantengas holgadamente dentro de un plan gratuito.

Requisitos previos y configuración del proyecto

Necesitas una cadena de herramientas estable y reciente de Rust (instálala mediante rustup) y una clave gratuita de la API de Finexly. Puedes registrarte gratis en menos de un minuto —sin tarjeta de crédito, 1.000 peticiones al mes en el plan gratuito.

Crea un nuevo proyecto y añade las dependencias:

cargo new finexly-rates
cd finexly-rates
cargo add tokio --features full
cargo add reqwest --features json
cargo add serde --features derive
cargo add thiserror
cargo add rust_decimal rust_decimal_macros

Las dependencias de tu Cargo.toml deberían ahora verse así:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_decimal = "1"
rust_decimal_macros = "1"

Una nota rápida sobre los crates: reqwest es el cliente HTTP asíncrono de facto para Rust; la característica json incorpora serde_json y habilita el ayudante .json(). serde con derive te da #[derive(Deserialize)]. thiserror facilita enums de error personalizados y ergonómicos. rust_decimal proporciona decimales de punto fijo para el dinero.

Guarda tu clave en una variable de entorno para que nunca acabe en el control de versiones:

export FINEXLY_API_KEY="your_api_key_here"

La forma de la respuesta de la API de Finexly

Antes de escribir nada de código, observa lo que devuelve el endpoint. Una petición al endpoint de últimos tipos:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=YOUR_KEY"

devuelve un objeto JSON pequeño y predecible:

{
  "success": true,
  "base": "USD",
  "timestamp": 1753660800,
  "rates": {
    "EUR": 0.9213,
    "GBP": 0.7847,
    "JPY": 161.42
  }
}

Dos cosas dan forma al código de abajo. Primero, rates es un mapa plano de código de divisa ISO 4217 a un número en coma flotante, lo que se corresponde limpiamente con un HashMap<String, f64> de Rust. Segundo, base te indica respecto a qué son esos tipos. Para convertir USD → EUR multiplicas por rates["EUR"]. Para convertir entre dos divisas que no son la base, pasas por la base: divides por una y multiplicas por la otra.

Tu primera petición con reqwest

Empieza con la petición asíncrona más simple posible. Reemplaza src/main.rs por:

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let api_key = std::env::var("FINEXLY_API_KEY")
        .expect("Set FINEXLY_API_KEY in your environment");

    let url = format!(
        "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey={api_key}"
    );

    let body = reqwest::get(&url).await?.text().await?;
    println!("{body}");

    Ok(())
}

Ejecútalo con cargo run y verás el JSON en bruto impreso. La macro #[tokio::main] configura el runtime asíncrono, reqwest::get realiza la petición, y cada .await? suspende la tarea hasta que la red responde mientras propaga cualquier error. Esto funciona, pero reqwest::get crea un cliente completamente nuevo en cada llamada —está bien para algo puntual, pero es incorrecto para una aplicación real. Lo arreglaremos en breve.

Modelando el JSON con Serde

Imprimir una cadena no es útil; lo que quieres son datos tipados. Define structs que reflejen la respuesta y deja que serde haga el análisis:

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
pub struct RatesResponse {
    pub success: bool,
    pub base: String,
    pub timestamp: i64,
    pub rates: HashMap<String, f64>,
}

Ahora cambia .text() por .json(), que deserializa el cuerpo directamente en tu struct:

let data: RatesResponse = reqwest::get(&url).await?.json().await?;
println!("1 USD = {} EUR", data.rates["EUR"]);

El método json() lee el cuerpo de la respuesta y ejecuta serde_json por debajo. Si falta un campo o es de un tipo incorrecto, obtienes un error de deserialización claro en lugar de un silencioso null. Como rates es un HashMap, puedes buscar cualquier divisa devuelta por su código ISO 4217.

Construyendo un cliente reutilizable

El error más común con reqwest es crear un nuevo Client para cada petición. Cada cliente posee su propio pool de conexiones, así que recrearlo desecha las conexiones keep-alive y los handshakes TLS. Crea un solo Client, clónalo de forma barata (internamente es un Arc) y reutilízalo en todas partes. Envuélvelo en una pequeña struct que oculte la URL y la clave:

use reqwest::Client;

#[derive(Clone)]
pub struct FinexlyClient {
    http: Client,
    api_key: String,
    base_url: String,
}

impl FinexlyClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("failed to build HTTP client"),
            api_key: api_key.into(),
            base_url: "https://api.finexly.com/v1".to_string(),
        }
    }

    pub async fn latest(
        &self,
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/latest", self.base_url);
        let symbols_csv = symbols.join(",");

        let resp = self
            .http
            .get(&url)
            .query(&[
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?;

        let resp = resp.error_for_status()?;
        let data = resp.json::<RatesResponse>().await?;
        Ok(data)
    }
}

Vale la pena señalar algunas cosas. El constructor .query(&[...]) se encarga de la codificación de la URL por ti, así que nunca concatenas manualmente cadenas de consulta. El .timeout(...) del builder protege contra que una conexión colgada bloquee tu servicio. Y error_for_status() convierte cualquier respuesta HTTP distinta de 2xx en un Err —lo que nos lleva al manejo de errores.

Manejo robusto de errores

El código de producción tiene que responder: ¿qué ocurre cuando la red está caída, la clave no es válida (403) o alcanzas el límite de tasa (429)? Modela esos casos con un enum de error personalizado usando thiserror:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum FinexlyError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("API returned an unsuccessful response")]
    Unsuccessful,

    #[error("currency '{0}' was not present in the response")]
    MissingCurrency(String),
}

La línea #[from] reqwest::Error significa que cualquier fallo de reqwest —DNS, TLS, timeout o un estado distinto de 2xx capturado por error_for_status()— se convierte automáticamente en FinexlyError::Http mediante el operador ?. Por eso cada .await? de latest() simplemente funciona.

También puedes inspeccionar el código de estado explícitamente cuando quieras un comportamiento a medida —por ejemplo, aplicar backoff ante un 429:

if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
    // sleep and retry, or fall back to a cached rate
}

Para un tratamiento más profundo de reintentos, backoff y estrategia de caché, consulta nuestra guía de mejores prácticas de caché y manejo de errores para APIs de divisas.

Convirtiendo entre cualquier par de divisas

Multiplicar por un único tipo solo funciona cuando tu divisa de origen es la base. Las aplicaciones reales necesitan pares arbitrarios —EUR → JPY, GBP → CAD— donde ningún lado es la base. El truco es enrutar a través de la divisa base: divide el importe por el tipo de origen para obtener el equivalente en la base, luego multiplica por el tipo de destino.

impl FinexlyClient {
    /// Convert `amount` from `from` to `to`, using `from` as the request base.
    pub async fn convert(
        &self,
        amount: f64,
        from: &str,
        to: &str,
    ) -> Result<f64, FinexlyError> {
        let data = self.latest(from, &[to]).await?;

        if !data.success {
            return Err(FinexlyError::Unsuccessful);
        }

        let rate = data
            .rates
            .get(to)
            .ok_or_else(|| FinexlyError::MissingCurrency(to.to_string()))?;

        Ok(amount * rate)
    }
}

Aquí simplemente solicitamos los tipos con from como base, así que el tipo devuelto ya es la conversión directa from → to. Si almacenas en caché una divisa base (digamos USD) y necesitas tipos cruzados, calcúlalos en el lado del cliente en su lugar: amount / rates[from] * rates[to]. Pruébalo de principio a fin:

#[tokio::main]
async fn main() -> Result<(), FinexlyError> {
    let client = FinexlyClient::new(
        std::env::var("FINEXLY_API_KEY").expect("set FINEXLY_API_KEY"),
    );

    let usd = client.convert(250.0, "EUR", "USD").await?;
    println!("250 EUR = {usd:.2} USD");

    Ok(())
}

Usando rust_decimal para el dinero

f64 está bien para conversiones de visualización, pero es el tipo equivocado para almacenar saldos, facturar o cualquier cosa que tenga que cuadrar al céntimo. La coma flotante binaria no puede representar 0.1 exactamente, y esos errores se acumulan. Para el dinero, usa decimales de punto fijo:

use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal_macros::dec;

pub fn convert_decimal(amount: Decimal, rate: f64) -> Decimal {
    let rate = Decimal::from_f64(rate).unwrap_or_default();
    (amount * rate).round_dp(2)
}

// usage
let total = convert_decimal(dec!(1999.99), 0.9213);
println!("{total}"); // rounded to 2 decimal places

Una advertencia importante: no todas las divisas tienen dos unidades menores. El JPY y el KRW tienen cero decimales; algunas divisas como el BHD y el KWD tienen tres. Redondea al número correcto de decimales por divisa (guiado por la tabla de unidades menores de la ISO 4217) en lugar de fijar 2 de forma rígida. Nuestro conversor de divisas y la documentación de la API reflejan ambos estas reglas por divisa.

Cacheando para mantenerte dentro del plan gratuito

Los tipos de cambio no se mueven de forma significativa segundo a segundo. Para la mayoría de los casos de uso de visualización, checkout y reporting, refrescar una vez por hora es más que suficiente —y la caché te mantiene cómodamente dentro de un plan gratuito sin importar cuánto tráfico recibas. Añade una sencilla caché TTL en memoria protegida por un Mutex:

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

struct CacheEntry {
    data: RatesResponse,
    fetched_at: Instant,
}

pub struct CachedClient {
    inner: FinexlyClient,
    ttl: Duration,
    cache: Mutex<HashMap<String, CacheEntry>>,
}

impl CachedClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            inner: FinexlyClient::new(api_key),
            ttl: Duration::from_secs(3600), // 1 hour
            cache: Mutex::new(HashMap::new()),
        }
    }

    pub async fn latest(&self, base: &str) -> Result<RatesResponse, FinexlyError> {
        {
            let cache = self.cache.lock().unwrap();
            if let Some(entry) = cache.get(base) {
                if entry.fetched_at.elapsed() < self.ttl {
                    return Ok(entry.data.clone());
                }
            }
        }

        let fresh = self.inner.latest(base, &[]).await?;
        let mut cache = self.cache.lock().unwrap();
        cache.insert(
            base.to_string(),
            CacheEntry { data: fresh.clone(), fetched_at: Instant::now() },
        );
        Ok(fresh)
    }
}

Para que esto compile querrás #[derive(Clone)] en RatesResponse. Con un TTL de una hora, una única divisa base te cuesta como mucho 24 peticiones al día independientemente de cuántas conversiones realicen tus usuarios —un error de redondeo frente a un plan gratuito de 1.000 peticiones. Si te quedas corto, los planes de precios escalan de forma lineal. Para patrones como stale-while-revalidate y cachés compartidas entre instancias, consulta nuestra guía de caché y manejo de errores.

Obteniendo tipos históricos

Las facturas con fecha retroactiva, los informes y los gráficos necesitan datos históricos. Finexly expone un endpoint histórico que puedes llamar desde el mismo cliente añadiendo un método que apunte a /v1/historical:

impl FinexlyClient {
    pub async fn historical(
        &self,
        date: &str, // "YYYY-MM-DD"
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/historical", self.base_url);
        let symbols_csv = symbols.join(",");

        let data = self
            .http
            .get(&url)
            .query(&[
                ("date", date),
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?
            .error_for_status()?
            .json::<RatesResponse>()
            .await?;

        Ok(data)
    }
}

La forma de la respuesta es idéntica a la del endpoint de últimos tipos, así que todo tu código de análisis y conversión existente simplemente funciona. Consulta la documentación de la API para la lista completa de parámetros y el endpoint /v1/timeseries para rangos de fechas.

Errores comunes que evitar

  • Crear un Client por petición. Construye uno, clónalo (es barato) y reutilízalo para mantener el pool de conexiones caliente.
  • Usar f64 para saldos almacenados. Está bien para visualización, mal para contabilidad —usa rust_decimal para cualquier cosa que deba cuadrar.
  • Fijar de forma rígida dos decimales. El JPY y el KRW tienen cero; unas pocas divisas tienen tres. Redondea por divisa usando las unidades menores de la ISO 4217.
  • Saltarte la comprobación de estado. Deserializar un cuerpo de 403 o 429 como datos de tipos produce errores confusos. error_for_status() maneja esto en una línea.
  • Consultar en cada conversión. Cachea con un TTL y aplica debounce a la entrada del usuario para no consumir tu cuota.
  • Subir tu clave de API al repositorio. Léela desde el entorno o un gestor de secretos, nunca desde un literal de cadena en main.rs.

Si todavía estás comparando proveedores, nuestra comparación de APIs de divisas desglosa la exactitud, la cobertura de divisas y los límites del plan gratuito, y la guía de APIs de divisas gratuitas cubre las opciones sin coste con más profundidad.

Preguntas frecuentes

¿Cuál es el mejor cliente HTTP para llamar a una API de divisas en Rust?

reqwest es la opción estándar para Rust asíncrono. Está construido sobre hyper y tokio, gestiona el pooling de conexiones, TLS y JSON de fábrica, y se combina de forma natural con serde para respuestas tipadas. Habilita la característica json y reutiliza una única instancia de Client en toda tu aplicación.

¿Cómo analizo una respuesta JSON de tipos de cambio en Rust?

Define una struct que refleje la respuesta y deriva serde::Deserialize en ella. Modela el campo rates como un HashMap<String, f64> ya que es un mapa plano de código de divisa a tipo, luego llama a .json::<RatesResponse>() en la respuesta y reqwest lo deserializa por ti.

¿Debería usar f64 o Decimal para la conversión de divisas en Rust?

Usa f64 para conversiones rápidas de visualización, pero usa rust_decimal::Decimal para cualquier cosa que almacenes o cuadres —saldos, facturas, contabilidad. La coma flotante binaria no puede representar fracciones decimales exactamente, así que los errores de redondeo se acumulan. rust_decimal te da precisión de punto fijo.

¿Con qué frecuencia debería obtener los tipos de cambio en un servicio de Rust?

Para la mayoría de los casos de uso de checkout, visualización y reporting, una vez por hora es más que suficiente —los tipos no se mueven lo bastante en una hora como para importar. Cachea la respuesta con un TTL de una hora, lo que te mantiene en torno a 24 llamadas por divisa base al día y cómodamente dentro de un plan gratuito.

¿Puedo obtener tipos de cambio históricos en Rust?

Sí. Añade un método que llame al endpoint /v1/historical?date=YYYY-MM-DD en el mismo cliente. La forma del JSON coincide con la del endpoint de últimos tipos, así que tus structs y tu lógica de conversión existentes funcionan sin cambios. Esto es útil para facturas con fecha retroactiva, informes y gráficos.

Empieza a construir

¿Listo para integrar tipos de cambio en tiempo real en tu proyecto de Rust? Consigue tu clave gratuita de la API de Finexly —sin tarjeta de crédito. Empieza con 1.000 peticiones gratuitas al mes y mejora tu plan a medida que creces. Consulta la documentación de la API para la referencia completa de endpoints y explora los planes de precios cuando estés listo para escalar tus funcionalidades multidivisa.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →