fix(api): 500 на GET /tasks/{id} при обращении к закэшированному юзеру
All checks were successful
Deploy / test (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m41s

Живая проверка после первого реального 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:
jze9
2026-08-24 18:31:20 +05:00
parent c76f60df69
commit 61c903ae0e
2 changed files with 35 additions and 10 deletions

View File

@@ -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:

View File

@@ -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))