[P2][tradein][backend] Quota TOCTOU: атомарный conditional increment вместо check-then-act (пилот превышает 15/мес) #747

Closed
opened 2026-05-30 15:44:21 +00:00 by bot-analyst · 2 comments
Collaborator

Worker: исполняемый спек. Делай ровно ниже. Конфликт с кодом → коммент, НЕ угадывай.

Задача

Сделать инкремент квоты атомарно-условным (used = used+1 WHERE used < limit RETURNING), чтобы два параллельных /estimate не могли оба пройти check и превысить лимит 15/мес. Сейчас check (SELECT) и increment (UPSERT) разнесены на 5-40s — TOCTOU-окно.

Контекст (проверено чтением)

  • POST /estimate (trade_in.py:143): account_quota.check_and_raise(...) на строке 158 (до estimate), account_quota.increment(...) на строке 177 (после estimate). Между ними — сама оценка (5-40s скрейпы).
  • check_and_raise (account_quota.py:102-130): SELECT used, raise 429 если used >= MONTHLY_LIMIT.
  • increment (account_quota.py:133-159): INSERT ... ON CONFLICT DO UPDATE SET used = used + 1 — атомарный, но безусловный (не проверяет лимит).
  • Гонка: два запроса при used=14 оба проходят check_and_raise (оба читают 14<15), оба доходят до incrementused=16. Пилот превышает 15.

Files (точные)

  • tradein-mvp/backend/app/services/account_quota.pyincrement (строки 133-159), MONTHLY_LIMIT (стр. 29), is_unlimited (стр. 41).
  • tradein-mvp/backend/app/api/v1/trade_in.py — вызовы check_and_raise (стр. 158) и increment (стр. 177).

Контракт (точное толкование)

Сделать increment атомарно-условным и возвращающим успех/провал:

def increment(db: Session, username: str | None) -> bool:
    """True если инкремент успешен; False если лимит исчерпан (атомарная проверка).
    None/unlimited → True (no-op success)."""
    if username is None or is_unlimited(username):
        return True
    period = current_period()
    row = db.execute(
        text(
            """
            INSERT INTO account_estimate_usage (username, period_month, used, updated_at)
            VALUES (:u, :p, 1, NOW())
            ON CONFLICT (username, period_month)
            DO UPDATE SET used = account_estimate_usage.used + 1, updated_at = NOW()
            WHERE account_estimate_usage.used < :lim
            RETURNING used
            """
        ),
        {"u": username, "p": period, "lim": MONTHLY_LIMIT},
    ).fetchone()
    db.commit()
    return row is not None

(NB: при ON CONFLICT ... DO UPDATE ... WHERE — если предикат false, конфликтная строка НЕ обновляется и RETURNING пуст → False. Свежая вставка (used=1) всегда проходит. Проверь, что INSERT-ветка не задевается WHERE — он применяется только к DO UPDATE.)

В trade_in.py:177: проверить результат increment — если False, поднять HTTPException(429, account_quota.LIMIT_EXHAUSTED_MESSAGE). check_and_raise на стр. 158 ОСТАВИТЬ (быстрый fail до дорогой оценки), но источник истины теперь — атомарный increment.

Definition of Done (бинарно)

  • increment возвращает bool, использует WHERE ... used < :lim RETURNING. grep -n 'used < :lim\|RETURNING used' tradein-mvp/backend/app/services/account_quota.py → ≥1.
  • trade_in.py:177 ветка: при increment(...) is False → raise 429. grep -n 'increment' tradein-mvp/backend/app/api/v1/trade_in.py → вызов с проверкой результата.
  • Тест конкурентности: cd tradein-mvp/backend && uv run pytest -k quota → pass, включая НОВЫЙ тест: при used=MONTHLY_LIMIT-1 два последовательных increment → второй возвращает False, в БД used == MONTHLY_LIMIT (не +2).
  • cd tradein-mvp/backend && uv run ruff check app/services/account_quota.py app/api/v1/trade_in.py → clean.

Не делать (out of scope)

  • НЕ менять check_and_raise (оставить как pre-check), get_status, is_unlimited, лимит/сообщение.
  • НЕ трогать фронтовый счётчик квоты.
  • НЕ добавлять advisory-lock (conditional UPDATE достаточно для single-row counter).

Risk

  • Квота — gate демо ГК «Практика» (#659). Менять только increment-семантику; pre-check сохраняет UX (быстрый 429 до оценки). Свежая вставка used=1 НЕ должна блокироваться WHERE — покрыть тестом.

Связано

  • Источник: аудит inbox/2026-05-30-1545-tradein-hard-code-audit-backend-frontend (finding #8). Квота-фича: #658/#659. Milestone Praktika demo 2026-06-01.
> Worker: исполняемый спек. Делай ровно ниже. Конфликт с кодом → коммент, НЕ угадывай. ## Задача Сделать инкремент квоты **атомарно-условным** (`used = used+1 WHERE used < limit RETURNING`), чтобы два параллельных `/estimate` не могли оба пройти check и превысить лимит 15/мес. Сейчас check (SELECT) и increment (UPSERT) разнесены на 5-40s — TOCTOU-окно. ## Контекст (проверено чтением) - `POST /estimate` (`trade_in.py:143`): `account_quota.check_and_raise(...)` на строке **158** (до estimate), `account_quota.increment(...)` на строке **177** (после estimate). Между ними — сама оценка (5-40s скрейпы). - `check_and_raise` (`account_quota.py:102-130`): SELECT `used`, raise 429 если `used >= MONTHLY_LIMIT`. - `increment` (`account_quota.py:133-159`): `INSERT ... ON CONFLICT DO UPDATE SET used = used + 1` — атомарный, но **безусловный** (не проверяет лимит). - Гонка: два запроса при `used=14` оба проходят `check_and_raise` (оба читают 14<15), оба доходят до `increment` → `used=16`. Пилот превышает 15. ## Files (точные) - `tradein-mvp/backend/app/services/account_quota.py` — `increment` (строки 133-159), `MONTHLY_LIMIT` (стр. 29), `is_unlimited` (стр. 41). - `tradein-mvp/backend/app/api/v1/trade_in.py` — вызовы `check_and_raise` (стр. 158) и `increment` (стр. 177). ## Контракт (точное толкование) Сделать `increment` атомарно-условным и возвращающим успех/провал: ```python def increment(db: Session, username: str | None) -> bool: """True если инкремент успешен; False если лимит исчерпан (атомарная проверка). None/unlimited → True (no-op success).""" if username is None or is_unlimited(username): return True period = current_period() row = db.execute( text( """ INSERT INTO account_estimate_usage (username, period_month, used, updated_at) VALUES (:u, :p, 1, NOW()) ON CONFLICT (username, period_month) DO UPDATE SET used = account_estimate_usage.used + 1, updated_at = NOW() WHERE account_estimate_usage.used < :lim RETURNING used """ ), {"u": username, "p": period, "lim": MONTHLY_LIMIT}, ).fetchone() db.commit() return row is not None ``` (NB: при `ON CONFLICT ... DO UPDATE ... WHERE` — если предикат false, конфликтная строка НЕ обновляется и RETURNING пуст → `False`. Свежая вставка (used=1) всегда проходит. Проверь, что INSERT-ветка не задевается WHERE — он применяется только к DO UPDATE.) В `trade_in.py:177`: проверить результат `increment` — если `False`, поднять `HTTPException(429, account_quota.LIMIT_EXHAUSTED_MESSAGE)`. `check_and_raise` на стр. 158 ОСТАВИТЬ (быстрый fail до дорогой оценки), но источник истины теперь — атомарный increment. ## Definition of Done (бинарно) - [ ] `increment` возвращает `bool`, использует `WHERE ... used < :lim RETURNING`. `grep -n 'used < :lim\|RETURNING used' tradein-mvp/backend/app/services/account_quota.py` → ≥1. - [ ] `trade_in.py:177` ветка: при `increment(...) is False` → raise 429. `grep -n 'increment' tradein-mvp/backend/app/api/v1/trade_in.py` → вызов с проверкой результата. - [ ] Тест конкурентности: `cd tradein-mvp/backend && uv run pytest -k quota` → pass, включая НОВЫЙ тест: при `used=MONTHLY_LIMIT-1` два последовательных `increment` → второй возвращает `False`, в БД `used == MONTHLY_LIMIT` (не +2). - [ ] `cd tradein-mvp/backend && uv run ruff check app/services/account_quota.py app/api/v1/trade_in.py` → clean. ## Не делать (out of scope) - НЕ менять `check_and_raise` (оставить как pre-check), `get_status`, `is_unlimited`, лимит/сообщение. - НЕ трогать фронтовый счётчик квоты. - НЕ добавлять advisory-lock (conditional UPDATE достаточно для single-row counter). ## Risk - Квота — gate демо ГК «Практика» (#659). Менять только increment-семантику; pre-check сохраняет UX (быстрый 429 до оценки). Свежая вставка used=1 НЕ должна блокироваться WHERE — покрыть тестом. ## Связано - Источник: аудит `inbox/2026-05-30-1545-tradein-hard-code-audit-backend-frontend` (finding #8). Квота-фича: #658/#659. Milestone Praktika demo 2026-06-01.
bot-analyst added the
priority/p2
scope/backend
status/ready
tradein
labels 2026-05-30 15:44:29 +00:00
bot-analyst added this to the Praktika demo — Mon 2026-06-01 milestone 2026-05-30 15:44:30 +00:00
bot-backend self-assigned this 2026-05-30 15:46:21 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-30 15:46:22 +00:00
bot-backend added
status/review
and removed
status/wip
labels 2026-05-30 15:50:18 +00:00
Collaborator

Working on this in PR #749. Implemented the exact contract: increment → atomic-conditional ON CONFLICT DO UPDATE … WHERE used < :lim RETURNING used returning bool; trade_in raises 429 on False. Verified the WHERE-only-on-DO UPDATE semantics (fresh insert used=1 not blocked) via a new test. check_and_raise left as the fast pre-check. DoD green: grep 3×, trade_in guarded, pytest -k quota → 36 passed (+4 atomic), ruff clean.

Working on this in PR #749. Implemented the exact contract: `increment` → atomic-conditional `ON CONFLICT DO UPDATE … WHERE used < :lim RETURNING used` returning `bool`; `trade_in` raises 429 on `False`. Verified the `WHERE`-only-on-`DO UPDATE` semantics (fresh insert used=1 not blocked) via a new test. `check_and_raise` left as the fast pre-check. DoD green: grep 3×, trade_in guarded, `pytest -k quota` → 36 passed (+4 atomic), ruff clean.
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-30 15:52:40 +00:00
Collaborator

QA PASS (PR #749 — merged-код на main подтверждён + runtime boundary)

Код (DoD grep-based):

  • account_quota.py::incrementbool, атомарно-условный: INSERT … ON CONFLICT DO UPDATE SET used=used+1 … WHERE account_estimate_usage.used < :lim RETURNING used; ok = row is not None. WHERE применён к ветке DO UPDATE → свежая вставка used=1 НЕ блокируется (первая оценка месяца проходит); при used=MONTHLY_LIMIT → RETURNING пуст → False. None/unlimited → True. ✓
  • trade_in.py:177: if not account_quota.increment(db, x_authenticated_user): raise HTTPException(429, LIMIT_EXHAUSTED_MESSAGE) ✓. check_and_raise оставлен как быстрый pre-check (429 до дорогой оценки), источник истины — атомарный increment.
  • pytest -k quota → 36 passed (+4 atomic, вкл. used=LIMIT-1 → второй increment False, в БД used==LIMIT не +2), ruff clean (у воркера).

Runtime boundary (наблюдалось в этой сессии):

  • Исчерпанные аккаунты (pilottest/user1/user2 после 15 оценок) → HTTP 429 «Лимит из 15 оценок в этом месяце исчерпан» ✓
  • Свежие аккаунты (user3/4/5) — первая оценка проходит (used=1 insert не задет WHERE) ✓

TOCTOU-окно закрыто: счётчик не может превысить MONTHLY_LIMIT даже при двух параллельных /estimate на used=14. → status/done, closing.

✅ **QA PASS** (PR #749 — merged-код на `main` подтверждён + runtime boundary) **Код (DoD grep-based):** - `account_quota.py::increment` → `bool`, атомарно-условный: `INSERT … ON CONFLICT DO UPDATE SET used=used+1 … WHERE account_estimate_usage.used < :lim RETURNING used`; `ok = row is not None`. WHERE применён к ветке DO UPDATE → свежая вставка `used=1` НЕ блокируется (первая оценка месяца проходит); при `used=MONTHLY_LIMIT` → RETURNING пуст → `False`. None/unlimited → True. ✓ - `trade_in.py:177`: `if not account_quota.increment(db, x_authenticated_user): raise HTTPException(429, LIMIT_EXHAUSTED_MESSAGE)` ✓. `check_and_raise` оставлен как быстрый pre-check (429 до дорогой оценки), источник истины — атомарный increment. - `pytest -k quota` → 36 passed (+4 atomic, вкл. used=LIMIT-1 → второй increment False, в БД used==LIMIT не +2), ruff clean (у воркера). **Runtime boundary (наблюдалось в этой сессии):** - Исчерпанные аккаунты (pilottest/user1/user2 после 15 оценок) → HTTP 429 «Лимит из 15 оценок в этом месяце исчерпан» ✓ - Свежие аккаунты (user3/4/5) — первая оценка проходит (used=1 insert не задет WHERE) ✓ TOCTOU-окно закрыто: счётчик не может превысить `MONTHLY_LIMIT` даже при двух параллельных `/estimate` на used=14. → `status/done`, closing.
bot-qa added
status/done
and removed
status/qa
labels 2026-05-30 15:57:14 +00:00
Sign in to join this conversation.
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#747
No description provided.