Skip to content

Settings & config

TL;DR — La configuration d'une app FastAPI ne se gère ni avec os.environ.get() dispersé partout, ni avec un fichier config.py rempli de constantes. On la centralise dans une classe pydantic-settings.BaseSettings : un schéma typé, validé au démarrage, sourcé depuis l'environnement (et .env en dev), instancié une seule fois et injecté via Depends. Le bénéfice senior : un démarrage qui fail-fast si une variable manque ou est mal typée, des secrets jamais hardcodés, et un point unique pour brancher Vault/AWS Secrets Manager en prod. Pour un service qui sert un agent LLM, c'est là que vivent ANTHROPIC_API_KEY, le model (claude-opus-4-8), les timeouts et les budgets — typés et overridables par environnement.


🧠 Mental model

Tu viens de NestJS : tu connais @nestjs/config, ConfigModule.forRoot(), configService.get<string>('DB_HOST'). pydantic-settings est l'équivalent Python, en mieux sur un point : la validation est structurelle et au démarrage, pas à la lecture. Tu ne fais pas get('PORT') en espérant que ce soit un int — tu déclares port: int et Pydantic refuse de booter si PORT=banane.

L'analogie qui colle : la classe Settings est le contrat d'entrée de ton process, exactement comme un DTO Pydantic est le contrat d'entrée d'un endpoint. Un body HTTP mal formé → 422 avant que ton handler ne tourne. Un environnement mal formé → ValidationError avant que ton serveur n'accepte la moindre requête. Dans les deux cas : la donnée invalide ne franchit jamais la frontière.

   ENV / .env / secrets manager          le contrat              ton code
  ┌──────────────────────────┐     ┌──────────────────────┐   ┌──────────────┐
  │ ANTHROPIC_API_KEY=sk-...  │     │ class Settings(      │   │  service.py  │
  │ MODEL=claude-opus-4-8     │ ──▶ │   BaseSettings):     │──▶│  settings.   │
  │ HTTP_TIMEOUT=30           │     │   anthropic_api_key  │   │   model      │
  │ LOG_LEVEL=info            │     │   model: str         │   │              │
  └──────────────────────────┘     │   http_timeout: int  │   └──────────────┘
        sources brutes             │   (typé + validé)    │     usage typé
        (strings)                  └──────────┬───────────┘
                                              │ ❌ fail-fast ici
                                       si une var manque /
                                       ne parse pas → crash
                                       au démarrage, pas en prod

La règle mentale : lis l'environnement une fois, valide-le une fois, ne le relis jamais. Tout le reste du code consomme un objet typé, pas des os.environ.


Le cœur : pydantic-settings

Depuis Pydantic v2, BaseSettings a été extrait dans un package dédié.

bash
pip install pydantic-settings

La façon idiomatique

python
# app/config.py
from functools import lru_cache
from typing import Literal

from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",            # chargé en dev ; absent en prod = pas grave
        env_file_encoding="utf-8",
        env_prefix="APP_",          # APP_DATABASE_URL, APP_LOG_LEVEL, ...
        extra="forbid",             # une var inconnue = ValidationError (voir prod)
        frozen=True,                # l'objet est immuable une fois construit
    )

    # --- App ---
    environment: Literal["dev", "staging", "prod"] = "dev"
    log_level: Literal["debug", "info", "warning", "error"] = "info"

    # --- Base de données ---
    database_url: str
    db_pool_size: int = Field(default=10, ge=1, le=100)

    # --- LLM (Anthropic) ---
    anthropic_api_key: SecretStr            # jamais loggé en clair (voir plus bas)
    anthropic_model: str = "claude-opus-4-8"
    anthropic_max_tokens: int = Field(default=4096, ge=1, le=128_000)
    anthropic_effort: Literal["low", "medium", "high", "xhigh", "max"] = "high"
    http_timeout_s: float = Field(default=30.0, gt=0)


@lru_cache
def get_settings() -> Settings:
    """Construit Settings une seule fois (mémoïsé). C'est le point d'injection FastAPI."""
    return Settings()

Trois choses non négociables ici :

  1. SecretStr pour les secrets. str(settings.anthropic_api_key) affiche **********, pas la clé. Pour la valeur réelle : settings.anthropic_api_key.get_secret_value(). Ça évite la fuite la plus banale — une clé qui se retrouve dans un log d'exception ou un print(settings).
  2. @lru_cache sur get_settings. C'est le singleton du pauvre, idiomatique et thread-safe. La classe Settings lit l'environnement dans son __init__ ; sans cache, chaque Depends(get_settings) relirait .env et reparserait tout à chaque requête.
  3. frozen=True — la config ne doit pas muter pendant la vie du process. Si tu veux "recharger", tu redéploies.

Injection dans FastAPI

python
# app/deps.py
from typing import Annotated
from fastapi import Depends
from app.config import Settings, get_settings

SettingsDep = Annotated[Settings, Depends(get_settings)]
python
# app/main.py
from fastapi import FastAPI
from app.deps import SettingsDep

app = FastAPI()


@app.get("/health")
async def health(settings: SettingsDep) -> dict[str, str]:
    return {"status": "ok", "env": settings.environment}

Annotated[..., Depends(...)] est le pattern moderne (FastAPI ≥ 0.95) : le type et la dépendance dans un seul alias réutilisable. Tu obtiens l'autocomplétion sur settings.anthropic_model dans tout endpoint qui déclare SettingsDep.

La façon erronée (celle qu'on voit en vrai)

python
# ❌ NE FAIS PAS ÇA
import os

DATABASE_URL = os.environ["DATABASE_URL"]              # KeyError au pire moment
DB_POOL_SIZE = int(os.environ.get("DB_POOL_SIZE", 10)) # parsing manuel, répété
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")     # peut être None silencieusement
MODEL = os.getenv("MODEL") or "claude-opus-4-8"        # la valeur par défaut est dupliquée partout

Pourquoi c'est mauvais, concrètement :

  • Pas de validation. DB_POOL_SIZE=-5 passe. HTTP_TIMEOUT=abc explose en ValueError... au premier appel HTTP, en prod, pas au boot.
  • None qui se propage. ANTHROPIC_API_KEY non défini → None → le SDK lève une erreur cryptique trois couches plus bas, à la première requête utilisateur.
  • Defaults dispersés. Le 10 et le "claude-opus-4-8" sont copiés dans chaque module qui les lit. Le jour où tu changes de modèle, tu fais un grep-and-pray.
  • Intestable. Tu ne peux pas injecter une config différente dans un test sans monkeypatcher os.environ globalement.

L'erreur intermédiaire, c'est le module de constantes :

python
# ⚠️ Mieux que os.environ, mais toujours pas idéal
# config.py
ANTHROPIC_MODEL = "claude-opus-4-8"
HTTP_TIMEOUT = 30

Typé... mais figé à l'import, non-overridable par environnement, et non injectable. On perd le fail-fast et la testabilité.


Précédence des sources & environnements multiples

pydantic-settings résout chaque champ dans cet ordre (du plus prioritaire au moins prioritaire) :

1. arguments passés à Settings(...)        ← les tests
2. variables d'environnement                ← la prod
3. fichier .env                             ← le dev local
4. valeurs par défaut dans la classe        ← le fallback

En clair : l'environnement gagne toujours sur .env, et tu peux tout écraser dans un test en passant des kwargs. C'est exactement ce qu'il faut.

Pour gérer dev/staging/prod sans if environment == ... partout, deux approches saines :

python
# Option A — un .env par environnement, sélectionné au lancement
# ENV_FILE=.env.prod uvicorn app.main:app
import os
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=os.getenv("ENV_FILE", ".env"),
        extra="forbid",
    )
    ...
python
# Option B — pas de .env du tout en prod : uniquement des vraies variables
# d'environnement injectées par l'orchestrateur (Kubernetes, ECS, systemd).
# .env reste un confort de dev, jamais commité, jamais déployé.

En prod, l'option B est la cible. Un .env ne devrait jamais atterrir dans une image Docker — c'est une source de fuite et une fausse source de vérité.


⚙️ En production

Failure modes

1. La variable manquante découverte trop tard. Sans BaseSettings, une ANTHROPIC_API_KEY absente devient une AuthenticationError à la première requête utilisateur. Avec, c'est un ValidationError au Settings() du boot → le conteneur crash, l'orchestrateur ne route aucun trafic vers lui, le déploiement échoue proprement. Fail-fast > fail-in-front-of-the-user. Force ce comportement en instanciant la config au démarrage :

python
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import get_settings


@asynccontextmanager
async def lifespan(app: FastAPI):
    settings = get_settings()   # ValidationError ici = crash au boot, pas en prod
    app.state.settings = settings
    # ... ouvrir les pools DB, le client Anthropic, etc.
    yield
    # ... fermer proprement


app = FastAPI(lifespan=lifespan)

2. extra="forbid" vs extra="ignore". forbid (mon défaut) attrape les fautes de frappe : APP_ANTROPIC_API_KEY (typo) lève une erreur au lieu d'être silencieusement ignorée, te laissant avec une clé... non chargée. Le piège : sur un host où traînent des dizaines de variables d'environnement sans rapport, forbid + un env_prefix vide ferait planter le boot sur la première variable étrangère. La parade est le env_prefix="APP_" : Pydantic ne regarde que les variables préfixées, donc forbid ne s'applique qu'à ton namespace.

3. Le secret qui fuite dans les logs. SecretStr protège le repr, mais ta get_secret_value() peut encore finir dans une trace si tu n'es pas prudent. Règle : ne passe jamais la valeur réelle ailleurs que dans le constructeur du client qui en a besoin, juste à temps.

Performance

@lru_cache rend get_settings() quasi gratuit après le premier appel — pas de relecture de fichier, pas de re-parsing. Mais attention : le client HTTP/SDK ne doit pas être recréé par requête. Une erreur classique est de faire AsyncAnthropic(api_key=...) dans chaque endpoint. Le client gère un pool de connexions ; le recréer à chaque requête tue le keep-alive et explose la latence. Construis-le une fois dans le lifespan, stocke-le, injecte-le.

Sécurité

  • Secrets en SecretStr, valeur réelle extraite au dernier moment.
  • .env dans .gitignore, jamais dans l'image Docker. Documente les variables dans un .env.example sans valeurs.
  • En prod, la source de vérité est le secrets manager (AWS Secrets Manager, Vault, GCP Secret Manager). pydantic-settings supporte des settings_customise_sources pour brancher un fetcher custom — mais le plus simple reste l'orchestrateur qui injecte les secrets comme variables d'environnement au démarrage.
  • Ne logge jamais l'objet Settings entier. Si tu veux un dump de diagnostic, utilise settings.model_dump() en sachant que les SecretStr y restent masqués.

Observabilité

Au boot, logge la forme de la config, pas son contenu : environment, anthropic_model, log_level, db_pool_size. Ça te donne, dans tes logs de démarrage, exactement quelle config tourne sur quel pod — inestimable quand un déploiement se comporte bizarrement. Jamais les secrets.

Le tradeoff senior

extra="forbid" + frozen=True + @lru_cache = une config rigide et immuable. C'est volontaire : en prod, une config qui mute à chaud est une source de bugs non reproductibles. Le prix à payer est qu'un changement de config = un redéploiement. Pour 99 % des services, c'est le bon arbitrage. Le feature-flagging dynamique (LaunchDarkly, etc.) est un autre concern, à ne pas confondre avec la config d'infrastructure.


Brancher un agent LLM (Anthropic) sur la config

C'est là que tout se rejoint. Le client Anthropic, le modèle, les budgets : tout vient de Settings, construit une fois dans le lifespan, injecté par Depends.

python
# app/llm.py
from contextlib import asynccontextmanager
from typing import Annotated

import anthropic
from anthropic import AsyncAnthropic
from fastapi import Depends, FastAPI, HTTPException, Request

from app.config import Settings, get_settings


@asynccontextmanager
async def lifespan(app: FastAPI):
    settings = get_settings()
    # Un seul client pour tout le process — pool de connexions partagé.
    app.state.anthropic = AsyncAnthropic(
        api_key=settings.anthropic_api_key.get_secret_value(),
        timeout=settings.http_timeout_s,
        max_retries=2,   # le SDK retry 429/5xx avec backoff exponentiel
    )
    app.state.settings = settings
    try:
        yield
    finally:
        await app.state.anthropic.close()


app = FastAPI(lifespan=lifespan)


def get_client(request: Request) -> AsyncAnthropic:
    return request.app.state.anthropic


ClientDep = Annotated[AsyncAnthropic, Depends(get_client)]
SettingsDep = Annotated[Settings, Depends(get_settings)]

Appel one-shot, typé et géré

python
# app/routes/chat.py
import anthropic
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from app.llm import ClientDep, SettingsDep

router = APIRouter()


class ChatRequest(BaseModel):
    prompt: str


class ChatResponse(BaseModel):
    text: str


@router.post("/chat")
async def chat(
    body: ChatRequest, client: ClientDep, settings: SettingsDep
) -> ChatResponse:
    try:
        message = await client.messages.create(
            model=settings.anthropic_model,          # "claude-opus-4-8"
            max_tokens=settings.anthropic_max_tokens,
            thinking={"type": "adaptive"},           # adaptive thinking, pas de budget_tokens
            output_config={"effort": settings.anthropic_effort},
            messages=[{"role": "user", "content": body.prompt}],
        )
    except anthropic.RateLimitError as exc:
        # exception TYPÉE — jamais de string-matching sur le message
        raise HTTPException(status_code=429, detail="LLM rate limited") from exc
    except anthropic.APIError as exc:
        raise HTTPException(status_code=502, detail="LLM upstream error") from exc

    text = "".join(b.text for b in message.content if b.type == "text")
    return ChatResponse(text=text)

Note thinking={"type": "adaptive"} + output_config={"effort": ...} : sur claude-opus-4-8, c'est la seule façon de configurer la réflexion. Un budget_tokens renverrait un 400. L'effort vient de la config — tu peux mettre low en staging pour économiser, high en prod, sans toucher au code.

Streaming de tokens en SSE

Pour une réponse longue, on stream : ça évite les timeouts HTTP sur les gros max_tokens et donne un retour immédiat à l'UI Angular.

python
# app/routes/stream.py
from fastapi import APIRouter
from fastapi.responses import StreamingResponse

from app.llm import ClientDep, SettingsDep

router = APIRouter()


@router.post("/chat/stream")
async def chat_stream(prompt: str, client: ClientDep, settings: SettingsDep):
    async def event_source():
        async with client.messages.stream(
            model=settings.anthropic_model,
            max_tokens=settings.anthropic_max_tokens,
            thinking={"type": "adaptive"},
            output_config={"effort": settings.anthropic_effort},
            messages=[{"role": "user", "content": prompt}],
        ) as stream:
            async for text in stream.text_stream:
                yield f"data: {text}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(event_source(), media_type="text/event-stream")

Tout ce qui change entre dev et prod (modèle, effort, timeout, max_tokens) est dans Settings — le code de l'agent ne contient aucune valeur en dur.

Prompt caching piloté par config

Le system prompt stable d'un agent est un excellent candidat au prompt caching (lecture à ~0,1× le prix). Tu peux activer/désactiver le caching par environnement via un flag de config, et garder le system prompt gelé (donc cacheable) tout en injectant le volatile après le breakpoint :

python
SYSTEM = "Tu es un assistant support. Réponds en français, cite tes sources."

await client.messages.create(
    model=settings.anthropic_model,
    max_tokens=settings.anthropic_max_tokens,
    system=[
        {
            "type": "text",
            "text": SYSTEM,
            "cache_control": {"type": "ephemeral"},   # breakpoint de cache
        }
    ],
    messages=[{"role": "user", "content": body.prompt}],
)

Le lien avec la config : ne jamais interpoler datetime.now() ou un user-id dans SYSTEM (ça casserait le cache). La config détermine quel modèle et quel prompt, mais le prompt lui-même reste byte-stable.


🏋️ Exercices

Exercice 1 — Le contrat minimal (implémenter)

Objectif. Écris une classe Settings(BaseSettings) avec : environment (Literal["dev","staging","prod"], défaut dev), database_url (obligatoire, pas de défaut), anthropic_api_key (SecretStr, obligatoire), anthropic_model (défaut claude-opus-4-8). Expose get_settings() mémoïsé. Écris un test qui prouve qu'instancier Settings() sans DATABASE_URL ni ANTHROPIC_API_KEY dans l'environnement lève bien une ValidationError.

Indice / Solution

Le test passe les variables via monkeypatch.setenv (cas succès) et vérifie l'absence (cas échec) avec pytest.raises(ValidationError). Astuce : passe directement les kwargs — Settings(database_url="...", anthropic_api_key="sk-...") — pour tester sans toucher à l'environnement. La précédence (kwargs > env) garantit que ça marche. Pour le cas d'échec, instancie avec un environnement vide : Settings(_env_file=None) force l'ignorance du .env.

Exercice 2 — Validation métier (production-grade)

Objectif. Ajoute un @field_validator ou un @model_validator qui refuse le boot si environment == "prod" et que anthropic_model n'est pas dans une allowlist de modèles approuvés ({"claude-opus-4-8", "claude-sonnet-4-6"}). En staging/dev, n'importe quel modèle est toléré. Ajoute aussi : en prod, log_level ne peut pas être debug.

Indice / Solution

Utilise un @model_validator(mode="after") car la règle croise deux champs. Il reçoit self (l'instance déjà validée champ par champ) et lève ValueError si la combinaison est interdite — Pydantic la convertit en ValidationError. Exemple de squelette :

python
from pydantic import model_validator

APPROVED = {"claude-opus-4-8", "claude-sonnet-4-6"}

@model_validator(mode="after")
def check_prod_invariants(self) -> "Settings":
    if self.environment == "prod":
        if self.anthropic_model not in APPROVED:
            raise ValueError(f"modèle {self.anthropic_model!r} interdit en prod")
        if self.log_level == "debug":
            raise ValueError("log_level=debug interdit en prod")
    return self

Exercice 3 — Injection testable (implémenter)

Objectif. Monte un endpoint /config-shape qui retourne {"env": ..., "model": ...} (jamais la clé). Écris un test qui override get_settings via app.dependency_overrides pour injecter une Settings de test (env staging, modèle claude-haiku-4-5) sans toucher à l'environnement réel, et vérifie la réponse.

Indice / Solution

app.dependency_overrides[get_settings] = lambda: Settings(environment="staging", anthropic_model="claude-haiku-4-5", database_url="sqlite://", anthropic_api_key="sk-test"). Utilise TestClient (ou httpx.AsyncClient + ASGITransport pour de l'async). N'oublie pas de app.dependency_overrides.clear() en teardown — sinon l'override fuit dans les tests suivants. C'est toute la raison d'être de l'injection par Depends : la config devient un point d'extension testable.

Exercice 4 — Casser puis réparer le cache LLM (break-then-fix)

Objectif. Prends l'exemple de prompt caching ci-dessus. Modifie-le pour interpoler f"... Date: {datetime.now()} ..." dans le SYSTEM. Fais deux appels identiques et observe que usage.cache_read_input_tokens reste à 0 (cache jamais lu) — la config "cache activé" ne sert à rien. Puis répare : sors la date du prefix caché. Re-mesure et prouve le hit.

Indice / Solution

Le caching est un prefix match : un seul octet qui change avant le breakpoint invalide tout. datetime.now() change à chaque requête → chaque appel écrit un cache neuf, jamais relu. La réparation : garde SYSTEM byte-stable et déplace tout volatile (date, contexte par-requête) après le dernier cache_control, c'est-à-dire dans le messages. Vérifie via message.usage.cache_read_input_tokens > 0 au second appel. Leçon transversale : la config peut activer une optimisation, mais une mauvaise construction de prompt la neutralise silencieusement.

Exercice 5 — Source custom : secrets manager (production-grade, hard)

Objectif. Implémente settings_customise_sources pour qu'en environment="prod", les secrets (anthropic_api_key, database_url) soient lus depuis un fetcher simulé (un dict, mimant AWS Secrets Manager) prioritaire sur l'environnement, tout en gardant .env en dev. Vérifie par un test que le secret du "manager" gagne bien sur une variable d'environnement homonyme.

Indice / Solution

pydantic-settings expose settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings) — un classmethod qui retourne un tuple de sources dans l'ordre de précédence souhaité. Crée une PydanticBaseSettingsSource custom dont __call__ retourne le dict du manager, et place-la avant env_settings dans le tuple retourné. La validation reste identique — seule la provenance change. Piège : ce hook tourne au moment de la construction, donc tout fetch réseau réel doit être synchrone et robuste (timeout, retry), car un échec ici = échec du boot.

Exercice 6 — Le piège du client recréé (break-then-fix, perf)

Objectif. Écris d'abord la mauvaise version : AsyncAnthropic(...) instancié dans le handler à chaque requête. Mesure la latence sous charge (par ex. ab ou un petit script asyncio.gather de 50 requêtes). Puis répare en hoistant le client dans le lifespan. Quantifie la différence.

Indice / Solution

Le client maintient un pool de connexions HTTP (httpx sous le capot). Le recréer par requête force une nouvelle handshake TLS à chaque fois et jette le keep-alive — la latence p95 grimpe nettement sous concurrence. La réparation est exactement le pattern app.state.anthropic du lifespan montré plus haut, injecté via ClientDep. Le lien avec la config : le client dépend de Settings (clé, timeout) mais ne doit être construit qu'une fois — c'est la frontière entre "config lue une fois" et "ressource créée une fois".


🎤 En entretien

Q : Pourquoi pydantic-settings plutôt que os.environ ou un module de constantes ? Parce que ça valide le contrat d'entrée du process au démarrage (fail-fast typé), centralise les valeurs par défaut, et rend la config injectable donc testable. os.environ repousse l'échec à la première requête en prod ; un module de constantes est figé à l'import et non-overridable.

Q : Comment empêcher une clé d'API d'apparaître dans les logs ? Type-la en SecretStr : le repr et model_dump() la masquent automatiquement, et on n'extrait get_secret_value() qu'au dernier moment, dans le constructeur du client qui la consomme — jamais dans une variable intermédiaire susceptible d'être loggée.

Q : Pourquoi @lru_cache sur get_settings et frozen=True sur la classe ?lru_cache fait de la lecture/parsing de l'environnement une opération unique (singleton thread-safe, performance), et frozen=True rend la config immuable pendant la vie du process — en prod, une config qui mute à chaud produit des bugs non reproductibles. Le prix assumé : changer la config = redéployer.

Q : Sur un service qui sert un agent Claude, qu'est-ce qui vit dans Settings vs dans le code ? Dans Settings : anthropic_api_key (SecretStr), anthropic_model (claude-opus-4-8), max_tokens, effort, http_timeout, flags de caching — tout ce qui varie par environnement. Dans le code : la logique d'appel, le parsing des content blocks, le system prompt (gelé, pour le caching). Le client AsyncAnthropic est construit une seule fois dans le lifespan à partir de Settings, jamais par requête.

Bibliothèque tech perso — Achref