Retour au blog

API de taux de change en Go : tutoriel Golang prêt pour la production (2026)

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

Si vous avez besoin d'une API de taux de change en Go, la bibliothèque standard suffit presque entièrement. Les paquets net/http, encoding/json et context permettent de construire un client rapide et sans dépendance. Le plus difficile n'est pas l'appel HTTP, mais tout ce qui l'entoure : timeouts, gestion des erreurs, cache, arithmétique monétaire et tests. La plupart des tutoriels s'arrêtent à http.Get et fmt.Println, ce qui convient à une démo mais devient risqué dans un flux de paiement.

Dans ce guide, vous allez construire un client Golang compact mais pensé pour la production, pour les endpoints décrits dans la documentation de l'API Finexly, l'envelopper dans un cache sûr en concurrence qui survit aux pannes du fournisseur, ajouter une fonction de conversion qui travaille en unités mineures entières, puis le livrer sous forme de service HTTP avec des tests. Tout compile avec Go 1.22 ou plus récent et n'utilise que la bibliothèque standard.

Pourquoi Go convient bien aux données de taux de change

Les taux de change sont lus très souvent, sont de petite taille et évoluent lentement à l'échelle d'une requête web. Ce profil convient très bien à Go. Les goroutines permettent de servir des milliers de requêtes concurrentes à partir d'un seul instantané en mémoire, un binaire statique unique se déploie facilement, et context.Context offre l'annulation et les échéances de premier ordre pour chaque appel sortant.

L'objectif de conception est simple : vos handlers ne doivent jamais appeler directement l'API de taux. Ils lisent dans un cache, et un unique chemin de rafraîchissement sûr dialogue avec l'API. La latence reste stable, votre quota est protégé, et un incident du fournisseur ne devient pas votre panne. Pour la théorie, notre guide sur le cache et la gestion des erreurs d'une API de devises détaille la stratégie ; ici, nous l'implémentons.

Étape 1 : testez d'abord avec cURL

Avant d'écrire du Go, vérifiez que votre clé fonctionne. Inscrivez-vous gratuitement, copiez votre clé et lancez :

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.

La réponse contient trois champs : base (la devise par rapport à laquelle sont exprimés tous les taux), timestamp (secondes Unix) et rates (une table associant code ISO 4217 et taux). Chaque valeur de rates indique combien d'unités de cette devise équivalent à une unité de la base. Stockez la clé dans une variable d'environnement, jamais dans le dépôt :

export FINEXLY_API_KEY="your_key_here"

Étape 2 : un client typé avec timeouts et erreurs sûres

Créez un module avec go mod init fxdemo et placez le code suivant dans main.go. Il définit la structure de réponse, un APIError dédié et un Client dont la méthode Latest accepte un 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
}

Plusieurs détails font la différence entre une démo et du code de production :

  • Un timeout strict. Le http.Client par défaut n'a aucun timeout : une connexion bloquée peut retenir une goroutine indéfiniment. Nous fixons 5 * time.Second et transmettons aussi le contexte de l'appelant, de sorte qu'une requête annulée par l'utilisateur annule l'appel amont.
  • Aucune fuite de clé dans les logs. La clé voyageant dans le paramètre apikey, le *url.Error de Go inclut l'URL complète, clé comprise, dans son message. Nous le déballons avec errors.As et ne gardons que l'erreur sous-jacente. Journaliser cette erreur devient sûr.
  • Un APIError typé. L'appelant peut utiliser errors.As pour réagir selon Status, par exemple temporiser sur un 429 ou alerter sur un 403. Le corps est lu via io.LimitReader afin qu'un serveur défaillant ne vous fasse pas stocker des mégaoctets.
  • Un baseURL configurable. Cela ne coûte rien et rend le client très simple à tester, comme vous le verrez à l'étape 6.

Étape 3 : un cache sûr en concurrence avec repli sur données périmées

Appeler l'API à chaque requête épuiserait votre quota et ajouterait de la latence. Pour la plupart des applications, les taux ne changent pas de façon significative en quelques minutes : mettez-les en cache. Le cache ci-dessous possède trois propriétés que les exemples simples map + Mutex négligent :

  1. Les lecteurs ne sont jamais bloqués par un rafraîchissement. L'appel réseau a lieu en dehors du verrou de lecture/écriture.
  2. Une seule goroutine rafraîchit à la fois. Un second mutex, avec une nouvelle vérification après son acquisition, évite l'effet de troupeau quand le TTL expire sous charge.
  3. Des données périmées valent mieux que pas de données. Si le rafraîchissement échoue et que nous détenons un instantané plus ancien, nous le renvoyons avec stale=true pour que l'appelant décide.
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
}

Notez que nous récupérons tous les taux de la base en une seule requête (symbols est omis). Un appel par heure sert alors toutes les paires de devises dont votre service a besoin, soit environ 720 appels par mois pour une instance toujours active. Cela tient dans le forfait gratuit de 1 000 requêtes. Un TTL de dix minutes représenterait environ 4 300 appels par mois : des intervalles plus courts exigent donc une offre payante ; consultez les forfaits tarifaires.

Étape 4 : convertir de l'argent sans surprise en virgule flottante

Ne stockez jamais d'argent dans un float64. Stockez des unités mineures entières (centimes, pence, yens) et appliquez le taux une seule fois, à la fin, avec un arrondi explicite :

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

Les taux étant cotés par rapport à une base, convertir des EUR en GBP passe par cette base : on divise par le taux source et on multiplie par le taux cible. La fonction traite aussi la base elle-même comme un taux exactement égal à 1.

Un piège bien réel existe. La fonction ci-dessus suppose que les deux devises ont le même nombre de décimales. Le JPY en a 0, l'USD 2 et le KWD 3 : un système de production doit donc mettre à l'échelle selon l'exposant de chaque devise. Notre guide des codes de devise ISO 4217 donne l'exposant de l'unité mineure pour chaque code. Pour une vérification interactive rapide, utilisez le convertisseur de devises.

Étape 5 : l'exposer en HTTP

Raccordons maintenant l'ensemble à un handler HTTP. Il analyse la requête, lit le cache avec le contexte de la requête et indique si les taux étaient périmés :

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

Lancez-le et essayez de convertir 100,00 USD en EUR (10 000 centimes) :

go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"

La réponse inclut rates_timestamp et un indicateur stale, ce qui permet aux systèmes en aval d'afficher « taux au... » ou de refuser de coter lorsque les données sont trop anciennes. Ce seul champ est souvent le premier que réclament les auditeurs et les équipes financières.

Étape 6 : tester sans toucher au réseau

L'URL de base étant injectable, vous pouvez tester toute la logique du client et du cache avec httptest.NewServer. Ce test démontre le comportement le plus important : lorsque l'API se met à échouer, le cache sert des données périmées plutôt qu'une erreur.

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

Lancez-le avec go test ./.... Ce genre de test s'exécute en quelques millisecondes, ne nécessite aucune clé d'API et peut s'intégrer à votre CI. Vous pouvez ajouter, dans le même style, des cas pour les réponses 429, le JSON mal formé et les serveurs lents (pour vérifier le timeout).

Check-list de mise en production

Avant de livrer, passez en revue ces points :

  • Choisissez un TTL raisonnable. Une heure convient à la facturation et à l'affichage des prix en e-commerce, et garde une instance dans le forfait gratuit. Les fonctions de cotation ou de trading peuvent exiger des minutes, voire des secondes. Adaptez le TTL au risque métier, puis choisissez l'offre en conséquence.
  • Exposez l'horodatage du taux partout où un client voit un montant converti, et enregistrez le taux utilisé pour chaque transaction afin de pouvoir la reproduire plus tard.
  • Alertez sur la péremption. Émettez une métrique lorsque stale=true persiste au-delà d'un seuil. Un cache qui sert discrètement des données vieilles d'un jour est pire qu'une erreur.
  • Préchauffez le cache au démarrage et faites échouer les contrôles de disponibilité si la première récupération échoue, pour détecter une mauvaise clé dès le déploiement.
  • Traitez le 429 avec un backoff plutôt que de réessayer aussitôt. Si vous le voyez souvent, votre TTL est trop court ou plusieurs instances se rafraîchissent chacune de leur côté. Un cache partagé comme Redis règle le second cas.
  • Comparez les fournisseurs de temps en temps. Si vous évaluez des sources de données, notre page pour comparer les API de devises et l'aperçu de l'API de devises gratuite montrent les critères à regarder.

Si vous travaillez dans plusieurs langages, les mêmes schémas apparaissent dans notre tutoriel Rust et notre tutoriel Python.

Questions fréquentes

Existe-t-il une API de taux de change gratuite pour Go ?

Oui. Finexly propose un forfait gratuit de 1 000 requêtes par mois, et le client ci-dessus n'a besoin d'aucun paquet tiers. Avec un cache d'une heure, une instance toujours active consomme environ 720 requêtes par mois, ce qui laisse de la marge. N'oubliez pas que chaque instance supplémentaire se rafraîchit de son côté. Consultez les forfaits tarifaires pour les limites actuelles.

Faut-il utiliser float64 pour les taux de change en Go ?

Utiliser float64 pour le taux est généralement acceptable, car les taux sont des approximations à quelques chiffres significatifs. L'utiliser pour les montants ne l'est pas. Conservez les montants en unités mineures entières, ou utilisez une bibliothèque décimale, et arrondissez une seule fois à la fin.

À quelle fréquence faut-il rafraîchir les taux de change ?

Cela dépend de votre tolérance au risque. Pour les prix affichés et les rapports, 15 à 60 minutes suffisent généralement. La facturation fige souvent un taux par document. Tout ce qui déplace de l'argent en temps réel doit enregistrer le taux exact et l'horodatage utilisés, quel que soit l'intervalle de rafraîchissement.

Comment éviter que ma clé API ne fuite dans les logs Go ?

Lorsqu'une clé est envoyée en paramètre de requête, les erreurs renvoyées par http.Client intègrent l'URL complète. Déballez *url.Error avec errors.As et ne journalisez que son champ Err, comme le fait le client de ce guide. Évitez aussi de journaliser les URL complètes dans vos middlewares.

Puis-je récupérer plusieurs devises de base en parallèle ?

Oui. Utilisez golang.org/x/sync/errgroup avec SetLimit pour borner le parallélisme et partagez le même Client, qui peut être utilisé en concurrence. Dans la plupart des cas, une seule devise de base associée au calcul de taux croisés de l'étape 4 est plus simple et moins coûteuse.

Prochaines étapes

Prêt à intégrer des taux de change en temps réel dans votre service Go ? Obtenez votre clé API Finexly gratuite : sans carte bancaire. Démarrez avec 1 000 requêtes gratuites par mois et évoluez à mesure que vous grandissez.

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 →

Partager cet article