Реестр имя ограничения 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
74 lines
4.6 KiB
Python
74 lines
4.6 KiB
Python
"""Гонки уникальных ограничений: имя нарушенного ограничения 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
|