Опирается на правила:
R-VLD-CFG-1…R-VLD-CFG-4иR-VLD-CFG-X1…R-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 (FastAPIDepends). 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.environ—KeyErrorна первом отсутствующем поле. - Тип не проверяется.
"not-a-number"вPOOL_SIZE→ValueErrorв глубине кода при первом использовании. - Нет документации.
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-X2 | BaseSettings-поле без default |
Класс конфига без наследования от BaseSettings | R-VLD-CFG-X1 | class Settings(BaseSettings) |
Required-поле с default=None или пустой строкой | R-VLD-CFG-X1 | Без default — Pydantic потребует значение |
float для денежных лимитов в конфиге | R-VLD-STD-5 | Decimal + Field(gt=Decimal("0")) |
Nested-конфиг как dict, не как BaseSettings | R-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-*).