Skip to content

Config (pydantic-settings, 12-factor)

TL;DR — La config d'une app n'est pas du code : c'est l'ensemble des valeurs qui changent entre local, staging et prod (URLs de DB, clés API, feature flags, timeouts). Le pattern 12-factor dit : mets tout ça dans l'environnement, jamais en dur dans le code, jamais commité. En Python moderne, on ne lit pas os.environ à la main : on utilise pydantic-settings (BaseSettings) qui charge depuis l'environnement + .env, valide et type chaque valeur au démarrage, et fail fast si une clé manque ou est mal formée. On expose ensuite ces settings comme un singleton caché (@lru_cache) qu'on injecte via la DI de FastAPI — y compris la clé ANTHROPIC_API_KEY, le choix de modèle et les timeouts de ton agent.


🧠 Mental model

Analogie. Pense à ta config comme au tableau de bord d'une voiture de location. Le moteur (ton code métier) est identique quelle que soit la voiture. Ce qui change, c'est ce que tu règles au démarrage : le siège, les rétros, la langue du GPS, le plein d'essence. Tu ne ressoudes pas le moteur à chaque location — tu lis les réglages depuis l'extérieur et tu refuses de partir si le réservoir est vide (fail fast).

En venant de NestJS, tu connais déjà @nestjs/config et ConfigServicepydantic-settings est l'équivalent Python, en plus strict (validation Pydantic v2 native). En venant de PHP/Laravel, c'est le config/*.php + .env + env(), mais typé et validé au boot au lieu de renvoyer des strings non vérifiées.

Le principe central du 12-factor : strict separation of config from code.

   ┌─────────────────────────────────────────────────────────┐
   │  Sources (par ordre de priorité décroissante)            │
   │                                                          │
   │  1. Arguments init explicites   Settings(db_url=...)     │  ← tests
   │  2. Variables d'environnement   APP_DB_URL=...           │  ← prod (12-factor)
   │  3. Fichier .env                APP_DB_URL=...           │  ← dev local
   │  4. Secrets (/run/secrets/...)  docker secret            │  ← prod secrets
   │  5. Valeurs par défaut          db_url: str = "sqlite"   │  ← fallback
   └────────────────────────┬─────────────────────────────────┘
                            │  pydantic-settings parse + valide

                  ┌───────────────────┐
                  │   Settings         │   ← objet typé, immuable, validé
                  │   .db_url: PgDsn   │      (fail fast si invalide)
                  │   .effort: Literal │
                  └─────────┬──────────┘
                            │  @lru_cache → 1 seule instance

              Injecté partout via Depends(get_settings)

La règle d'or : la config est lue une fois, au démarrage, validée, puis figée. Tout le reste du code reçoit un objet typé — jamais os.getenv("TIMEOUT") enfoui au fond d'une fonction.


Le cœur : BaseSettings

La mauvaise façon (à ne PLUS jamais faire)

python
# ❌ La façon "PHP 2010" — éparpillée, non typée, non validée
import os

DATABASE_URL = os.getenv("DATABASE_URL", "postgres://localhost/app")
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3"))   # ValueError si "abc" en prod
DEBUG = os.getenv("DEBUG", "false") == "true"      # "True", "1", "yes" → cassés
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") # peut être None → crash à 3h du matin

Pourquoi c'est piégeux :

  • Aucune validation. MAX_RETRIES=abc ne casse pas au boot, mais au premier appel — souvent en prod, dans un chemin de code rare.
  • Booléens fragiles. "false" est truthy en Python ; ton == "true" rate "1", "yes", "True".
  • Pas de source unique. Les clés sont disséminées, impossible de savoir ce dont l'app a besoin.
  • None silencieux. Une clé manquante donne None, qui se propage jusqu'à un TypeError opaque dix couches plus loin.

La façon idiomatique (Python 3.12 + Pydantic v2)

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

from pydantic import Field, PostgresDsn, SecretStr, ValidationInfo, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="APP_",            # APP_DB_URL → db_url
        env_file=".env",              # chargé en dev ; absent en prod = OK
        env_file_encoding="utf-8",
        case_sensitive=False,         # APP_DB_URL == app_db_url
        extra="forbid",               # une clé inconnue dans .env = erreur (anti-typo)
        frozen=True,                  # settings immuables après chargement
    )

    # --- App ---
    env: Literal["local", "staging", "production"] = "local"
    debug: bool = False
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"

    # --- Base de données ---
    db_url: PostgresDsn                      # pas de défaut → REQUIS, fail fast
    db_pool_size: int = Field(default=10, ge=1, le=100)

    # --- Agent / Anthropic ---
    anthropic_api_key: SecretStr             # SecretStr : pas loggé en clair
    anthropic_model: str = "claude-opus-4-8"
    anthropic_effort: Literal["low", "medium", "high", "xhigh", "max"] = "high"
    anthropic_max_tokens: int = Field(default=4096, ge=1, le=128_000)
    request_timeout_s: float = Field(default=600.0, gt=0)

    @field_validator("debug")
    @classmethod
    def no_debug_in_prod(cls, v: bool, info: ValidationInfo) -> bool:
        # garde-fou : DEBUG=true interdit en production
        if v and info.data.get("env") == "production":
            raise ValueError("debug must be False in production")
        return v


@lru_cache
def get_settings() -> Settings:
    """Singleton : construit et validé une seule fois par process."""
    return Settings()  # type: ignore[call-arg]  # les champs viennent de l'env

Ce que tu gagnes immédiatement :

  • Fail fast typé. db_url sans valeur → ValidationError explicite au démarrage, avec le nom du champ. L'app refuse de booter plutôt que de mourir en prod.
  • Coercition correcte. APP_DEBUG=1, true, yes, onTrue ; Pydantic gère le parsing booléen pour toi.
  • Bornes. Field(ge=1, le=100) rejette db_pool_size=0 ou 999.
  • Secrets protégés. SecretStr empêche la fuite de la clé dans un log ou un repr() (str(settings) affiche **********).
  • Source unique. La classe Settings est la documentation de tout ce que l'app consomme.

Le .env correspondant (jamais commité — voir .gitignore) :

bash
# .env  (dev local uniquement)
APP_ENV=local
APP_DEBUG=true
APP_DB_URL=postgresql://user:pass@localhost:5432/app
APP_ANTHROPIC_API_KEY=sk-ant-...
APP_ANTHROPIC_EFFORT=medium

⚠️ Le .env est pour le dev, pas pour la prod. En prod (Kubernetes, ECS, Cloud Run…), les variables viennent de l'environnement réel ou d'un secret manager — pas d'un fichier dans l'image. Le env_file étant optionnel, le même code marche dans les deux mondes : si .env n'existe pas, pydantic-settings se rabat sur l'environnement.


Champs imbriqués et types composés

Pour une vraie app tu auras des groupes logiques. Pydantic v2 gère l'imbrication via un délimiteur :

python
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict


class RedisSettings(BaseModel):
    host: str = "localhost"
    port: int = 6379
    db: int = 0


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="APP_",
        env_nested_delimiter="__",   # APP_REDIS__HOST → redis.host
    )
    redis: RedisSettings = RedisSettings()
    cors_origins: list[str] = []     # APP_CORS_ORIGINS='["http://a","http://b"]' (JSON)
bash
APP_REDIS__HOST=cache.internal
APP_REDIS__PORT=6380
APP_CORS_ORIGINS=["https://app.example.com","https://admin.example.com"]

Les list / dict sont parsés en JSON depuis l'environnement — c'est le piège n°1 des nouveaux venus (APP_CORS_ORIGINS=a,b,c ne marche pas par défaut ; il faut du JSON, ou un validateur custom qui split(",")).


Intégration FastAPI (DI + singleton)

Le réflexe est de Depends(get_settings). Grâce au @lru_cache, get_settings() ne construit l'objet qu'une fois ; tous les handlers reçoivent la même instance.

python
# main.py
from typing import Annotated

from fastapi import Depends, FastAPI

from .config import Settings, get_settings

app = FastAPI()

# Alias typé réutilisable — propre et DRY
SettingsDep = Annotated[Settings, Depends(get_settings)]


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

Pourquoi le singleton caché plutôt qu'un import global ?

Tu pourrais faire settings = Settings() au niveau module et l'importer partout. C'est tentant mais ça a deux défauts :

  1. Effet de bord à l'import. L'objet est construit (et peut lever une ValidationError) dès que n'importe quoi importe le module — y compris pytest pendant la collecte des tests, avant que tu aies pu monkeypatcher l'environnement.
  2. Intestable. Tu ne peux pas substituer une config de test sans bidouiller l'import.

Avec Depends(get_settings), tu override proprement en test :

python
# conftest.py
from app.config import Settings, get_settings
from app.main import app


def _test_settings() -> Settings:
    return Settings(
        env="local",
        db_url="postgresql://localhost/test",
        anthropic_api_key="sk-ant-test",
        anthropic_effort="low",
    )


app.dependency_overrides[get_settings] = _test_settings

Aucun environnement à patcher, aucune fuite entre tests. C'est l'équivalent FastAPI de l'override de provider dans un Test.createTestingModule() de NestJS.


Servir un agent Anthropic configuré

C'est là que la config devient critique : ta clé API, ton choix de modèle, l'effort et les timeouts ne doivent jamais être en dur. On construit le client AsyncAnthropic une fois, on le partage via le lifespan, et on lit tous ses paramètres depuis Settings.

python
# agent.py
from contextlib import asynccontextmanager
from typing import Annotated, AsyncIterator

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

from .config import Settings, get_settings


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    settings = get_settings()
    # Le SDK lit ANTHROPIC_API_KEY tout seul, mais on injecte explicitement
    # depuis Settings pour garder UNE seule source de vérité.
    app.state.anthropic = AsyncAnthropic(
        api_key=settings.anthropic_api_key.get_secret_value(),
        timeout=settings.request_timeout_s,
        max_retries=4,  # le SDK retry 429/5xx en backoff exponentiel
    )
    yield
    await app.state.anthropic.close()


app = FastAPI(lifespan=lifespan)
SettingsDep = Annotated[Settings, Depends(get_settings)]


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


ClientDep = Annotated[AsyncAnthropic, Depends(get_client)]

Streaming des tokens (SSE) piloté par la config

python
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

router = APIRouter()


class ChatIn(BaseModel):
    prompt: str


@router.post("/chat/stream")
async def chat_stream(
    body: ChatIn, client: ClientDep, settings: SettingsDep
) -> StreamingResponse:
    async def token_gen():
        # adaptive thinking + effort lus depuis la config — JAMAIS en dur
        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": body.prompt}],
        ) as stream:
            async for text in stream.text_stream:
                yield f"data: {text}\n\n"
        yield "data: [DONE]\n\n"

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

Le bénéfice config-first est concret : pour passer ton agent de claude-opus-4-8 à claude-haiku-4-5 en staging (moins cher, plus rapide pour les tests E2E), tu changes une variable d'environnementAPP_ANTHROPIC_MODEL=claude-haiku-4-5 — sans toucher au code ni redéployer une image différente. Idem pour APP_ANTHROPIC_EFFORT=low quand tu veux réduire les coûts sur un environnement de démo.

Structured outputs et tool-use : mêmes leviers

Le pattern reste identique pour les sorties structurées (messages.parse()) ou une boucle tool-use. Tout ce qui varie par environnement — modèle, effort, max_tokens, timeout — vient de Settings :

python
from pydantic import BaseModel


class Ticket(BaseModel):
    title: str
    severity: Literal["low", "medium", "high"]


async def classify(text: str, client: ClientDep, settings: SettingsDep) -> Ticket:
    # output_format (le raccourci) et output_config sont mutuellement exclusifs ;
    # pour combiner schéma + effort, on passe les deux dans output_config.
    msg = await client.messages.parse(
        model=settings.anthropic_model,
        max_tokens=settings.anthropic_max_tokens,
        output_config={
            "format": {"type": "json_schema", "schema": Ticket.model_json_schema()},
            "effort": settings.anthropic_effort,
        },
        messages=[{"role": "user", "content": text}],
    )
    return msg.parsed_output

⚙️ En production

Failure modes (et comment les désamorcer)

Mode de défaillanceCauseDésamorçage
App boote, crash 3h plus tardclé lue avec os.getenv sans validationdb_url: PostgresDsn sans défaut → fail fast au boot
.env de dev déployé en prodfichier dans l'image Docker.dockerignore + variables d'env réelles ; le .env reste optionnel
Typo APP_ANTROPIC_KEY ignoréeclé surnuméraire silencieuseextra="forbid" rejette les clés inconnues
Clé API dans les logslogger.info(settings)SecretStr masque (**********) ; ne jamais .get_secret_value() dans un log
Liste mal parséeAPP_CORS=a,b au lieu de JSONformat JSON, ou validateur split(",") documenté
Config divergente entre workerschaque worker relit l'envOK si l'env est identique ; un seul secret manager comme source

Performance

  • @lru_cache est obligatoire, pas cosmétique. Sans lui, Settings() reparse .env et l'environnement à chaque Depends — du disk I/O par requête. Avec, c'est zéro coût après le premier appel.
  • frozen=True : settings immuables. Évite les bugs « quelqu'un a muté settings.debug au runtime ». La config doit être figée après le boot.

Sécurité

  • Jamais de secret en dur, jamais commité. Le .env va dans .gitignore. Commits un .env.example sans valeurs comme documentation.
  • SecretStr partout pour clés API, mots de passe, tokens. get_secret_value() uniquement au point d'usage (construction du client), jamais dans un log ou une réponse HTTP.
  • Secrets ≠ config. Une URL de DB est de la config ; le mot de passe dedans est un secret. En prod, sors les secrets vers Vault / AWS Secrets Manager / Docker secrets (secrets_dir de pydantic-settings lit /run/secrets/*).
  • Validation = surface d'attaque réduite. Literal["local","staging","production"] rejette une valeur d'env injectée arbitraire.

Observabilité

  • Logue la config au démarrage, masquée. Au boot, émets un log INFO avec settings.model_dump()SecretStr y apparaît masqué — pour savoir exactement avec quelle config le process tourne. Inestimable pour débugger « ça marche en staging pas en prod ».
  • Expose env et model dans /health (comme plus haut) pour vérifier d'un coup d'œil quel environnement et quel modèle servent.

Les tradeoffs du senior

  • extra="forbid" vs extra="ignore". forbid attrape les typos mais casse si une plateforme injecte des vars système (PORT, PATH…). La parade : un env_prefix (APP_) qui isole tes vars du bruit système — alors forbid ne voit que tes clés.
  • Défaut vs requis. Un défaut rend une clé optionnelle… et masque une mauvaise config. Règle : les secrets et les URLs externes n'ont jamais de défaut (fail fast) ; les réglages de tuning (pool size, timeouts) peuvent en avoir un sensé.
  • Un gros Settings vs plusieurs. En monolithe, un seul Settings suffit. Si tu as plusieurs services/workers, sous-classe par contexte (WebSettings, WorkerSettings) partageant une base — chacun ne valide que ce qu'il consomme.

🏋️ Exercices

1. (implement) Le Settings qui fail fast

Objectif. Écris un Settings avec : env (Literal), db_url (PostgresDsn, requis), anthropic_api_key (SecretStr, requis), anthropic_effort (Literal[...], défaut "high"), port (int, 1..65535, défaut 8000). Démontre que lancer le process sans APP_DB_URL lève une ValidationError claire au boot, et qu'un repr ne révèle pas la clé.

Indice/Solution. env_prefix="APP_", pas de défaut sur db_url ni anthropic_api_key. Test : with pytest.raises(ValidationError): Settings(_env_file=None) après avoir vidé l'env. Vérifie "sk-ant" not in repr(settings) et str(settings.anthropic_api_key) == "**********".

2. (implement) Le validateur de liste tolérant

Objectif. Ajoute cors_origins: list[str]. Fais en sorte qu'il accepte et le JSON (["a","b"]) et la forme CSV (a,b,c), pour ne pas piéger les ops.

Indice/Solution. @field_validator("cors_origins", mode="before") : si isinstance(v, str) et que v ne commence pas par [, retourne [s.strip() for s in v.split(",") if s.strip()]. Sinon laisse Pydantic parser le JSON.

3. (production-grade) DI + override en test

Objectif. Branche get_settings (caché) sur un endpoint FastAPI. Écris un test qui override get_settings pour pointer vers une config de test, sans toucher aux variables d'environnement réelles, et vérifie que /health renvoie env="local".

Indice/Solution. app.dependency_overrides[get_settings] = lambda: Settings(env="local", db_url=..., anthropic_api_key="sk-ant-test"). Pense à get_settings.cache_clear() entre tests si tu modifies l'env, et nettoie app.dependency_overrides.clear() en teardown.

4. (production-grade) Secrets séparés de la config

Objectif. Configure Settings pour lire les secrets depuis /run/secrets/ (Docker secrets) en plus de l'environnement, avec la bonne priorité (secret > env > .env > défaut). Démontre qu'un fichier /run/secrets/app_anthropic_api_key est bien pris en compte.

Indice/Solution. SettingsConfigDict(secrets_dir="/run/secrets"). Pour personnaliser la priorité, override settings_customise_sources(cls, ...) et réordonne le tuple (init, env, dotenv, file_secret). En test, pointe secrets_dir vers un tmp_path.

5. (break-then-fix) Le piège du singleton à l'import

Objectif. Pars d'un settings = Settings() au niveau module. Constate qu'un import dans conftest.py lève une ValidationError pendant la collecte des tests (avant tout monkeypatch). Répare en passant à @lru_cache def get_settings().

Indice/Solution. Le bug : l'objet est construit à l'import, donc avant que pytest ait pu poser les env vars. Le fix transforme la construction en appel paresseux — rien n'est validé tant que get_settings() n'est pas effectivement appelé, ce que tu contrôles via la DI/override.

6. (break-then-fix) Le secret qui fuite dans les logs

Objectif. Logue logger.info("config: %s", settings.model_dump()) et observe si la clé fuite. Mets en place une config où même model_dump() masque le secret, mais où le code de construction du client y accède correctement.

Indice/Solution. Avec SecretStr, model_dump() renvoie l'objet masqué (SecretStr('**********')) — sûr à logguer. Mais client = AsyncAnthropic(api_key=settings.anthropic_api_key) passerait l'objet SecretStr, pas la string ! Le fix : .get_secret_value() uniquement à la construction du client, jamais ailleurs. Ajoute un test qui scanne la sortie de log et assert que "sk-ant" n'y apparaît pas.


🎤 En entretien

Q : Pourquoi pydantic-settings plutôt que os.getenv + python-dotenv ? R : Parce que ça valide et type au démarrage (fail fast) au lieu de propager des strings ou des None jusqu'à un crash en prod ; une classe Settings est aussi la source unique et documentée de toute la config consommée.

Q : Comment tu gères les secrets en prod par rapport au dev ? R : En dev, .env (gitignoré) ; en prod, variables d'environnement réelles ou un secret manager (Vault / AWS Secrets Manager / Docker secrets_dir), jamais un fichier dans l'image — SecretStr empêche la fuite dans les logs, et get_secret_value() n'est appelé qu'au point d'usage.

Q : Pourquoi @lru_cache sur get_settings() et pas un global ? R : Le cache donne un singleton paresseux — construit une fois, donc pas de re-parsing du .env à chaque Depends (perf) et pas d'effet de bord à l'import (testabilité) ; et via dependency_overrides, je substitue une config de test sans patcher l'environnement.

Q : Tu sers un agent LLM — où mettre le choix du modèle et l'effort ? R : Dans Settings, lus depuis l'env (APP_ANTHROPIC_MODEL, APP_ANTHROPIC_EFFORT), injectés via la DI — ainsi je passe d'opus-4-8 en prod à haiku-4-5 en staging par une variable d'environnement, sans changer le code ni l'image Docker, et le timeout/max_tokens suivent le même chemin.

Bibliothèque tech perso — Achref