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:
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -65,6 +66,28 @@ def _decode_token(token: str) -> int:
|
||||
) 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):
|
||||
"""Загрузить пользователя из Redis-кэша или из БД."""
|
||||
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)
|
||||
if cached:
|
||||
data = json.loads(cached)
|
||||
# Возвращаем "живой" объект из БД только по id, но без лишнего SELECT
|
||||
# Создаём User без ORM-связей (достаточно для проверок в роутерах)
|
||||
u = User.__new__(User)
|
||||
u.__dict__.update(data)
|
||||
return u
|
||||
return CachedUser(**json.loads(cached))
|
||||
|
||||
# Кэш пустой — идём в БД
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
|
||||
Reference in New Issue
Block a user