fix(api): 500 на GET /tasks/{id} при обращении к закэшированному юзеру
Живая проверка после первого реального OAuth-логина: юзер вошёл, поиск
отработал, но статус задачи не показывался — GET /api/tasks/{id} падал 500.
В логе: AttributeError: 'NoneType' object has no attribute
'supports_population' на current_user.id.
Причина: _load_user() при попадании в Redis-кэш делал
User.__new__(User); u.__dict__.update(data) — выглядело как лёгкий объект
без лишнего SELECT, но замапленные атрибуты User (id, email, ...) —
дескрипторы данных SQLAlchemy: их __get__ обращается к InstanceState,
которого у объекта в обход __init__/ORM-машинерии нет. Падало на КАЖДОМ
запросе, где юзер брался из кэша (5 мин TTL) — то есть почти всегда,
кроме первого запроса после логина/протухания кэша.
Фикс: CachedUser — обычный dataclass с теми же полями, без дескрипторов,
падать нечему. Заодно нашёл тем же грепом идентичный баг в
resend_verification (трогал current_user.verification_token — не входит
в кэшируемый набор полей, к тому же current_user из кэша не привязан к
сессии — db.commit()/refresh() на нём тоже не сработали бы) — почистил по
образцу update_profile/change_password: подгружает свежего юзера из БД.
Подтверждено вручную на проде: сгенерировал JWT для реального юзера,
GET /api/tasks/{id} возвращал 500 до фикса.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -213,15 +213,22 @@ async def resend_verification(
|
|||||||
detail="Email уже подтверждён",
|
detail="Email уже подтверждён",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not current_user.verification_token:
|
# current_user из dependency может быть из Redis-кэша (без verification_token
|
||||||
current_user.verification_token = secrets.token_urlsafe(32)
|
# и не привязан к сессии — commit/refresh на нём не сработают) — грузим "живого"
|
||||||
|
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
|
||||||
|
|
||||||
|
if not user.verification_token:
|
||||||
|
user.verification_token = secrets.token_urlsafe(32)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(user)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
celery_app.send_task(
|
celery_app.send_task(
|
||||||
"notify.send_verification",
|
"notify.send_verification",
|
||||||
args=[current_user.email, current_user.name, current_user.verification_token],
|
args=[user.email, user.name, user.verification_token],
|
||||||
queue="queue.notify",
|
queue="queue.notify",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -65,6 +66,28 @@ def _decode_token(token: str) -> int:
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CachedUser:
|
||||||
|
"""Пользователь из Redis-кэша — обычный dataclass, НЕ SQLAlchemy-модель.
|
||||||
|
|
||||||
|
Раньше здесь был User.__new__(User) + __dict__.update(data) — казалось
|
||||||
|
эквивалентом, но замапленные атрибуты User (id, email, ...) — дескрипторы
|
||||||
|
данных: их __get__ обращается к self.impl, который берётся из InstanceState,
|
||||||
|
а у объекта, созданного в обход __init__/ORM-машинерии, состояния нет.
|
||||||
|
Итог — AttributeError на любое обращение к полю из кэша (нашли на
|
||||||
|
GET /tasks/{id}: current_user.id падал 500-й, как только юзер брался не
|
||||||
|
из БД, а из кэша). Обычный dataclass с теми же именами полей — просто
|
||||||
|
plain-атрибуты, без дескрипторов, падать нечему.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
plan: str
|
||||||
|
is_verified: bool
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
async def _load_user(user_id: int, db: AsyncSession):
|
async def _load_user(user_id: int, db: AsyncSession):
|
||||||
"""Загрузить пользователя из Redis-кэша или из БД."""
|
"""Загрузить пользователя из Redis-кэша или из БД."""
|
||||||
from app.models.user import User # избегаем circular import на уровне модуля
|
from app.models.user import User # избегаем circular import на уровне модуля
|
||||||
@@ -75,12 +98,7 @@ async def _load_user(user_id: int, db: AsyncSession):
|
|||||||
# Пробуем кэш
|
# Пробуем кэш
|
||||||
cached = await r.get(cache_key)
|
cached = await r.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
data = json.loads(cached)
|
return CachedUser(**json.loads(cached))
|
||||||
# Возвращаем "живой" объект из БД только по id, но без лишнего SELECT
|
|
||||||
# Создаём User без ORM-связей (достаточно для проверок в роутерах)
|
|
||||||
u = User.__new__(User)
|
|
||||||
u.__dict__.update(data)
|
|
||||||
return u
|
|
||||||
|
|
||||||
# Кэш пустой — идём в БД
|
# Кэш пустой — идём в БД
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
|||||||
Reference in New Issue
Block a user