Как минорный релиз пакета сломал прод: разбор инцидента
Библиотека вышла с версией 2.4.1, CI собрался зелёным, а через час прод начал падать с TypeError. Разбираем, где обратная совместимость дала трещину и как этого избежать.

Пятница, 16:40. Мониторинг присылает всплеск 500-х на сервисе оплаты: за десять минут доля ошибок выросла с 0,1% до 12%. В логах — TypeError: __init__() got an unexpected keyword argument 'timeout'. Никто ничего не деплоил. Последний коммит в сервисе был два дня назад. Виноватой оказалась зависимость: внутренний пакет payments-core вышел в версии 2.4.1, и её подтянул pip install при пересборке образа в автоскейлинге. Минорный, по SemVer безопасный релиз сломал вызов, который работал три года.
Ниже — разбор того, как это произошло, почему CI не поймал поломку заранее и что мы поменяли в правилах выпуска пакета. Речь про Python 3.11 и пакет, распространяемый через приватный индекс; версии — по SemVer.
Что именно сломалось
В версии 2.3.x конструктор клиента выглядел так:
class PaymentClient:
def __init__(self, api_url, retries=3):
self.api_url = api_url
self.retries = retriesСервисы по всей компании вызывали его позиционно и с одним именованным аргументом:
client = PaymentClient("https://pay.internal", timeout=5)Стоп. Параметра timeout в 2.3.x не было вовсе. Как это работало? А оно и не работало как задумано — но и не падало. В 2.3.x конструктор принимал **kwargs, который молча проглатывал лишние аргументы:
class PaymentClient:
def __init__(self, api_url, retries=3, **kwargs):
self.api_url = api_url
self.retries = retries
# kwargs игнорировались — включая timeoutТо есть timeout=5 три года никак не влиял на поведение, но и не вызывал ошибку. В релизе 2.4.1 автор убрал **kwargs, посчитав это чисткой кода, и добавил явный timeout — но назвал его request_timeout. Итог: старый вызов с timeout=5 получил TypeError, потому что глотать лишнее стало некому.
Обратная совместимость — это не про то, что код продолжает делать правильное. Это про то, что вызовы, которые компилировались и запускались раньше, продолжают запускаться. Даже кривые.
Почему это минор, а не мажор
Автор рассуждал так: «я не менял публичный контракт, timeout нигде не документирован, значит удаление **kwargs ничего не ломает». Ошибка в слове «документирован». Контракт пакета — это не то, что написано в README. Контракт — это множество вызовов, которые пакет принимал без ошибки. Как только **kwargs проглатывал произвольные ключи, любой такой ключ де-факто стал частью совместимости, нравится это автору или нет.
Почему CI пропустил поломку
Тесты самого пакета payments-core были зелёными: в них клиент нигде не создавался с параметром timeout. Ломающий вызов жил в другом репозитории. CI пакета проверял пакет в вакууме, а не то, как его используют потребители.
Второй фактор — незакреплённая версия. В сервисе оплаты зависимость была указана как payments-core>=2.3. При пересборке образа pip взял самый свежий подходящий релиз — 2.4.1. Между «код прошёл ревью» и «код в проде» вклинилась третья сторона, которую в PR никто не видел.
Три дырки в процессе, каждая достаточная для инцидента
- Тесты пакета не знали о потребителях. Поломка контракта видна только на стыке пакет + сервис, а этот стык нигде не собирался.
- Диапазон версий вместо lock-файла.
>=2.3означает «доверяю всем будущим релизам вслепую». - Молчаливый
**kwargs. Он превратил опечатку в аргументе в тихую совместимость на три года. Убрать его было правильно — но это ломающее изменение, а не чистка.
Как ловить такие поломки заранее
Первое, что мы сделали, — научились видеть изменение публичной сигнатуры глазами инструмента, а не ревьюера. Для этого подходит проверка публичного API между двумя версиями. Точные имена и флаги таких инструментов сверяйте с их документацией — здесь важен принцип, а не конкретная команда: инструмент строит слепок публичных сигнатур на старой версии и сравнивает с новой, и удаление **kwargs или переименование параметра он пометит как ломающее.
Второе — контрактные тесты со стороны потребителя. Самый дешёвый вариант: в CI пакета запускать тесты хотя бы одного ключевого сервиса против собираемой версии пакета. Тогда вызов с timeout=5 упал бы на этапе PR, а не в проде в пятницу вечером.
Третье — дисциплина закрепления версий. Диапазон >= в проде — это отложенный инцидент. Ниже — сравнение стратегий.
| Стратегия | Что выигрываете | Чем платите |
|---|---|---|
payments-core>=2.3 | Автоматические обновления, меньше рутины | Любой релиз третьей стороны попадает в прод без ревью |
payments-core==2.3.4 | Полный контроль, воспроизводимая сборка | Обновления вручную, риск отстать по безопасности |
~=2.3.4 (только патчи) | Патчи безопасности без минорных сюрпризов | Полагается на честность SemVer у автора пакета |
| Диапазон + lock-файл | Гибкость в проекте, детерминизм в сборке | Нужно поддерживать lock и обновлять осознанно |
Где граница совместимости проходит на самом деле
Разбор показал неочевидную вещь: у пакета есть зона, которую авторы не считают контрактом, но потребители — считают. Несколько граничных случаев, где это выстреливает.
- Порядок позиционных аргументов. Добавили новый параметр в середину — все позиционные вызовы поехали. Новые параметры — только в конец, а лучше keyword-only через
*. - Тип возвращаемого значения. Раньше метод возвращал
dict, стал возвращать датакласс. Формально удобнее, ноresult["amount"]у всех потребителей теперь падает. - Исключения. Класс исключения — тоже часть контракта. Если
except ValueErrorловил ошибку, а вы стали кидать свойPaymentError(Exception), обработчики перестанут срабатывать. - Побочные значения по умолчанию. Как в нашем случае с
**kwargs: молчаливое поведение, на которое кто-то полагается, — это совместимость, даже если оно нигде не описано.
Что стоит закрепить в правилах выпуска
После инцидента мы записали короткий регламент для авторов внутренних пакетов:
- Удаление или переименование любого публичного имени — мажорный релиз. Всегда.
- Новые параметры добавляются только keyword-only и с дефолтом.
- Убираемый параметр сначала помечается
DeprecationWarningминимум на один минорный цикл, и только потом удаляется в мажоре. - Перед публикацией — прогон контрактных тестов ключевых потребителей.
**kwargsв публичном конструкторе запрещён: либо параметр явный, либо его нет.
Депрекация выглядит так — вызов продолжает работать, но предупреждает:
import warnings
class PaymentClient:
def __init__(self, api_url, retries=3, *, request_timeout=5, timeout=None):
if timeout is not None:
warnings.warn(
"Параметр timeout переименован в request_timeout "
"и будет удалён в 3.0",
DeprecationWarning,
stacklevel=2,
)
request_timeout = timeout
self.api_url = api_url
self.retries = retries
self.request_timeout = request_timeoutТакой мост стоит десяти строк и одного минорного цикла, а спасает от пятничного инцидента у каждого потребителя сразу.
Что делать дальше
Начните с самого дешёвого шага: закрепите версии зависимостей lock-файлом и уберите открытые >= из прод-сборок — это закрывает целый класс инцидентов без единой строки в самом пакете. Дальше добавьте в CI пакета прогон тестов хотя бы одного крупного потребителя. И встройте проверку публичной сигнатуры между версиями: она ловит именно тот класс ошибок, который ревьюер пропускает, потому что смотрит на диф, а не на контракт. Метрика, за которой стоит следить в проде после релиза зависимостей, — доля 5xx и новые типы исключений в логах в первые 30 минут после выкатки.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Semantic Versioning 2.0.0, Python docs — warnings (DeprecationWarning)
Частые вопросы
А если у меня не приватный пакет, а обычная зависимость с PyPI — правила те же?
Почему не заблокировать все версии жёстко через == и не думать об этом?
Мы удалили **kwargs и ничего не сломалось в тестах. Значит, всё в порядке?
Инструмент сравнения API показал изменение, но я уверен, что это не ломает. Игнорировать?
Депрекация через DeprecationWarning — её вообще кто-то видит? Предупреждения часто скрыты.
Как понять, минорный у меня релиз или мажорный, если я не уверен?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.