Опирается на правила: R-VLD-CFG-1R-VLD-CFG-4 и R-VLD-CFG-X1R-VLD-CFG-X2 из Validation Style Guide → раздел 7. Конфигурация.

Важно знать

  • pydantic-settings BaseSettings валидирует поля по типам на старте — невалидный конфиг бросает ValidationError до того, как приложение примет первый запрос.
  • Required-поля — без default. Pydantic требует значение из env, .env-файла или через model_config — не нашёл → падение на старте.
  • Типобезопасность встроена. PostgresDsn, AnyUrl, timedelta, int, bool — Pydantic парсит из строки среды и проверяет корректность. "not-a-url" в DATABASE_URL → ошибка, не None.
  • Nested-конфиг — вложенный BaseSettings/BaseModel в поле. Pydantic v2 валидирует рекурсивно автоматически; для env-маппинга — env_nested_delimiter.
  • os.environ["X"] для required-конфига — антипаттерн. Читает строку без проверки типа; KeyError вместо понятного fail-fast.
  • Optional-полеfield: str | None = None. Отсутствующая env-переменная даёт None, не ошибку. Код, использующий поле, проверяет на None явно.
  • Единственный экземпляр Settings создаётся на старте и пробрасывается через DI (FastAPI Depends). Pydantic не перечитывает среду при каждом запросе.

Конфиг — единственное место, где «упасть на старте» лучше, чем «работать с битыми значениями». Невалидный URL в Pydantic-DTO даёт 400 одному клиенту; невалидный DATABASE_URL в Settings ломает весь сервис до первой транзакции. BaseSettings ловит это до того, как Uvicorn принял соединение. Раскрытие раздела 7 гайда.

BaseSettings как единственный источник конфига

R-VLD-CFG-1, R-VLD-CFG-2: каждый класс конфига — BaseSettings, required-поля без default.

# backend/config/settings.py
from pydantic import PostgresDsn, AnyUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from datetime import timedelta


class SberClientSettings(BaseSettings):
    base_url: AnyUrl                                        # required, типобезопасно
    connect_timeout: timedelta = timedelta(seconds=2)
    read_timeout: timedelta = timedelta(seconds=10)
    max_concurrent: int = Field(default=20, ge=1, le=100)
    api_key: str | None = None                              # optional — None если не задан

    model_config = SettingsConfigDict(env_prefix="SBER_")


class Settings(BaseSettings):
    database_url: PostgresDsn                               # required, без default
    sber: SberClientSettings = SberClientSettings()        # nested (R-VLD-CFG-4)

    model_config = SettingsConfigDict(
        env_prefix="APP_",
        env_nested_delimiter="__",
    )

Что происходит при запуске без APP_DATABASE_URL в среде:

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
database_url
  Field required [type=missing, input_url=test_url, input_type=dict]

Uvicorn не поднимается. Healthcheck красный. Деплой не проходит. Это и есть fail-fast.

При опечатке SBER_BASE_URL=not-a-url:

pydantic_core._pydantic_core.ValidationError: 1 validation error for SberClientSettings
base_url
  URL input should be a valid URL, unable to parse raw url [type=url_parsing, ...]

Без BaseSettings та же опечатка дала бы None или строку, которую клиент обнаружит только при первом HTTP-запросе к Sber.

Типобезопасность: правильные типы для конфига

R-VLD-STD-5: проверка на правильном типе — числовой конфиг числовым типом, URL — AnyUrl/PostgresDsn, флаги — bool.

# backend/config/settings.py
from pydantic import PostgresDsn, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from decimal import Decimal


class PaymentSettings(BaseSettings):
    provider_url: AnyUrl                                    # URL, не str
    secret_key: str                                         # строка-секрет, required
    max_amount: Decimal = Field(default=Decimal("100000.00"), gt=Decimal("0"))
    retry_attempts: int = Field(default=3, ge=1, le=10)
    enable_3ds: bool = True

    model_config = SettingsConfigDict(env_prefix="PAYMENT_")
# .env (или переменные окружения)
PAYMENT_PROVIDER_URL=https://api.payment.sber.ru/v2
PAYMENT_SECRET_KEY=sk_live_...
PAYMENT_MAX_AMOUNT=500000.00
PAYMENT_RETRY_ATTEMPTS=5
PAYMENT_ENABLE_3DS=true

timedelta парсится из строк формата ISO 8601 (PT10S) или секунд (10.0). bool — из "true"/"false"/"1"/"0". Pydantic обрабатывает конвертацию, код видит нужный тип без явного приведения.

Деньги — всегда Decimal, не float (R-VLD-STD-5). float не представляет 0.10 точно; в конфиге лимитов это не менее критично, чем в DTO.

Числовые пределы на старте

R-VLD-STD-2: Field(ge=, le=, gt=, lt=) на числовых полях конфига действует при валидации BaseSettings — ошибка на старте, не при первом использовании.

class OrderServiceSettings(BaseSettings):
    max_items_per_order: int = Field(default=50, ge=1, le=500)
    reservation_ttl_seconds: int = Field(default=900, ge=60, le=86400)
    batch_size: int = Field(default=100, ge=10, le=1000)

    model_config = SettingsConfigDict(env_prefix="ORDER_")
ORDER_MAX_ITEMS_PER_ORDER=0   # ValidationError на старте: ge=1
ORDER_BATCH_SIZE=5000          # ValidationError на старте: le=1000

Без Field(ge=1) значение 0 в max_items_per_order дошло бы до первого range(0) в бизнес-логике. Ошибка всплыла бы через час при первом заказе с нестандартным конфигом.

Nested-конфиг рекурсивно

R-VLD-CFG-4: вложенные модели валидируются автоматически. Для маппинга из среды — env_nested_delimiter.

class DatabaseSettings(BaseSettings):
    url: PostgresDsn                                        # required
    pool_size: int = Field(default=10, ge=1, le=200)
    pool_timeout: timedelta = timedelta(seconds=30)

    model_config = SettingsConfigDict(env_prefix="DB_")


class MessagingSettings(BaseSettings):
    bootstrap_servers: str                                  # required: "kafka:9092"
    consumer_group_id: str                                  # required
    poll_timeout: timedelta = timedelta(seconds=5)
    session_timeout: timedelta = timedelta(seconds=30)
    batch_size: int = Field(default=100, ge=1, le=1000)

    model_config = SettingsConfigDict(env_prefix="KAFKA_")


class Settings(BaseSettings):
    database: DatabaseSettings = DatabaseSettings()
    messaging: MessagingSettings = MessagingSettings()

    model_config = SettingsConfigDict(env_prefix="APP_")
# Переменные среды для вложенного конфига
DB_URL=postgresql+asyncpg://order_user:pass@db:5432/order
DB_POOL_SIZE=20
KAFKA_BOOTSTRAP_SERVERS=kafka-broker:9092
KAFKA_CONSUMER_GROUP_ID=order-service

Каждый BaseSettings считывает свои переменные по префиксу независимо. Если DB_URL отсутствует — DatabaseSettings бросает ошибку при создании Settings. Весь граф зависимостей конфига проверяется в момент инициализации приложения.

DI через Depends — один экземпляр Settings

# backend/config/dependencies.py
from functools import lru_cache
from .settings import Settings


@lru_cache
def get_settings() -> Settings:
    return Settings()
# backend/customer/router.py
from fastapi import APIRouter, Depends
from ..config.dependencies import get_settings, Settings

router = APIRouter()


@router.get("/v1/customers/{customer_id}/profile")
async def get_customer_profile(
    customer_id: UUID,
    settings: Settings = Depends(get_settings),
):
    timeout = settings.sber.read_timeout
    ...

@lru_cache на get_settings() гарантирует, что Settings() создаётся один раз. Повторная валидация не происходит. В тестах get_settings переопределяется через app.dependency_overrides.

os.environ напрямую — антипаттерн

R-VLD-CFG-X2: os.environ["X"] читает строку без парсинга типа и без валидации.

# ПЛОХО
import os

DATABASE_URL = os.environ["DATABASE_URL"]       # KeyError если нет; строка без проверки
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "10"))  # int() упадёт при "not-a-number", но без сообщения
MAX_AMOUNT = float(os.getenv("MAX_AMOUNT", "0"))  # float для денег — потеря точности

Что не так:

  • Нет единого отчёта. BaseSettings собирает все ошибки и выводит сразу; os.environKeyError на первом отсутствующем поле.
  • Тип не проверяется. "not-a-number" в POOL_SIZEValueError в глубине кода при первом использовании.
  • Нет документации. BaseSettings — это явная схема конфига сервиса. os.environ разбросан по модулям.
  • Сложно тестировать. Переопределение os.environ в тестах — хрупко; dependency_overrides + BaseSettings — стандарт.

Правильно — даже для одного поля:

# backend/config/settings.py
class ProductCatalogSettings(BaseSettings):
    search_index_url: AnyUrl                               # один required URL — всё равно BaseSettings

    model_config = SettingsConfigDict(env_prefix="CATALOG_")

Optional-поля: None как «не задано»

Поля, которые могут отсутствовать, получают = None в типе:

class SberClientSettings(BaseSettings):
    base_url: AnyUrl                                        # required
    connect_timeout: timedelta = timedelta(seconds=2)
    api_key_override: str | None = None                    # optional — из секрет-менеджера в проде
    proxy_url: AnyUrl | None = None                        # optional — только в некоторых окружениях

    model_config = SettingsConfigDict(env_prefix="SBER_")

Код, использующий optional-поле, проверяет явно:

effective_api_key = settings.sber.api_key_override or vault_client.get_secret("sber/api-key")

None — валидное значение «не задано». Optional[str] не используем — это семантика возвращаемого значения, не поля конфига. str | None = None достаточно и читается прямо.

Что запрещено

АнтипаттернПравилоЧто взамен
os.environ["X"] или os.getenv("X") для required-конфигаR-VLD-CFG-X2BaseSettings-поле без default
Класс конфига без наследования от BaseSettingsR-VLD-CFG-X1class Settings(BaseSettings)
Required-поле с default=None или пустой строкойR-VLD-CFG-X1Без default — Pydantic потребует значение
float для денежных лимитов в конфигеR-VLD-STD-5Decimal + Field(gt=Decimal("0"))
Nested-конфиг как dict, не как BaseSettingsR-VLD-CFG-4Вложенный BaseSettings или BaseModel
Settings() создаётся при каждом запросе@lru_cache на get_settings()
Optional[str] для optional-поля конфигаstr \| None = None

Куда дальше

  • python/where-to-validate.md — три места валидации в FastAPI-сервисе: DTO, конфиг, агрегат.
  • python/standard-constraints.md — Field(ge=, le=), EmailStr, UUID, Decimal — что использовать на каких типах.
  • python/custom-constraints.md — Annotated[T, AfterValidator(fn)] для переиспользуемых валидаторов.
  • python/cross-field-validation.md — @model_validator(mode="after") для правил с несколькими полями.
  • python/messages-and-i18n.md — тексты ошибок на русском, интерполяция значений.
  • Resilience Style Guide — таймауты в *ClientSettings как бизнес-инварианты (R-RES-*).