Если вам нужен API курсов валют на Go, стандартной библиотеки почти достаточно. Пакетов net/http, encoding/json и context хватает, чтобы собрать быстрый клиент без внешних зависимостей. Сложен не сам HTTP-запрос, а всё, что вокруг него: таймауты, обработка ошибок, кэширование, арифметика денег и тесты. Большинство руководств заканчиваются на http.Get и fmt.Println: для демо этого хватает, но в платёжном потоке это рискованно.
В этом руководстве вы построите небольшой, но рассчитанный на продакшен клиент на Golang для эндпоинтов из документации API Finexly, обернёте его в потокобезопасный кэш, переживающий сбои поставщика, добавите функцию конвертации, работающую в целых минимальных единицах, и запустите всё как HTTP-сервис с тестами. Код компилируется в Go 1.22 и новее и использует только стандартную библиотеку.
Почему Go хорошо подходит для данных о курсах
Курсы валют читают очень часто, они занимают мало места и медленно меняются в масштабе веб-запроса. Такой профиль отлично подходит Go. Горутины позволяют дёшево обслуживать тысячи параллельных запросов из одного снимка в памяти, единый статический бинарник легко развернуть, а context.Context даёт полноценную отмену и дедлайны для каждого исходящего вызова.
Цель проектирования проста: ваши обработчики запросов никогда не должны напрямую обращаться к API курсов. Они читают из кэша, а единственный безопасный путь обновления общается с API. Задержка остаётся стабильной, квота защищена, а сбой поставщика не превращается в ваш сбой. Теорию вы найдёте в нашем руководстве по кэшированию и обработке ошибок в валютных API; здесь мы реализуем её на практике.
Шаг 1. Сначала проверьте через cURL
Прежде чем писать на Go, убедитесь, что ключ работает. Зарегистрируйтесь бесплатно, скопируйте ключ и выполните:
curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=$FINEXLY_API_KEY"{
"base": "USD",
"timestamp": 1762761600,
"rates": { "EUR": 0.92, "GBP": 0.80, "JPY": 151.2 }
}The numbers above are illustrative.
В ответе три поля: base (валюта, относительно которой заданы все курсы), timestamp (секунды Unix) и rates (соответствие кода ISO 4217 курсу). Каждое значение в rates показывает, сколько единиц этой валюты равно одной единице базовой. Храните ключ в переменной окружения, но не в системе контроля версий:
export FINEXLY_API_KEY="your_key_here"Шаг 2. Типизированный клиент с таймаутами и безопасными ошибками
Создайте модуль командой go mod init fxdemo и поместите следующий код в main.go. Он определяет структуру ответа, собственный APIError и Client, чей метод Latest принимает context.Context:
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
type RatesResponse struct {
Base string `json:"base"`
Timestamp int64 `json:"timestamp"`
Rates map[string]float64 `json:"rates"`
}
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("finexly: HTTP %d: %s", e.Status, e.Body)
}
type Client struct {
apiKey string
baseURL string
http *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
baseURL: "https://api.finexly.com/v1",
http: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *Client) Latest(ctx context.Context, base string, symbols []string) (*RatesResponse, error) {
q := url.Values{}
q.Set("base", base)
if len(symbols) > 0 {
q.Set("symbols", strings.Join(symbols, ","))
}
q.Set("apikey", c.apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/latest?"+q.Encode(), nil)
if err != nil {
return nil, errors.New("finexly: build request failed")
}
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err // drop the URL, it contains the API key
}
return nil, fmt.Errorf("finexly: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, &APIError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
var out RatesResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("finexly: decode response: %w", err)
}
return &out, nil
}Несколько деталей отличают демо от продакшен-кода:
- Жёсткий таймаут. У
http.Clientс нулевыми значениями таймаута нет, поэтому зависшее соединение может держать горутину бесконечно. Мы задаём5 * time.Secondи передаём контекст вызывающего, так что отменённый пользователем запрос отменяет и вызов к внешнему API. - Ключ не попадает в логи. Так как ключ передаётся в параметре
apikey,*url.Errorв Go включает в сообщение полный URL вместе с ключом. Мы распаковываем его черезerrors.Asи оставляем только вложенную ошибку. Теперь такую ошибку безопасно логировать. - Типизированный
APIError. Вызывающий код может черезerrors.Asветвиться поStatus: например, замедляться при429или поднимать тревогу при403. Тело читается черезio.LimitReader, чтобы неисправный сервер не заставил вас буферизовать мегабайты. - Настраиваемый
baseURL. Это ничего не стоит и делает клиент тривиально тестируемым, как вы увидите на шаге 6.
Шаг 3. Потокобезопасный кэш с запасным вариантом из устаревших данных
Обращаться к API при каждом запросе значит быстро расходовать квоту и добавлять задержку. В большинстве приложений курсы заметно не меняются в течение нескольких минут, поэтому их кэшируют. У приведённого ниже кэша три свойства, которые упускают простые примеры на map и Mutex:
- Читатели никогда не блокируются обновлением. Сетевой вызов выполняется вне блокировки чтения/записи.
- Одновременно обновляет только одна горутина. Второй мьютекс и повторная проверка после его захвата предотвращают лавину запросов, когда TTL истекает под нагрузкой.
- Устаревшие данные лучше, чем никаких. Если обновление не удалось, а старый снимок есть, мы возвращаем его с
stale=true, и вызывающий код решает, как поступить.
type Cache struct {
client *Client
base string
ttl time.Duration
mu sync.RWMutex
data *RatesResponse
fetchedAt time.Time
refreshMu sync.Mutex
}
func NewCache(c *Client, base string, ttl time.Duration) *Cache {
return &Cache{client: c, base: base, ttl: ttl}
}
func (c *Cache) fresh() (*RatesResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.data != nil && time.Since(c.fetchedAt) < c.ttl {
return c.data, true
}
return nil, false
}
// Get returns rates, and stale=true if it had to fall back to old data.
func (c *Cache) Get(ctx context.Context) (rates *RatesResponse, stale bool, err error) {
if d, ok := c.fresh(); ok {
return d, false, nil
}
c.refreshMu.Lock() // only one goroutine hits the API
defer c.refreshMu.Unlock()
if d, ok := c.fresh(); ok { // refreshed while we waited
return d, false, nil
}
d, err := c.client.Latest(ctx, c.base, nil)
if err != nil {
c.mu.RLock()
old := c.data
c.mu.RUnlock()
if old != nil {
return old, true, nil
}
return nil, false, err
}
c.mu.Lock()
c.data, c.fetchedAt = d, time.Now()
c.mu.Unlock()
return d, false, nil
}Обратите внимание: мы запрашиваем все курсы для базовой валюты одним запросом (symbols опущен). Один вызов в час обслуживает любую пару валют, которая нужна сервису, то есть около 720 вызовов в месяц для постоянно работающего экземпляра. Это укладывается в бесплатный тариф на 1000 запросов. TTL в десять минут дал бы около 4300 вызовов в месяц, поэтому более короткие интервалы требуют платного тарифа; см. тарифные планы.
Шаг 4. Конвертация денег без сюрпризов с плавающей точкой
Никогда не храните деньги в float64. Храните целые минимальные единицы (центы, пенсы, иены) и применяйте курс один раз, в конце, с явным округлением:
// Convert works in minor units (cents). It assumes both currencies use the
// same number of decimal places; see the ISO 4217 note in the article.
func Convert(amountMinor int64, from, to string, r *RatesResponse) (int64, error) {
rate := func(code string) (float64, error) {
if code == r.Base {
return 1, nil
}
v, ok := r.Rates[code]
if !ok || v <= 0 {
return 0, fmt.Errorf("no rate for %q", code)
}
return v, nil
}
f, err := rate(from)
if err != nil {
return 0, err
}
t, err := rate(to)
if err != nil {
return 0, err
}
return int64(math.Round(float64(amountMinor) * t / f)), nil
}Поскольку курсы котируются относительно базовой валюты, конвертация EUR в GBP проходит через неё: делим на курс исходной валюты и умножаем на курс целевой. Функция также считает саму базовую валюту курсом, ровно равным 1.
Есть реальная ловушка. Приведённая функция предполагает, что у обеих валют одинаковое число десятичных знаков. У JPY их 0, у USD 2, у KWD 3, поэтому в продакшене нужно масштабировать по показателю степени каждой валюты. В нашем руководстве по кодам валют ISO 4217 указан показатель минимальной единицы для каждого кода. Для быстрой интерактивной проверки используйте конвертер валют.
Шаг 5. Отдайте сервис по HTTP
Теперь подключим всё к HTTP-обработчику. Обработчик разбирает запрос, читает из кэша с контекстом запроса и сообщает, были ли курсы устаревшими:
func convertHandler(cache *Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
amount, err := strconv.ParseInt(q.Get("amount"), 10, 64)
if err != nil {
http.Error(w, "amount must be an integer in minor units", http.StatusBadRequest)
return
}
from, to := strings.ToUpper(q.Get("from")), strings.ToUpper(q.Get("to"))
rates, stale, err := cache.Get(r.Context())
if err != nil {
log.Printf("rates unavailable: %v", err)
http.Error(w, "rates unavailable", http.StatusBadGateway)
return
}
out, err := Convert(amount, from, to, rates)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"from": from, "to": to,
"amount_minor": amount, "converted_minor": out,
"rates_timestamp": rates.Timestamp, "stale": stale,
})
}
}
func main() {
apiKey := os.Getenv("FINEXLY_API_KEY")
if apiKey == "" {
log.Fatal("FINEXLY_API_KEY is not set")
}
cache := NewCache(NewClient(apiKey), "USD", time.Hour)
http.Handle("/convert", convertHandler(cache))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}Запустите и попробуйте конвертировать 100,00 USD в EUR (10 000 центов):
go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"Ответ содержит rates_timestamp и флаг stale, поэтому нижестоящие системы могут показывать «курсы на...» или отказываться от котировки, если данные слишком старые. Именно это поле аудиторы и финансовые команды обычно просят в первую очередь.
Шаг 6. Тесты без обращения к сети
Поскольку базовый URL можно подменить, вы можете проверить всю логику клиента и кэша через httptest.NewServer. Этот тест доказывает самое важное поведение: когда API начинает отказывать, кэш отдаёт устаревшие данные вместо ошибки.
func TestLatestAndStaleFallback(t *testing.T) {
healthy := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
return
}
w.Write([]byte(`{"base":"USD","timestamp":1,"rates":{"EUR":0.92}}`))
}))
defer srv.Close()
c := NewClient("test-key")
c.baseURL = srv.URL
cache := NewCache(c, "USD", time.Millisecond)
if _, stale, err := cache.Get(context.Background()); err != nil || stale {
t.Fatalf("first call: err=%v stale=%v", err, stale)
}
healthy = false
time.Sleep(5 * time.Millisecond)
r, stale, err := cache.Get(context.Background())
if err != nil || !stale || r.Rates["EUR"] != 0.92 {
t.Fatalf("expected stale fallback, got err=%v stale=%v", err, stale)
}
}
func TestConvert(t *testing.T) {
r := &RatesResponse{Base: "USD", Rates: map[string]float64{"EUR": 0.92, "GBP": 0.80}}
got, err := Convert(10000, "EUR", "GBP", r)
if err != nil || got != 8696 {
t.Fatalf("got %d err %v", got, err)
}
}Запустите командой go test ./.... Такие тесты выполняются за миллисекунды, не требуют API-ключа и подходят для CI. В том же стиле можно добавить случаи для ответов 429, некорректного JSON и медленных серверов (для проверки таймаута).
Чек-лист перед продакшеном
Перед релизом пройдитесь по этим пунктам:
- Выберите разумный TTL. Час подходит для выставления счетов и отображаемых цен в интернет-магазине и позволяет одному экземпляру уложиться в бесплатный тариф. Функциям котирования или трейдинга могут понадобиться минуты или секунды. Подбирайте TTL по бизнес-риску, а затем выбирайте тариф.
- Показывайте метку времени курса везде, где клиент видит пересчитанную сумму, и сохраняйте использованный курс в каждой транзакции, чтобы её можно было воспроизвести.
- Настройте оповещения об устаревании. Отправляйте метрику, когда
stale=trueдержится дольше порога. Кэш, который молча отдаёт данные суточной давности, хуже, чем ошибка. - Прогревайте кэш при запуске и проваливайте проверки готовности, если первая загрузка не удалась, чтобы неверный ключ обнаруживался при деплое.
- Обрабатывайте
429с задержкой, а не повторяйте запрос сразу. Если это происходит часто, TTL слишком короткий или несколько экземпляров обновляются независимо. Общий кэш вроде Redis решает второй случай. - Время от времени сравнивайте поставщиков. Если вы выбираете источник данных, наша страница сравнения валютных API и обзор бесплатного валютного API подскажут, на что смотреть.
Если вы работаете на нескольких языках, те же шаблоны есть в нашем руководстве по Rust и руководстве по Python.
Часто задаваемые вопросы
Есть ли бесплатный API курсов валют для Go?
Да. У Finexly есть бесплатный тариф на 1000 запросов в месяц, а клиент выше не требует сторонних пакетов. С часовым кэшем постоянно работающий экземпляр использует около 720 запросов в месяц, так что запас остаётся. Помните, что каждый дополнительный экземпляр обновляется сам по себе. Актуальные лимиты смотрите в тарифных планах.
Нужно ли использовать float64 для курсов валют в Go?
Использовать float64 для самого курса обычно допустимо, ведь курсы являются приближениями с несколькими значащими цифрами. Для денежных сумм это недопустимо. Храните суммы в целых минимальных единицах или используйте десятичную библиотеку и округляйте один раз в конце.
Как часто обновлять курсы валют?
Зависит от вашей терпимости к риску. Для отображаемых цен и отчётов обычно хватает 15–60 минут. При выставлении счетов курс часто фиксируют на документ. Всё, что двигает деньги в реальном времени, должно сохранять точный использованный курс и метку времени независимо от интервала обновления.
Как не допустить утечки API-ключа в логи Go?
Когда ключ передаётся параметром запроса, ошибки http.Client содержат полный URL. Распакуйте *url.Error через errors.As и логируйте только поле Err, как делает клиент в этом руководстве. Также не записывайте полные URL запросов в middleware.
Можно ли получать несколько базовых валют параллельно?
Да. Используйте golang.org/x/sync/errgroup с SetLimit, чтобы ограничить параллелизм, и делитесь одним Client, который безопасен для конкурентного использования. В большинстве случаев одна базовая валюта плюс расчёт кросс-курсов из шага 4 проще и дешевле.
Что дальше
Готовы подключить курсы валют в реальном времени к своему сервису на Go? Получите бесплатный API-ключ Finexly: банковская карта не нужна. Начните с 1000 бесплатных запросов в месяц и переходите на старший тариф по мере роста.
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 →