Retour au blog

Créer une API de taux de change avec FastAPI (httpx asynchrone, mise en cache, Pydantic)

V
Vlado Grigirov
August 11, 2026
FastAPI Python Currency API Exchange Rates Tutorial Fintech

Si vous construisez un backend fintech, un tunnel de paiement multidevise ou un service de tarification interne en Python, vous aurez tôt ou tard besoin de votre propre endpoint de change. Créer une API de taux de change avec FastAPI vous donne un microservice rapide, asynchrone et entièrement typé qui enveloppe un fournisseur de taux en direct, ajoute une mise en cache pour rester dans un palier gratuit et expose un endpoint /convert propre que vos autres services peuvent appeler. FastAPI est naturellement adapté ici : il repose sur ASGI pour de vraies I/O asynchrones, il valide les requêtes et les réponses avec Pydantic, et il génère gratuitement une documentation OpenAPI interactive. Ce tutoriel parcourt tout le chemin — de votre première requête au fournisseur jusqu'à un convertisseur prêt pour la production avec un client HTTP partagé, des lectures en cache, des montants sûrs en décimal et une gestion d'erreurs typée.

À la fin, vous disposerez d'un petit service FastAPI autonome, appuyé sur la documentation de l'API Finexly et sa couverture de plus de 170 devises. Si vous avez déjà lu notre tutoriel de l'API de devises en Python, voici le complément au niveau service qui montre comment les pièces s'assemblent dans un vrai backend ASGI. Là où le tutoriel Django s'appuie sur l'ORM et la couche de templates, FastAPI reste léger et orienté async.

Pourquoi FastAPI est un bon choix pour un microservice de devises

La conversion de devises paraît triviale — multiplier un montant par un taux — mais bien la faire en tant que service touche à des préoccupations pour lesquelles FastAPI a été conçu :

  • De vraies I/O asynchrones. Récupérer des taux dépend du réseau. Avec des endpoints async def et un client HTTP asynchrone, un seul worker peut traiter de nombreuses conversions concurrentes pendant que les requêtes sont en cours, au lieu de bloquer un thread par appel.
  • Des contrats typés. Les modèles Pydantic valident les paramètres de requête entrants et façonnent le JSON sortant, si bien qu'un amount mal formé ou un code de devise inconnu est rejeté avec un 422 clair avant même d'atteindre votre logique.
  • Documentation interactive gratuite. Chaque endpoint que vous écrivez apparaît dans Swagger UI à /docs, ce qui rend le service auto-documenté pour le reste de votre équipe.
  • Gestion du lifespan. FastAPI vous offre un endroit propre pour ouvrir un unique pool de connexions HTTP au démarrage et le vider à l'arrêt — essentiel pour la performance, comme vous le verrez plus bas.

L'architecture que nous construisons est un proxy fin et conscient du cache : votre service appelle l'API de taux, met la réponse en cache pendant une courte fenêtre et sert des conversions à votre propre frontend ou à d'autres microservices. Ce schéma garde vos clés d'API côté serveur, vous isole des limites de débit du fournisseur et vous permet d'ajouter des règles métier (arrondi, marges, listes d'autorisation) en un seul endroit.

Prérequis et mise en place du projet

Il vous faut Python 3.11 ou plus récent. Créez un environnement virtuel et installez les dépendances :

python -m venv .venv
source .venv/bin/activate  # on Windows: .venv\Scripts\activate

pip install "fastapi[standard]" httpx pydantic-settings

fastapi[standard] embarque Uvicorn (le serveur ASGI) et la CLI. httpx est notre client HTTP asynchrone, et pydantic-settings gère la configuration à partir des variables d'environnement.

Récupérez une clé d'API gratuite dans votre tableau de bord Finexly et exportez-la pour qu'elle n'atterrisse jamais dans le contrôle de version :

export FINEXLY_API_KEY="your_api_key_here"

Créez l'arborescence du projet :

fx-service/
├── app/
│   ├── __init__.py
│   ├── config.py
│   ├── client.py
│   ├── models.py
│   └── main.py
└── .env

Étape 1 : Configuration avec pydantic-settings

Gardez la configuration en un seul endroit typé. pydantic-settings lit les variables d'environnement et un fichier .env, et échoue bruyamment si une valeur requise manque.

# app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="FINEXLY_")

    api_key: str
    base_url: str = "https://api.finexly.com/v1"
    cache_ttl_seconds: int = 300
    request_timeout_seconds: float = 10.0


settings = Settings()  # raises at startup if FINEXLY_API_KEY is unset

Comme l'objet de configuration est créé au moment de l'import, une clé manquante empêche le service de démarrer au lieu d'échouer à la première requête — exactement ce que vous voulez dans un pipeline de déploiement.

Étape 2 : Un client HTTP asynchrone partagé via le lifespan

C'est la décision de performance la plus importante de tout le service. Ne créez pas un nouveau httpx.AsyncClient par requête. Chaque client possède un pool de connexions ; en créer un par appel jette les connexions keep-alive et force un nouveau handshake TLS à chaque fois. Ouvrez plutôt un client au démarrage de l'app et réutilisez-le pendant toute la vie du processus.

La manière moderne de le faire est le gestionnaire de contexte lifespan de FastAPI. (Les anciens décorateurs @app.on_event("startup") et @app.on_event("shutdown") ont été dépréciés dans FastAPI 0.93+ au profit du lifespan.) Utiliser async with garantit que le pool de connexions se vide et que les sockets se ferment à l'arrêt, même si le démarrage est interrompu.

# app/client.py
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request

from .config import settings


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once on startup: open a single pooled client
    async with httpx.AsyncClient(
        base_url=settings.base_url,
        headers={"Authorization": f"Bearer {settings.api_key}"},
        timeout=settings.request_timeout_seconds,
    ) as client:
        app.state.http = client
        yield
    # On shutdown, the async-with block closes the pool automatically


def get_client(request: Request) -> httpx.AsyncClient:
    # FastAPI dependency: hand endpoints the shared client
    return request.app.state.http

Désormais, chaque endpoint emprunte le même client du pool via une dépendance, et personne n'a à se soucier d'ouvrir ou de fermer les connexions.

Étape 3 : Modèles typés de requête et de réponse

Les modèles Pydantic vous apportent validation et réponses auto-documentées. Notez l'usage de Decimal pour les montants monétaires : les flottants binaires ne peuvent pas représenter exactement les fractions décimales, et les erreurs d'arrondi sur l'argent sont un vrai bug, pas une curiosité.

# app/models.py
from decimal import Decimal
from pydantic import BaseModel, Field


class ConversionResult(BaseModel):
    base: str = Field(..., examples=["USD"])
    quote: str = Field(..., examples=["EUR"])
    amount: Decimal = Field(..., examples=["100.00"])
    rate: Decimal = Field(..., examples=["0.92"])
    converted: Decimal = Field(..., examples=["92.00"])
    as_of: str = Field(..., description="Timestamp of the upstream rate")


class RatesResponse(BaseModel):
    base: str
    rates: dict[str, Decimal]
    as_of: str

FastAPI rendra ces modèles dans le schéma OpenAPI et convertira/validera les valeurs automatiquement, si bien qu'une requête avec un montant absurde est rejetée avant l'exécution de votre handler.

Étape 4 : Récupérer les taux avec un petit cache TTL

Les taux ne changent pas de milliseconde en milliseconde, et marteler l'API du fournisseur à chaque requête est à la fois lent et un moyen rapide d'épuiser votre quota. Un petit cache en mémoire à durée de vie (TTL) lisse cela. Pour un service mono-processus, ce cache basé sur un dictionnaire suffit ; pour plusieurs workers, remplacez-le par Redis plus tard sans changer les points d'appel.

# app/main.py
import time
from decimal import Decimal, ROUND_HALF_UP

import httpx
from fastapi import Depends, FastAPI, HTTPException, Query

from .client import lifespan, get_client
from .config import settings
from .models import ConversionResult

app = FastAPI(title="FX Service", lifespan=lifespan)

# Simple in-memory cache: {base_currency: (expiry_timestamp, rates_dict)}
_cache: dict[str, tuple[float, dict]] = {}


async def fetch_rates(base: str, client: httpx.AsyncClient) -> dict:
    base = base.upper()
    now = time.monotonic()
    cached = _cache.get(base)
    if cached and cached[0] > now:
        return cached[1]  # cache hit — no network call

    try:
        resp = await client.get("/latest", params={"base": base})
        resp.raise_for_status()
    except httpx.HTTPStatusError as exc:
        code = exc.response.status_code
        if code == 429:
            raise HTTPException(503, "Upstream rate limit reached; try again shortly")
        if code in (401, 403):
            raise HTTPException(502, "Upstream authentication failed")
        raise HTTPException(502, "Upstream rates provider error")
    except httpx.RequestError:
        raise HTTPException(504, "Could not reach rates provider")

    rates = resp.json()["rates"]
    _cache[base] = (now + settings.cache_ttl_seconds, rates)
    return rates

Remarquez comment les échecs du fournisseur sont traduits en codes de statut HTTP pertinents pour vos appelants. Un 429 du fournisseur devient un 503 avec une indication de nouvelle tentative ; un échec d'authentification devient un 502 pour ne jamais fuiter de détails d'identifiants en aval. Notre guide complémentaire sur le cache et la gestion des erreurs des API de devises approfondit ces schémas.

Étape 5 : Les endpoints /convert et /rates

Les endpoints eux-mêmes sont minuscules, car tout le travail difficile vit dans fetch_rates. Nous validons les paramètres de requête avec le Query de FastAPI, faisons des calculs sûrs en décimal et retournons des modèles typés.

@app.get("/convert", response_model=ConversionResult)
async def convert(
    base: str = Query(..., min_length=3, max_length=3),
    quote: str = Query(..., min_length=3, max_length=3),
    amount: Decimal = Query(..., gt=0),
    client: httpx.AsyncClient = Depends(get_client),
):
    base, quote = base.upper(), quote.upper()
    rates = await fetch_rates(base, client)

    if quote not in rates:
        raise HTTPException(400, f"Unsupported currency: {quote}")

    rate = Decimal(str(rates[quote]))
    converted = (amount * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

    return ConversionResult(
        base=base, quote=quote, amount=amount,
        rate=rate, converted=converted, as_of="latest",
    )


@app.get("/health")
async def health():
    return {"status": "ok"}

Lancez-le en local :

fastapi dev app/main.py

Ouvrez ensuite http://127.0.0.1:8000/docs pour la Swagger UI interactive, ou appelez l'endpoint directement :

curl "http://127.0.0.1:8000/convert?base=USD&quote=EUR&amount=100"
{
  "base": "USD",
  "quote": "EUR",
  "amount": "100",
  "rate": "0.92",
  "converted": "92.00",
  "as_of": "latest"
}

Vous avez maintenant un microservice de devises fonctionnel. Si vous préférez ne pas exécuter votre propre calcul de conversion, Finexly propose aussi un convertisseur de devises hébergé et un endpoint de conversion direct que vous pouvez appeler.

Étape 6 : Une note sur la précision décimale et les devises sans décimales

Deux pièges de l'argent attrapent presque tous les services de devises :

  1. N'utilisez jamais de flottants binaires pour les montants. 0.1 + 0.2 ne vaut pas 0.3 en arithmétique flottante. Analysez taux et montants via Decimal(str(value)) et quantifiez le résultat final, comme ci-dessus.
  2. Toutes les devises n'ont pas deux décimales. L'ISO 4217 définit des unités mineures par devise : JPY et KRW ont zéro décimale, tandis que BHD et KWD en ont trois. Coder en dur .quantize(Decimal("0.01")) arrondira mal le yen et le dinar en silence. Un service de production devrait rechercher l'exposant correct par devise et quantifier en conséquence.

Bien faire cela, c'est la différence entre une démo et quelque chose que vous pouvez placer devant de vraies transactions.

Étape 7 : Tester le service

Comme le client HTTP est injecté en tant que dépendance, les tests sont faciles — remplacez la dépendance par un mock et vous ne touchez jamais au réseau :

# test_convert.py
from fastapi.testclient import TestClient
from app.main import app, fetch_rates

async def fake_rates(base, client):
    return {"EUR": "0.92", "GBP": "0.79"}

def test_convert(monkeypatch):
    monkeypatch.setattr("app.main.fetch_rates", fake_rates)
    with TestClient(app) as c:
        r = c.get("/convert?base=USD&quote=EUR&amount=100")
        assert r.status_code == 200
        assert r.json()["converted"] == "92.00"

TestClient exécute les événements de lifespan pour vous, si bien que le client partagé est mis en place et démonté exactement comme en production.

Vers la production : montée en charge et limites de débit

Quelques points à anticiper avant de livrer :

  • Plusieurs workers. Sous Uvicorn/Gunicorn avec plusieurs workers, votre cache en mémoire est par processus. Passez à Redis pour un cache partagé, afin qu'un taux récupéré par un worker serve à tous. La séparation dans fetch_rates en fait un changement d'une seule fonction.
  • Quota du fournisseur. Le cache est votre première ligne de défense. Avec un TTL de 5 minutes, une devise de base coûte au plus 12 appels au fournisseur par heure, quel que soit le nombre de conversions servies. Consultez les limites de votre offre sur la page des tarifs et réglez le TTL selon votre palier.
  • Taux historiques. Pour les factures, les rapports ou les pistes d'audit, vous voudrez un endpoint /historical qui interroge les taux d'une date précise. Les mêmes schémas de client et de cache s'appliquent ; indexez simplement le cache par (base, date).
  • Observabilité. Journalisez les taux de succès/échec du cache et la latence du fournisseur pour régler le TTL avec des données réelles.

Si vous choisissez encore un fournisseur, notre page pour comparer les API de devises présente couverture, limites et tarifs côte à côte.

Foire aux questions

Pourquoi utiliser httpx plutôt que requests dans FastAPI ?

requests est synchrone et bloque la boucle d'événements, ce qui va à l'encontre d'un framework asynchrone. httpx propose un client entièrement asynchrone (httpx.AsyncClient) avec pool de connexions, timeouts et transports avec réessais, si bien qu'il s'intègre directement aux endpoints async def de FastAPI sans bloquer les autres requêtes.

Dois-je créer le client httpx par requête ou une seule fois ?

Une seule fois. Créez un unique httpx.AsyncClient dans le gestionnaire de contexte lifespan et réutilisez-le pour toutes les requêtes. Un client par requête jette le pool de connexions et force un nouveau handshake TLS à chaque appel, ce qui est mesurablement plus lent en charge et peut épuiser les sockets.

Comment éviter d'atteindre la limite de débit de l'API de devises ?

Mettez les réponses du fournisseur en cache avec un TTL court (par exemple 5 minutes). Comme les taux évoluent lentement, un cache par devise de base réduit des milliers de conversions d'utilisateurs à une poignée d'appels au fournisseur par heure. Pour des déploiements multi-workers, utilisez Redis afin que le cache soit partagé entre les processus.

Comment gérer les devises avec un nombre différent de décimales ?

Utilisez le type Decimal de Python, jamais float, et quantifiez le résultat au bon nombre d'unités mineures de chaque devise selon l'ISO 4217. JPY et KRW utilisent zéro décimale, la plupart en utilisent deux, et BHD/KWD en utilisent trois. Recherchez l'exposant par devise plutôt que de coder en dur deux décimales.

Puis-je utiliser ce schéma pour un framework synchrone comme Flask ou Django ?

Les schémas de cache et de Decimal se transposent directement, mais les parties client partagé et endpoint asynchrone sont propres à FastAPI. Pour Django, voyez notre tutoriel dédié de l'API de devises en Django, qui utilise le backend de cache et l'ORM du framework.

Commencez à construire

Vous disposez maintenant d'un microservice de devises complet, asynchrone et conscient du cache en FastAPI — typé avec Pydantic, sûr avec Decimal et résilient aux échecs du fournisseur. Il est assez petit pour être lu d'une traite et assez structuré pour grandir vers un vrai service de production.

Prêt à intégrer des taux de change en temps réel dans votre projet ? Obtenez votre clé d'API Finexly gratuite — sans carte bancaire. Commencez avec un palier gratuit généreux couvrant plus de 170 devises, et montez en charge à mesure que votre trafic croît. Vous préférez explorer d'abord ? Parcourez l'aperçu de l'API de devises gratuite ou plongez dans la documentation de l'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 →

Partager cet article