Назад к блогу

API курсов валют на Rust — полное руководство по интеграции

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

Если вы разрабатываете финтех-инфраструктуру, движок ценообразования, инструмент командной строки или высоконагруженный бэкенд, рано или поздно вам понадобятся актуальные данные о валютном рынке. Подключение API курсов валют на Rust — одна из самых элегантных сетевых задач, которые предлагает язык: с reqwest, tokio и serde вы получаете асинхронный HTTP-клиент, автоматическую десериализацию JSON и гарантии на этапе компиляции того, что ваш код обработки курсов корректен ещё до запуска. Это руководство проходит весь путь — от первого GET-запроса до конвертера валют производственного уровня с переиспользуемым клиентом, типизированными моделями, собственной обработкой ошибок, безопасной для денег арифметикой с десятичными числами и кэшем в памяти, который позволяет оставаться в рамках бесплатного тарифа.

К концу у вас будет небольшой переиспользуемый модуль на Rust, построенный на основе документации Finexly API, который конвертирует между любыми из 170+ валют по курсам в реальном времени. Если вы уже читали наше руководство по API валют на Go, это его аналог на Rust с той же архитектурой — типизированный клиент, кэш и чистое распространение ошибок.

Почему Rust отлично подходит для работы с валютными данными

Валютные данные привязаны к вводу-выводу, чувствительны к задержкам и — в любой системе, работающей с деньгами, — критичны к корректности. Rust необычайно хорошо подходит для всех трёх аспектов:

  • Асинхронность по умолчанию. reqwest построен на hyper и tokio, поэтому параллельные запросы курсов дёшевы и не блокируют выполнение. Вы можете запрашивать десятки валютных пар параллельно, не порождая потоков ОС.
  • Система типов ловит ошибки на раннем этапе. Структура, выведенная через serde, означает, что некорректный ответ или отсутствующее поле станут ошибкой на этапе компиляции или десериализации, а не загадочным null тремя уровнями глубже в продакшене.
  • Нет сборщика мусора, предсказуемые задержки. Для сервиса, котирующего курсы валют под нагрузкой, отсутствие пауз сборщика мусора имеет значение.
  • Безопасные для денег десятичные числа. С крейтом rust_decimal вы получаете арифметику с фиксированной точкой, поэтому никогда не выпустите ошибку округления, превращающую €100.00 в €99.999999.

Компромисс — чуть более сложная настройка, чем в скриптовом языке, но результат — валютный клиент, который можно встроить в сервис и которому можно доверять.

Что вы создадите

Переиспользуемый FinexlyClient, который:

  1. Получает актуальные курсы для базовой валюты из API курсов валют.
  2. Десериализует JSON в типизированные структуры Rust с помощью serde.
  3. Конвертирует между любыми двумя валютами, даже когда ни одна из них не является базовой (кросс-курсы).
  4. Обрабатывает ошибки — сетевые сбои, ответы не 200, ограничения по частоте запросов — с помощью собственного типа ошибки.
  5. Кэширует ответы в памяти с TTL, чтобы вы уверенно оставались в рамках бесплатного тарифа.

Предварительные требования и настройка проекта

Вам нужны свежий стабильный тулчейн Rust (установите через rustup) и бесплатный ключ Finexly API. Вы можете зарегистрироваться бесплатно менее чем за минуту — без кредитной карты, 1000 запросов в месяц на бесплатном тарифе.

Создайте новый проект и добавьте зависимости:

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

Раздел зависимостей в вашем Cargo.toml теперь должен выглядеть так:

[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"

Небольшое замечание о крейтах: reqwest — это де-факто стандартный асинхронный HTTP-клиент для Rust; функция json подтягивает serde_json и включает вспомогательный метод .json(). serde с derive даёт вам #[derive(Deserialize)]. thiserror упрощает создание удобных перечислений ошибок. rust_decimal предоставляет десятичные числа с фиксированной точкой для денег.

Храните ключ в переменной окружения, чтобы он никогда не попадал в систему контроля версий:

export FINEXLY_API_KEY="your_api_key_here"

Формат ответа Finexly API

Прежде чем писать код, посмотрите, что возвращает эндпоинт. Запрос к эндпоинту актуальных курсов:

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

возвращает небольшой предсказуемый JSON-объект:

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

Две вещи определяют код ниже. Во-первых, rates — это плоское отображение кода валюты ISO 4217 на число с плавающей запятой — это чисто ложится на Rust-тип HashMap<String, f64>. Во-вторых, base сообщает вам, относительно чего эти курсы. Чтобы конвертировать USD → EUR, вы умножаете на rates["EUR"]. Чтобы конвертировать между двумя не базовыми валютами, вы идёте через базу: делите на одну, умножаете на другую.

Ваш первый запрос с помощью reqwest

Начните с максимально простого асинхронного запроса. Замените src/main.rs на:

#[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(())
}

Запустите его командой cargo run, и вы увидите распечатанный сырой JSON. Макрос #[tokio::main] настраивает асинхронную среду выполнения, reqwest::get выполняет запрос, а каждый .await? приостанавливает задачу до ответа сети, распространяя любую ошибку. Это работает, но reqwest::get создаёт совершенно новый клиент при каждом вызове — нормально для разового использования, но неправильно для реального приложения. Мы это скоро исправим.

Моделирование JSON с помощью Serde

Печать строки бесполезна; вам нужны типизированные данные. Определите структуры, отражающие ответ, и позвольте serde выполнить разбор:

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>,
}

Теперь замените .text() на .json(), который десериализует тело напрямую в вашу структуру:

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

Метод json() читает тело ответа и запускает serde_json под капотом. Если поле отсутствует или имеет неверный тип, вы получите понятную ошибку десериализации вместо молчаливого null. Поскольку rates — это HashMap, вы можете найти любую возвращённую валюту по её коду ISO 4217.

Создание переиспользуемого клиента

Самая распространённая ошибка с reqwest — создание нового Client для каждого запроса. Каждый клиент владеет собственным пулом соединений, поэтому его пересоздание выбрасывает keep-alive-соединения и TLS-рукопожатия. Создайте один Client, дёшево клонируйте его (внутри это Arc) и переиспользуйте везде. Оберните его в небольшую структуру, скрывающую URL и ключ:

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)
    }
}

Несколько моментов, которые стоит отметить. Билдер .query(&[...]) сам обрабатывает URL-кодирование, поэтому вам никогда не придётся вручную склеивать строки запроса. .timeout(...) в билдере защищает от того, чтобы зависшее соединение застопорило ваш сервис. А error_for_status() превращает любой HTTP-ответ не из диапазона 2xx в Err — что подводит нас к обработке ошибок.

Надёжная обработка ошибок

Продакшен-код должен отвечать на вопрос: что происходит, когда сеть недоступна, ключ недействителен (403) или вы достигли лимита запросов (429)? Смоделируйте эти случаи с помощью собственного перечисления ошибок, используя 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),
}

Строка #[from] reqwest::Error означает, что любой сбой reqwest — DNS, TLS, тайм-аут или статус не из 2xx, пойманный error_for_status() — автоматически преобразуется в FinexlyError::Http через оператор ?. Именно поэтому каждый .await? в latest() просто работает.

Вы также можете явно проверять код статуса, когда хотите специфического поведения — например, отступая при 429:

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

Для более глубокого разбора повторных попыток, отступов и стратегии кэширования смотрите наше руководство по лучшим практикам кэширования и обработки ошибок для API валют.

Конвертация между любыми двумя валютами

Умножение на один курс работает только тогда, когда ваша исходная валюта является базовой. Реальным приложениям нужны произвольные пары — EUR → JPY, GBP → CAD — где ни одна из сторон не базовая. Хитрость в том, чтобы проходить через базовую валюту: разделить сумму на курс исходной валюты, чтобы получить эквивалент в базе, затем умножить на курс целевой валюты.

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)
    }
}

Здесь мы просто запрашиваем курсы с from в качестве базы, поэтому возвращённый курс уже является прямой конвертацией from → to. Если вы кэшируете одну базовую валюту (скажем, USD) и вам нужны кросс-курсы, вычисляйте их на стороне клиента: amount / rates[from] * rates[to]. Попробуйте от начала до конца:

#[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(())
}

Использование rust_decimal для денег

f64 подходит для отображаемых конвертаций, но это неправильный тип для хранения балансов, выставления счетов или чего-либо, что должно сходиться до копейки. Двоичная плавающая запятая не может точно представить 0.1, и эти ошибки накапливаются. Для денег используйте десятичные числа с фиксированной точкой:

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

Одна важная оговорка: не у каждой валюты есть две младшие единицы. У JPY и KRW ноль знаков после запятой; некоторые валюты, такие как BHD и KWD, имеют три. Округляйте до правильного числа знаков для каждой валюты (согласно таблице младших единиц ISO 4217), а не жёстко кодируйте 2. Наш конвертер валют и документация API отражают эти правила для каждой валюты.

Кэширование для сохранения бесплатного тарифа

Курсы валют не меняются существенно от секунды к секунде. Для большинства сценариев отображения, оформления заказа и отчётности обновления раз в час более чем достаточно — и кэширование позволяет комфортно оставаться в рамках бесплатного тарифа независимо от того, сколько трафика вы получаете. Добавьте простой TTL-кэш в памяти, защищённый 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)
    }
}

Чтобы это скомпилировалось, вам понадобится #[derive(Clone)] на RatesResponse. С часовым TTL одна базовая валюта обходится вам максимум в 24 запроса в день независимо от того, сколько конвертаций выполняют ваши пользователи — погрешность округления против бесплатного тарифа в 1000 запросов. Если вы его перерастёте, тарифные планы масштабируются линейно. О таких паттернах, как stale-while-revalidate и общие кэши между экземплярами, читайте в нашем руководстве по кэшированию и обработке ошибок.

Получение исторических курсов

Счета задним числом, отчётность и графики требуют исторических данных. Finexly предоставляет исторический эндпоинт, который вы можете вызвать из того же клиента, добавив метод, обращающийся к /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)
    }
}

Формат ответа идентичен эндпоинту актуальных курсов, поэтому весь ваш существующий код разбора и конвертации просто работает. Смотрите документацию API для полного списка параметров и эндпоинт /v1/timeseries для диапазонов дат.

Типичные ошибки, которых стоит избегать

  • Создание Client для каждого запроса. Создайте один, клонируйте его (это дёшево) и переиспользуйте, чтобы пул соединений оставался горячим.
  • Использование f64 для хранимых балансов. Подходит для отображения, неправильно для реестров — используйте rust_decimal для всего, что должно сходиться.
  • Жёсткое кодирование двух знаков после запятой. У JPY и KRW ноль; у некоторых валют три. Округляйте для каждой валюты, используя младшие единицы ISO 4217.
  • Пропуск проверки статуса. Десериализация тела 403 или 429 как данных о курсах порождает запутанные ошибки. error_for_status() решает это в одну строку.
  • Запрос при каждой конвертации. Кэшируйте с TTL и делайте дебаунс пользовательского ввода, чтобы не сжигать квоту.
  • Коммит вашего API-ключа. Читайте его из окружения или менеджера секретов, никогда не как строковый литерал в main.rs.

Если вы всё ещё сравниваете провайдеров, наше сравнение API валют разбирает точность, охват валют и лимиты бесплатных тарифов, а руководство по бесплатным API валют подробнее рассматривает бесплатные варианты.

Часто задаваемые вопросы

Какой HTTP-клиент лучше всего подходит для вызова API валют на Rust?

reqwest — стандартный выбор для асинхронного Rust. Он построен на hyper и tokio, обрабатывает пулинг соединений, TLS и JSON из коробки и естественно сочетается с serde для типизированных ответов. Включите функцию json и переиспользуйте единственный экземпляр Client во всём приложении.

Как разобрать JSON-ответ с курсами валют на Rust?

Определите структуру, отражающую ответ, и выведите на ней serde::Deserialize. Смоделируйте поле rates как HashMap<String, f64>, поскольку это плоское отображение кода валюты на курс, затем вызовите .json::<RatesResponse>() на ответе, и reqwest десериализует его за вас.

Что использовать для конвертации валют на Rust — f64 или Decimal?

Используйте f64 для быстрых отображаемых конвертаций, но используйте rust_decimal::Decimal для всего, что вы храните или сверяете — балансов, счетов, реестров. Двоичная плавающая запятая не может точно представить десятичные дроби, поэтому ошибки округления накапливаются. rust_decimal даёт вам точность с фиксированной точкой.

Как часто нужно запрашивать курсы валют в сервисе на Rust?

Для большинства сценариев оформления заказа, отображения и отчётности раз в час более чем достаточно — курсы не меняются в течение часа настолько, чтобы это имело значение. Кэшируйте ответ с часовым TTL, что удерживает вас в пределах примерно 24 вызовов на базовую валюту в день и комфортно внутри бесплатного тарифа.

Можно ли получить исторические курсы валют на Rust?

Да. Добавьте метод, вызывающий эндпоинт /v1/historical?date=YYYY-MM-DD на том же клиенте. Формат JSON совпадает с эндпоинтом актуальных курсов, поэтому ваши существующие структуры и логика конвертации работают без изменений. Это полезно для счетов задним числом, отчётности и графиков.

Начните разработку

Готовы интегрировать курсы валют в реальном времени в свой Rust-проект? Получите бесплатный ключ Finexly API — без кредитной карты. Начните с 1000 бесплатных запросов в месяц и повышайте тариф по мере роста. Проверьте документацию API для полного справочника по эндпоинтам и изучите тарифные планы, когда будете готовы масштабировать свои мультивалютные функции.

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 →