Представьте: вы выпустили API, клиенты его используют, и вам нужно поменять формат ответа. Если просто изменить — все клиенты сломаются. Версионирование решает эту проблему: старые клиенты работают с v1, новые получают v2.
Версия в URL-пути
Самый распространённый способ — включить версию прямо в адрес:
/api/v1/orders
/api/v2/orders
В FastAPI это делается через APIRouter с префиксом:
from fastapi import FastAPI, APIRouter
v1_router = APIRouter(prefix="/api/v1")
v2_router = APIRouter(prefix="/api/v2")
app = FastAPI(redirect_slashes=False)
app.include_router(v1_router)
app.include_router(v2_router)
Несколько правил, которые помогают избежать путаницы:
- Версия — только целое число:
v1,v2,v3. Неv1.2, не2026. - Перед версией всегда
/api. Путь/v1/ordersбез/api— частая ошибка. - Версия — в пути, не в query-параметрах.
?version=1— плохой вариант: он ломает кеширование на шлюзах и усложняет маршрутизацию. - Версия — не в заголовке (
Accept-Version). Заголовки труднее тестировать и отлаживать.
Что выглядит правильно, а что нет:
| Путь | Годится | Почему |
|---|---|---|
/api/v1/orders | да | целая версия сразу после /api |
/api/v2/orders | да | следующая версия выглядит так же |
/api/v1.2/orders | нет | минорная версия не нужна |
/api/2026/orders | нет | дата вместо версии |
/orders | нет | нет ни /api, ни версии |
/v1/orders | нет | нет /api |
Breaking и non-breaking изменения
Не каждое изменение API ломает клиентов. Важно различать два вида:
Non-breaking (безопасные) — клиенты продолжают работать без изменений:
- добавить необязательное поле в ответ;
- добавить необязательный query-параметр;
- добавить новый эндпоинт;
- добавить новое значение в перечисление (StrEnum);
- ослабить валидацию (увеличить максимальную длину строки).
Breaking (ломающие) — требуют новую версию, потому что клиент перестанет работать:
- удалить или переименовать поле (
order_id→id); - изменить тип поля (
str→int); - сделать параметр обязательным там, где он был необязательным;
- удалить эндпоинт или изменить его HTTP-метод;
- изменить URL-путь (
/orders→/sales-orders); - удалить значение из перечисления;
- ужесточить валидацию (уменьшить максимальную длину).
Главное правило: новая версия только при breaking change. Не нужно создавать v2 ради нового необязательного поля — добавьте его в текущую версию.
Forward compatibility: клиент игнорирует неизвестное
Чтобы non-breaking изменения действительно были безопасными, клиенты должны спокойно воспринимать поля, которых они не ожидали. FastAPI + Pydantic v2 делает это по умолчанию при десериализации.
Добавление необязательного поля в ответ — безопасная операция:
from pydantic import BaseModel
class OrderResponse(BaseModel):
order_id: str
status: str
channel: str | None = None # новое поле — безопасно добавить в текущую версию
Старые клиенты, которые не знают про channel, просто проигнорируют его.
То же правило работает для StrEnum. Добавление нового значения — безопасно, если клиент обрабатывает неизвестные значения:
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
# CANCELLED = "CANCELLED" ← добавить — безопасно
Обратная ситуация: удаление значения из StrEnum — breaking change, потому что клиент, который ожидал это значение, получит что-то неожиданное.
Держать v1 и v2 одновременно
Когда breaking change всё же нужен, v1 не отключают сразу — оба роутера работают параллельно. Под капотом — одна бизнес-логика, разные Pydantic-схемы:
Две версии расходятся только в роутере и схеме ответа; обработчик и доменная модель общие, иначе логика двоится.
from fastapi import APIRouter, Depends
from . import schemas_v1, schemas_v2
from .use_cases import OrderUseCases
v1_router = APIRouter(prefix="/api/v1/orders")
v2_router = APIRouter(prefix="/api/v2/orders")
@v1_router.get("/{order_id}", response_model=schemas_v1.OrderResponse)
async def get_order_v1(order_id: str, use_cases: OrderUseCases = Depends()):
order = await use_cases.get_order(order_id)
return schemas_v1.OrderResponse.model_validate(order)
@v2_router.get("/{order_id}", response_model=schemas_v2.OrderResponse)
async def get_order_v2(order_id: str, use_cases: OrderUseCases = Depends()):
order = await use_cases.get_order(order_id)
return schemas_v2.OrderResponse.model_validate(order)
Порядок действий при breaking change:
- Создать
schemas_v2.pyс новым контрактом. - Добавить
v2_routerс новыми маршрутами. v1_routerпродолжает работать без изменений — клиенты мигрируют постепенно.- Пометить
v1устаревшим через заголовкиDeprecationиSunset. - После даты отключения вернуть
410 Gone.
Глубже: как узнать, кто ещё сидит на старой версиирасширенное
Шаг «после того как клиенты перешли» невыполним, пока нет ответа на вопрос «а перешли?». Ответ даёт измерение.
Метрика по версии уже есть, если HTTP-метрики размечены шаблоном маршрута, как в статье про конфигурацию наблюдаемости: http_requests_total{path="/api/v1/orders"} против /api/v2/orders, и график доли v1 по неделям отвечает на вопрос без опроса команд. Если потребители известны, к метрике добавляют метку клиента из токена (client_id, не пользователя), и становится видно, кто именно не переехал.
Заголовки в ответах v1: Deprecation: true и Sunset: <дата>; сгенерированные клиенты и шлюзы их читают, люди замечают в логах. Дату ставят сразу и держат слово.
Отключают не выключателем, а по порядку: предупреждение в ответах, письмо оставшимся клиентам по client_id, две недели нулевого трафика на v1, затем 410 Gone с указанием нового адреса. Удалять роутер из кода можно ещё позже, 410 стоит почти ничего.
Глубже: версионируется не только URLрасширенное
API не единственный контракт сервиса, и остальные ломаются так же.
События в очереди. Схема сообщения такой же контракт, и у него та же арифметика совместимости: добавить необязательное поле безопасно, переименовать или изменить смысл нет. Версию кладут в имя события или в заголовок: order.confirmed.v1 с классом OrderConfirmedV1, а при ломающем изменении рядом появляется v2, и потребители переезжают каждый в своём темпе. Подробно в статье про синхронизацию через события.
Схема базы. Во время выката несколько минут работают две версии кода с одной базой, и миграция должна быть совместима с предыдущей версией: сначала добавить колонку, потом научить код, потом убрать старую, о чём статья про завершение и персистентность.
Pydantic-модели ответов. Поле, которое клиент читает, нельзя убрать из OrderResponse «потому что в коде не используется»; за этим следит сравнение OpenAPI с прошлой версией в сборке (oasdiff breaking), а не ревью.
Коротко
- Версия всегда в URL-пути:
APIRouter(prefix="/api/v1"). Формат —v+ целое число. - Путь начинается с
/api./v1/ordersбез/api— ошибка. - Новую версию делают только при breaking change: удаление или переименование поля, изменение типа, удаление эндпоинта.
- Non-breaking изменения (необязательное поле, новый эндпоинт, новое значение enum) — добавляют в текущую версию без
v2. - Pydantic v2 по умолчанию игнорирует неизвестные поля при десериализации — это и есть forward compatibility.
- При breaking change v1 не отключают сразу: оба роутера работают параллельно, за ними одна бизнес-логика, разные схемы.
- Снятие v1 начинается с измерения: метрика по шаблону маршрута и клиенту плюс
Sunset; версия есть не только у URL — у событий в очереди и схемы базы та же арифметика совместимости.
Что почитать дальше
- URL и ресурсы — как строить пути, вложенность ресурсов, HTTP-методы на FastAPI.
- Ошибки RFC 9457 — расширение
ErrorCodeкак пример non-breaking изменения. - Rate limiting и deprecation — заголовки
Sunsetдля плавного отключения v1.