Pydantic пропустил неверное значение по умолчанию: включаем validate_default

В конфигурации сервиса размер страницы ограничен положительным числом. Клиент присылает ноль — API отвечает ошибкой. Но после изменения значения по умолчанию на ноль тот же сервис спокойно запускается. Кажется, что ограничение работает через раз. На самом деле модель проверяет два разных пути получения значения: явный ввод и подстановку из объявления поля.

Почему привычный тест проходит

Проверка модели обычно начинается с правильного и неправильного словаря. Такой тест полезен, но не создаёт модель без аргументов. Между тем именно так часто появляется конфигурация: разработчик рассчитывает на готовые значения, а окружение переопределяет лишь часть параметров. Ошибка в оставшемся поле обнаружится только в вычислении размера страницы или при обращении к базе.

В Pydantic v2 значения по умолчанию обычной BaseModel по умолчанию не валидируются. Аннотация и ограничение Field описывают принимаемый ввод, однако само объявленное значение требует отдельной настройки validate_default. Её можно поставить на одно поле или включить через ConfigDict для модели. Для конфигурации, в которой любое неверное значение мешает запуску, общий вариант проще проверять при изменениях.

Создадим конфигурацию до приёма запросов

Пример сначала воспроизводит расхождение на маленькой модели, затем использует исправленную модель в lifespan FastAPI. Нужны Python 3.11+, FastAPI, Pydantic v2 и HTTPX. Сохраните весь блок в файл и запустите Python. Ресурсом приложения здесь является проверенная конфигурация: запрос получает уже готовое значение, а не пересоздаёт настройки на каждом обращении.

Значение "20" выбрано намеренно. При включённой проверке оно проходит обычное преобразование Pydantic и становится целым числом. Ноль нарушает границу независимо от того, передан он явно или записан в объявлении. Так проверяется и тип итогового объекта, и содержательное ограничение поля.

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field, ValidationError

class Unchecked(BaseModel):
    page_size: int = Field(default=0, gt=0)

assert Unchecked().page_size == 0
try:
    Unchecked(page_size=0)
except ValidationError:
    pass
else:
    raise AssertionError("explicit zero must fail")

class Config(BaseModel):
    model_config = ConfigDict(validate_default=True)
    page_size: int = Field(default="20", gt=0)

class Broken(Config):
    page_size: int = Field(default=0, gt=0)

try:
    Broken()
except ValidationError as exc:
    assert exc.errors()[0]["loc"] == ("page_size",)
else:
    raise AssertionError("default zero must fail")

@asynccontextmanager
async def lifespan(app):
    app.state.config = Config()
    yield

app = FastAPI(lifespan=lifespan)
@app.get("/config")
def config(request: Request):
    size = request.app.state.config.page_size
    return {"page_size": size, "is_int": isinstance(size, int)}

with TestClient(app) as client:
    result = client.get("/config")
    assert result.json() == {"page_size": 20, "is_int": True}
print(result.json())

Что проверять при развитии настроек

У результата две полезные характеристики: page_size равен 20 и is_int равен true. Если убрать validate_default, строка из объявления останется строкой. Это легко пропустить, когда endpoint только сериализует значение: JSON примет и число, и текст. Ошибка проявится позже, например при сложении смещения или сравнении лимитов. Поэтому тестируетcя именно тип внутренней настройки, а не один успешный статус.

Для обязательного секрета лучше вообще не задавать учебную заглушку. Тогда отсутствие значения станет отдельной ошибкой конфигурации. validate_default помогает проверить подставленное значение, но не определяет, какие параметры допустимо оставлять необязательными. Сначала решите, может ли сервис выполнять полезную работу без параметра, затем выразите это в модели.

Если настройка загружается из нескольких источников, добавьте тесты для каждого реально используемого пути. BaseModel и специализированные настройки pydantic-settings имеют собственные значения конфигурации по умолчанию; переносить наблюдение из этого примера на любой класс настроек без проверки нельзя. В проекте оставьте тест пустого конструктора и тест явного переопределения: они фиксируют оба входа.

Создание модели в lifespan помогает остановить запуск при неверной конфигурации. Не перехватывайте ValidationError ради того, чтобы продолжить с прежним объектом: процесс тогда выглядит готовым, хотя часть параметров уже не соответствует развёртыванию. В журнал достаточно вынести имя поля и причину; секретные значения из исходного словаря туда не нужны.

При рефакторинге модели обратите внимание на default_factory. Фабрика тоже создаёт значение по умолчанию, и её результат стоит проверять, когда она вычисляет лимит или срок. Настройка проверки полезна именно в момент создания объекта: после него остальной код получает нормализованный тип и может работать с ним без повторения одних условий в каждом endpoint.

При изменении дефолта проверяйте сценарий без внешнего переопределения. Именно он чаще всего теряется в тестах, где fixture всегда передаёт все параметры. Отдельный пустой конструктор стоит почти ничего, зато обнаруживает неверное объявление поля сразу после редактирования модели.

Следующая практика

Создайте настройки при запуске, затем используйте их в маршруте. В бесплатном курсе FastAPI вы соедините модели, зависимости и HTTP-тесты в работающий API. Перейдите к бесплатному курсу FastAPI.

Продолжить на практике