Settings & config
TL;DR — La configuration d'une app FastAPI ne se gère ni avec
os.environ.get()dispersé partout, ni avec un fichierconfig.pyrempli de constantes. On la centralise dans une classepydantic-settings.BaseSettings: un schéma typé, validé au démarrage, sourcé depuis l'environnement (et.enven dev), instancié une seule fois et injecté viaDepends. 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 viventANTHROPIC_API_KEY, lemodel(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 prodLa 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é.
pip install pydantic-settingsLa façon idiomatique
# 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 :
SecretStrpour 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 unprint(settings).@lru_cachesurget_settings. C'est le singleton du pauvre, idiomatique et thread-safe. La classeSettingslit l'environnement dans son__init__; sans cache, chaqueDepends(get_settings)relirait.envet reparserait tout à chaque requête.frozen=True— la config ne doit pas muter pendant la vie du process. Si tu veux "recharger", tu redéploies.
Injection dans FastAPI
# app/deps.py
from typing import Annotated
from fastapi import Depends
from app.config import Settings, get_settings
SettingsDep = Annotated[Settings, Depends(get_settings)]# 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)
# ❌ 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 partoutPourquoi c'est mauvais, concrètement :
- Pas de validation.
DB_POOL_SIZE=-5passe.HTTP_TIMEOUT=abcexplose enValueError... au premier appel HTTP, en prod, pas au boot. Nonequi se propage.ANTHROPIC_API_KEYnon défini →None→ le SDK lève une erreur cryptique trois couches plus bas, à la première requête utilisateur.- Defaults dispersés. Le
10et 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.environglobalement.
L'erreur intermédiaire, c'est le module de constantes :
# ⚠️ Mieux que os.environ, mais toujours pas idéal
# config.py
ANTHROPIC_MODEL = "claude-opus-4-8"
HTTP_TIMEOUT = 30Typé... 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 fallbackEn 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 :
# 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",
)
...# 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 :
# 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. .envdans.gitignore, jamais dans l'image Docker. Documente les variables dans un.env.examplesans valeurs.- En prod, la source de vérité est le secrets manager (AWS Secrets Manager, Vault, GCP Secret Manager).
pydantic-settingssupporte dessettings_customise_sourcespour 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
Settingsentier. Si tu veux un dump de diagnostic, utilisesettings.model_dump()en sachant que lesSecretStry 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.
# 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é
# 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.
# 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 :
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 :
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 selfExercice 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.