arm-core/api/src/common/constraint_conflicts.py
Andrey Epifancev 070a398c65 fix(api): гонка уникальных ограничений отдаёт тот же 409 с кодом, что и pre-check (T6.5, T7.1, T8.2, T12.7, T15.3, T15.4)
Реестр имя ограничения PostgreSQL -> (код, постоянный текст) в common/constraint_conflicts.py;
integrity_error_handler для unique_violation (23505) по известному ограничению отдаёт
{"detail": {"code", "message"}}, иначе прежняя общая строка. Имя берётся из полей драйвера
(asyncpg __cause__.constraint_name, psycopg diag), не из текста ошибки; resolved_code пишется в лог.
Тесты: unit обработчика/реестра (ключи есть в Base.metadata), интеграционные гонки (ГК/ЮЛ crm_id,
ИНН, алиас, email, членство, откат и следующий запрос, FK остаётся общим 409).
T15.3: README шаг 4 импорта; T15.4: test_components_have_no_duplicated_models.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 02:02:14 +04:00

74 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Гонки уникальных ограничений: имя нарушенного ограничения PostgreSQL → тот же код и текст 409, что у pre-check.
Сервисы проверяют уникальность до вставки (`INN_EXISTS`, `ALIAS_EXISTS`, …), но между проверкой и вставкой параллельный
запрос может успеть раньше — тогда БД отвечает `unique_violation` (SQLSTATE 23505), и `IntegrityError` доходит до
обработчика исключений. Реестр `UNIQUE_CONSTRAINT_CONFLICTS` сопоставляет имя ограничения доменному конфликту, чтобы
клиент видел тот же `{"detail": {"code", "message"}}`, что и без гонки. Текст сообщения — только постоянная часть
pre-check-а: динамические поля (`legal_entity_id`, `related_group_id`) в ответ гонки не попадают.
Имя ограничения берётся из структурированного поля драйвера (asyncpg: `__cause__.constraint_name`, psycopg:
`diag.constraint_name`), а не из текста ошибки: текст содержит DETAIL со значениями ключей (email, ИНН) — это ПДн.
"""
from dataclasses import dataclass
from typing import Any
from sqlalchemy.exc import DBAPIError
UNIQUE_VIOLATION_SQLSTATE = "23505"
@dataclass(frozen=True)
class ConstraintConflict:
"""Код и постоянный текст доменного конфликта 409."""
code: str
message: str
#: Имя уникального ограничения/индекса → конфликт. Ключи обязаны существовать в `Base.metadata`
#: (`tests/test_exception_handlers.py` падает при переименовании). Тексты совпадают с pre-check-ами сервисов.
UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = {
"uq_company_group_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ГК с таким crm_id уже существует"),
"uq_legal_entity_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует"),
"uq_legal_entity_inn": ConstraintConflict("INN_EXISTS", "ЮЛ с таким ИНН уже существует"),
"uq_legal_entity_alias_legal_entity_id_normalized_alias": ConstraintConflict(
"ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас"
),
"uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"),
"uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"),
# Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан
# миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а
"uq_related_group_member_entity_kind_current": ConstraintConflict(
"MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида"
),
}
def _sqlstate(orig: Any) -> str | None:
value = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None)
return value if isinstance(value, str) else None
def constraint_name(exc: DBAPIError) -> str | None:
"""Имя нарушенного ограничения из полей драйвера (asyncpg — `__cause__`, psycopg — `diag`); текст не разбирается."""
orig = exc.orig
if orig is None:
return None
for source in (getattr(orig, "__cause__", None), orig):
name = getattr(source, "constraint_name", None)
if isinstance(name, str) and name:
return name
diag_name = getattr(getattr(source, "diag", None), "constraint_name", None)
if isinstance(diag_name, str) and diag_name:
return diag_name
return None
def resolve_unique_conflict(exc: DBAPIError) -> ConstraintConflict | None:
"""Конфликт из реестра, если это `unique_violation` по известному ограничению; иначе `None` (общий 409)."""
if _sqlstate(exc.orig) != UNIQUE_VIOLATION_SQLSTATE:
return None
name = constraint_name(exc)
return UNIQUE_CONSTRAINT_CONFLICTS.get(name) if name is not None else None