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

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