Если вы разрабатываете финтех-инфраструктуру, движок ценообразования, инструмент командной строки или высоконагруженный бэкенд, рано или поздно вам понадобятся актуальные данные о валютном рынке. Подключение 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, который:
- Получает актуальные курсы для базовой валюты из API курсов валют.
- Десериализует JSON в типизированные структуры Rust с помощью
serde. - Конвертирует между любыми двумя валютами, даже когда ни одна из них не является базовой (кросс-курсы).
- Обрабатывает ошибки — сетевые сбои, ответы не 200, ограничения по частоте запросов — с помощью собственного типа ошибки.
- Кэширует ответы в памяти с 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 для полного справочника по эндпоинтам и изучите тарифные планы, когда будете готовы масштабировать свои мультивалютные функции.
Explore More
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 →