Назад к блогу

API курсов валют на Go: готовое к продакшену руководство по Golang (2026)

V
Vlado Grigirov
November 10, 2026
Currency API Golang Tutorial Exchange Rates Finexly Backend

Если вам нужен 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:

  1. Читатели никогда не блокируются обновлением. Сетевой вызов выполняется вне блокировки чтения/записи.
  2. Одновременно обновляет только одна горутина. Второй мьютекс и повторная проверка после его захвата предотвращают лавину запросов, когда TTL истекает под нагрузкой.
  3. Устаревшие данные лучше, чем никаких. Если обновление не удалось, а старый снимок есть, мы возвращаем его с 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 бесплатных запросов в месяц и переходите на старший тариф по мере роста.

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 →