# Bug #003 — Serializable conflict → 500 (нужно 409 + retry) **Severity:** **CRITICAL** (visible incorrect HTTP-код на race; кассир видит «ошибка сервера» вместо корректного «попробуй ещё раз») **Component:** `RetailSalesController.Post` (и любой posting endpoint, который BeginTransactionAsync(Serializable)) **Found:** Sprint 23, 2026-06-08 ## Воспроизведение 20 параллельных POST `/api/sales/retail/{id}/post` на один продукт при stock=10: ``` ok=8 conflict409=0 500=12 (12 пятисоток с empty body) ``` Stock-инвариант сохраняется (`stock=2.0 == Σ movements`), over-sell'a НЕТ ✓. Но `500 + empty body` — это серверная ошибка, кассиру нечего сказать «попробуй ещё раз», UI показывает «Ошибка сервера». ## Причина В `RetailSalesController.Post`: ```csharp await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); … await tx.CommitAsync(ct); // ← здесь Postgres может бросить 40001 ``` PostgreSQL Serializable выбрасывает `SqlState=40001` при serialization failure (другая транзакция изменила данные, которые мы читали). EF Core ловит как `PostgresException`, прокидывает наверх; глобальный middleware возвращает 500 без тела. ## Фикс Wrap commit + retry. Маленький helper `WithSerializableRetryAsync`: ```csharp const int maxAttempts = 5; for (var attempt = 1; attempt <= maxAttempts; attempt++) { try { await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); // … work … await tx.CommitAsync(ct); return Ok(); } catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == "40001") { // Serialization conflict — retry с exp backoff + jitter. if (attempt == maxAttempts) return Conflict(new { error = "Слишком много одновременных продаж этого товара. Попробуйте ещё раз." }); await Task.Delay(TimeSpan.FromMilliseconds(50 * attempt + Random.Shared.Next(50)), ct); } catch (PostgresException pg) when (pg.SqlState == "40001") { // Аналогично для исключения вне DbUpdateException обёртки. if (attempt == maxAttempts) return Conflict(new { error = "Слишком много одновременных продаж этого товара. Попробуйте ещё раз." }); await Task.Delay(TimeSpan.FromMilliseconds(50 * attempt + Random.Shared.Next(50)), ct); } } ``` После фикса: те же 20 параллельных постов → большинство retry'ятся и проходят, остаток возвращает 409 (а не 500) с понятным сообщением. Stock-инвариант по-прежнему сохраняется (это другое свойство). ## Retest После фикса — те же 20 параллельных POST: - ok = 10 (полное число доступных единиц) - 409 = 10 (over-stock или retry-exhausted, с понятным сообщением) - 500 = 0 ✓ - stock-invariant: 0 == 0 ✓