Compare commits

...

1 Commits

Author SHA1 Message Date
Zamil Majdy
a6f3bda734 fix(backend/executor): parse RPC user dict into User model before accessing .timezone
When the scheduler/executor process calls add_graph_execution with prisma
not connected, udb is the DatabaseManagerAsyncClient whose get_user_by_id
returns response.json() — a plain dict — not a typed User model.  Accessing
user.timezone on a dict raises AttributeError, causing every scheduled graph
execution to fail with:

  AttributeError: 'dict' object has no attribute 'timezone'

Fix: wrap the result with User.model_validate(), which accepts both a dict
(from the RPC path) and an existing User instance (from the Prisma path),
returning a properly typed User in either case.

Adds a regression test that explicitly simulates the RPC path (prisma not
connected, get_user_by_id returns a raw dict) to prevent future regressions.

Fixes: https://significant-gravitas.sentry.io/issues/7395473462/
2026-04-08 17:52:41 +07:00
2 changed files with 116 additions and 2 deletions

View File

@@ -31,7 +31,12 @@ from backend.data.execution import (
NodesInputMasks,
)
from backend.data.graph import GraphModel, Node
from backend.data.model import USER_TIMEZONE_NOT_SET, CredentialsMetaInput, GraphInput
from backend.data.model import (
USER_TIMEZONE_NOT_SET,
CredentialsMetaInput,
GraphInput,
User,
)
from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig
from backend.util.clients import (
get_async_execution_event_bus,
@@ -962,7 +967,7 @@ async def add_graph_execution(
# Generate execution context if it's not provided
if execution_context is None:
user = await udb.get_user_by_id(user_id)
user = User.model_validate(await udb.get_user_by_id(user_id))
settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id)
workspace = await wdb.get_or_create_workspace(user_id)

View File

@@ -473,6 +473,115 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
assert result2 == mock_graph_exec_2
@pytest.mark.asyncio
async def test_add_graph_execution_via_db_manager_client_returns_user_dict(
mocker: MockerFixture,
):
"""
Regression test: when prisma is NOT connected (executor/scheduler process),
udb is the DatabaseManagerAsyncClient whose get_user_by_id returns a plain
dict (response.json()) instead of a typed User model. Without the fix,
accessing user.timezone raises AttributeError: 'dict' object has no attribute
'timezone'. With the fix, User.model_validate() converts the dict to a User
before the attribute is accessed.
"""
from datetime import datetime, timezone
from backend.data.execution import GraphExecutionWithNodes
from backend.executor.utils import add_graph_execution
graph_id = "test-graph-id"
user_id = "test-user-id"
mock_graph = mocker.MagicMock()
mock_graph.version = 1
mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes)
mock_graph_exec.id = "exec-id-rpc"
mock_graph_exec.node_executions = []
mock_graph_exec.status = ExecutionStatus.QUEUED
mock_graph_exec.graph_version = 1
mock_graph_exec.to_graph_execution_entry.return_value = mocker.MagicMock()
mock_queue = mocker.AsyncMock()
mock_event_bus = mocker.MagicMock()
mock_event_bus.publish = mocker.AsyncMock()
mock_validate = mocker.patch(
"backend.executor.utils.validate_and_construct_node_execution_input"
)
mock_validate.return_value = (mock_graph, [], {}, set())
mock_prisma = mocker.patch("backend.executor.utils.prisma")
mock_prisma.is_connected.return_value = False # ← executor/scheduler path
# Simulate what DatabaseManagerAsyncClient.get_user_by_id returns:
# a plain dict (response.json()), NOT a User model instance.
user_dict = {
"id": user_id,
"email": "test@example.com",
"email_verified": True,
"name": None,
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
"metadata": {},
"integrations": "",
"stripe_customer_id": None,
"top_up_config": None,
"max_emails_per_day": 3,
"notify_on_agent_run": True,
"notify_on_zero_balance": True,
"notify_on_low_balance": True,
"notify_on_block_execution_failed": True,
"notify_on_continuous_agent_error": True,
"notify_on_daily_summary": True,
"notify_on_weekly_summary": True,
"notify_on_monthly_summary": True,
"timezone": "UTC",
"subscription_tier": None,
}
mock_db_client = mocker.MagicMock()
mock_db_client.get_user_by_id = mocker.AsyncMock(return_value=user_dict)
mock_db_client.get_graph_settings = mocker.AsyncMock(
return_value=mocker.MagicMock(
human_in_the_loop_safe_mode=False, sensitive_action_safe_mode=False
)
)
mock_db_client.create_graph_execution = mocker.AsyncMock(
return_value=mock_graph_exec
)
mock_db_client.update_graph_execution_stats = mocker.AsyncMock(
return_value=mock_graph_exec
)
mock_db_client.update_node_execution_status_batch = mocker.AsyncMock()
mock_workspace = mocker.MagicMock()
mock_workspace.id = "ws-id"
mock_db_client.get_or_create_workspace = mocker.AsyncMock(
return_value=mock_workspace
)
mock_db_client.increment_onboarding_runs = mocker.AsyncMock()
mocker.patch(
"backend.executor.utils.get_database_manager_async_client",
return_value=mock_db_client,
)
mocker.patch(
"backend.executor.utils.get_async_execution_queue", return_value=mock_queue
)
mocker.patch(
"backend.executor.utils.get_async_execution_event_bus",
return_value=mock_event_bus,
)
# Must not raise AttributeError: 'dict' object has no attribute 'timezone'
result = await add_graph_execution(
graph_id=graph_id,
user_id=user_id,
)
assert result == mock_graph_exec
# ============================================================================
# Tests for Optional Credentials Feature
# ============================================================================