fix: prevent concurrent device purchases exceeding max device limit

Add SELECT FOR UPDATE row lock on subscription before checking device
limit in all 3 device purchase endpoints (cabinet new, cabinet legacy,
miniapp). Without the lock, two concurrent requests both read the old
device_limit, both pass validation, and both increment — resulting in
device count exceeding max_device_limit (e.g., 5 devices when limit is 3).

Also moved max-devices check before balance check in legacy endpoint
to fail fast under lock.
This commit is contained in:
Fringg
2026-03-05 07:33:09 +03:00
parent c8ef808539
commit 1cfede28b7
2 changed files with 30 additions and 17 deletions
+24 -17
View File
@@ -998,9 +998,13 @@ async def purchase_devices_legacy(
detail='Subscription purchases are restricted for this account',
)
await db.refresh(user, ['subscription'])
# Lock subscription row to prevent concurrent device purchases exceeding the limit
result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id).with_for_update()
)
subscription = result.scalar_one_or_none()
if not user.subscription:
if not subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No subscription found',
@@ -1018,6 +1022,17 @@ async def purchase_devices_legacy(
if devices_discount_percent < 100 and total_price > 0:
total_price = max(100, total_price)
# Check max devices limit (under row lock — prevents concurrent purchases exceeding limit)
current_devices = subscription.device_limit or 1
new_devices = current_devices + request.devices
max_devices = settings.MAX_DEVICES_LIMIT
if new_devices > max_devices:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Maximum device limit is {max_devices}',
)
# Check balance
if user.balance_kopeks < total_price:
missing = total_price - user.balance_kopeks
@@ -1053,17 +1068,6 @@ async def purchase_devices_legacy(
},
)
# Check max devices limit
current_devices = user.subscription.device_limit or 1
new_devices = current_devices + request.devices
max_devices = settings.MAX_DEVICES_LIMIT
if new_devices > max_devices:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Maximum device limit is {max_devices}',
)
# Deduct balance and create transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod
@@ -1089,7 +1093,7 @@ async def purchase_devices_legacy(
)
# Add devices
user.subscription.device_limit = new_devices
subscription.device_limit = new_devices
await db.commit()
await db.refresh(user)
@@ -2292,8 +2296,11 @@ async def purchase_devices(
)
try:
await db.refresh(user, ['subscription'])
subscription = user.subscription
# Lock subscription row to prevent concurrent device purchases exceeding the limit
result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id).with_for_update()
)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(
@@ -2329,7 +2336,7 @@ async def purchase_devices(
detail='Докупка устройств недоступна',
)
# Check max device limit
# Check max device limit (under row lock — prevents concurrent purchases exceeding limit)
current_devices = subscription.device_limit or 1
new_device_count = current_devices + request.devices
if max_device_limit and new_device_count > max_device_limit:
+6
View File
@@ -6113,6 +6113,12 @@ async def update_subscription_devices_endpoint(
},
)
# Re-read subscription under row lock to prevent concurrent device purchases exceeding limit
locked_result = await db.execute(
select(Subscription).where(Subscription.id == subscription.id).with_for_update()
)
subscription = locked_result.scalar_one()
current_devices_value = subscription.device_limit
if current_devices_value is None:
fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1