Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions backend/src/baserow/contrib/automation/nodes/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from baserow.core.registries import ImportExportConfig
from baserow.core.services.exceptions import (
ServiceImproperlyConfiguredDispatchException,
UnexpectedDispatchException,
)
from baserow.core.services.handler import ServiceHandler
from baserow.core.services.models import Service
Expand Down Expand Up @@ -379,6 +380,23 @@ def _handle_workflow_error(
node_history.status = HistoryStatusChoices.ERROR
node_history.save()

def _handle_simulation_notify(
self, simulate_until_node: AutomationNode | None, node: AutomationNode
) -> bool:
"""
When the simulated node is the current node, refresh the sample data
and send a node updated signal so that the frontend receives the
updated sample data.

Returns True if a signal was sent, False otherwise.
"""

if simulate_until_node and simulate_until_node.id == node.id:
node.service.specific.refresh_from_db(fields=["sample_data"])
automation_node_updated.send(self, user=None, node=node)
return True
return False

def dispatch_node(
self,
node_id: int,
Expand Down Expand Up @@ -444,6 +462,16 @@ def dispatch_node(
except ServiceImproperlyConfiguredDispatchException as e:
error = f"The node {node.id} is misconfigured and cannot be dispatched. {str(e)}"
self._handle_workflow_error(node_history, error)
self._handle_simulation_notify(simulate_until_node, node)
return None
except UnexpectedDispatchException as e:
original_workflow = node.workflow.get_original()
error = (
f"Error while running workflow {original_workflow.id}. Error: {str(e)}"
)
logger.warning(error)
self._handle_workflow_error(node_history, error)
self._handle_simulation_notify(simulate_until_node, node)
return None
except Exception as e:
original_workflow = node.workflow.get_original()
Expand All @@ -454,6 +482,7 @@ def dispatch_node(
)
logger.exception(error)
self._handle_workflow_error(node_history, error)
self._handle_simulation_notify(simulate_until_node, node)
return None

iteration_index = 0
Expand All @@ -464,11 +493,8 @@ def dispatch_node(

# Return early if this is a simulation as we've reached the
# simulated node.
if until_node := simulate_until_node:
if until_node.id == node.id:
until_node.service.specific.refresh_from_db(fields=["sample_data"])
automation_node_updated.send(self, user=None, node=until_node)
return None
if self._handle_simulation_notify(simulate_until_node, node):
return None

history_handler.create_node_result(
node_history=node_history,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from baserow.contrib.automation.nodes.handler import AutomationNodeHandler
from baserow.contrib.automation.workflows.tasks import handle_workflow_dispatch_done
from baserow.core.services.exceptions import UnexpectedDispatchException
from baserow.test_utils.helpers import AnyInt, AnyStr

TRIGGER_NODE_TYPE_PATH = (
Expand Down Expand Up @@ -213,6 +214,41 @@ def test_dispatch_node_unexpected_error(mock_logger, mock_dispatch, data_fixture
assert node_history.status == HistoryStatusChoices.ERROR


@pytest.mark.django_db
@patch(f"{TRIGGER_NODE_TYPE_PATH}.dispatch")
@patch(f"{NODE_HANDLER_PATH}.logger")
def test_dispatch_node_expected_error(mock_logger, mock_dispatch, data_fixture):
mock_dispatch.side_effect = UnexpectedDispatchException("Mock external API error")

data = create_workflow(data_fixture)
trigger_node = data["trigger_node"]
workflow_history = data["workflow_history"]

result = AutomationNodeHandler().dispatch_node(
trigger_node.id,
history_id=workflow_history.id,
)
assert result is None
workflow_history.refresh_from_db()
error = (
f"Error while running workflow {trigger_node.workflow.id}. "
"Error: Mock external API error"
)

mock_logger.warning.assert_called_once_with(error)
# Ensure error/exception are not logged, since that would cause
# Sentry to create an issue.
mock_logger.error.assert_not_called()
mock_logger.exception.assert_not_called()

assert error in workflow_history.message
assert workflow_history.status == HistoryStatusChoices.ERROR

node_history = AutomationNodeHistory.objects.get(workflow_history=workflow_history)
assert error in node_history.message
assert node_history.status == HistoryStatusChoices.ERROR


@pytest.mark.django_db
def test_dispatch_node_dispatches_trigger(data_fixture):
data = create_workflow(data_fixture)
Expand Down Expand Up @@ -539,6 +575,143 @@ def test_dispatch_node_dispatches_action_simulation(
)


@pytest.mark.django_db
@patch(f"{NODE_HANDLER_PATH}.automation_node_updated")
def test_dispatch_node_simulation_error_misconfigured_service_sends_node_updated_signal(
mock_automation_node_updated,
data_fixture,
):
data = create_workflow(data_fixture)
trigger_node = data["trigger_node"]
action_node = data["action_node"]

workflow_history = data["workflow_history"]
workflow_history.simulate_until_node = action_node
workflow_history.save()

assert action_node.service.specific.sample_data is None

# Simulate the trigger first so that the dispatch context can populate
# previous_node_results from the database.
result = AutomationNodeHandler().dispatch_node(
trigger_node.id,
history_id=workflow_history.id,
)
assert_dispatches_next_node(result, (action_node, workflow_history, None))

# Break the action node's service
action_node.service.specific.table = None
action_node.service.specific.save()

# Now simulate the action node, which should fail
result = AutomationNodeHandler().dispatch_node(
action_node.id,
history_id=workflow_history.id,
)
assert result is None

action_node.service.specific.refresh_from_db()
assert action_node.service.specific.sample_data == {"_error": "No table selected"}

# Make sure the node updated signal is sent
mock_automation_node_updated.send.assert_called_once_with(
ANY, user=None, node=action_node
)


@pytest.mark.django_db
@patch(f"{NODE_HANDLER_PATH}.automation_node_updated")
def test_dispatch_node_simulation_error_dispatch_exception_sends_node_updated_signal(
mock_automation_node_updated,
data_fixture,
):
data = create_workflow(data_fixture)
trigger_node = data["trigger_node"]
action_node = data["action_node"]

workflow_history = data["workflow_history"]
workflow_history.simulate_until_node = action_node
workflow_history.save()

assert action_node.service.specific.sample_data is None

# Simulate the trigger first so that the dispatch context can populate
# previous_node_results from the database.
result = AutomationNodeHandler().dispatch_node(
trigger_node.id,
history_id=workflow_history.id,
)
assert_dispatches_next_node(result, (action_node, workflow_history, None))

# Simulate an UnexpectedDispatchException
node_type = action_node.get_type()
with patch.object(
type(node_type),
"dispatch",
side_effect=UnexpectedDispatchException("Mock dispatch error"),
):
result = AutomationNodeHandler().dispatch_node(
action_node.id,
history_id=workflow_history.id,
)

assert result is None

action_node.service.specific.refresh_from_db()
assert action_node.service.specific.sample_data is None

# Make sure the node updated signal is sent
mock_automation_node_updated.send.assert_called_once_with(
ANY, user=None, node=action_node
)


@pytest.mark.django_db
@patch(f"{NODE_HANDLER_PATH}.automation_node_updated")
def test_dispatch_node_simulation_error_unknown_exception_sends_node_updated_signal(
mock_automation_node_updated,
data_fixture,
):
data = create_workflow(data_fixture)
trigger_node = data["trigger_node"]
action_node = data["action_node"]

workflow_history = data["workflow_history"]
workflow_history.simulate_until_node = action_node
workflow_history.save()

assert action_node.service.specific.sample_data is None

# Simulate the trigger first so that the dispatch context can populate
# previous_node_results from the database.
result = AutomationNodeHandler().dispatch_node(
trigger_node.id,
history_id=workflow_history.id,
)
assert_dispatches_next_node(result, (action_node, workflow_history, None))

# Simulate an unexpected error that is handled by the
# `except Exception:` block.
node_type = action_node.get_type()
with patch.object(
type(node_type), "dispatch", side_effect=ValueError("Mock unexpected error")
):
result = AutomationNodeHandler().dispatch_node(
action_node.id,
history_id=workflow_history.id,
)

assert result is None

action_node.service.specific.refresh_from_db()
assert action_node.service.specific.sample_data is None

# Make sure the node updated signal is sent
mock_automation_node_updated.send.assert_called_once_with(
ANY, user=None, node=action_node
)


@pytest.mark.django_db
@patch(f"{NODE_HANDLER_PATH}.automation_node_updated")
def test_dispatch_node_dispatches_iterator_simulation(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Ensure known service errors are logged as warnings to reduce error monitoring noise.",
"issue_origin": "github",
"issue_number": null,
"domain": "automation",
"bullet_points": [],
"created_at": "2026-03-17"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fixed a bug where node simulation errors weren't immediately shown.",
"issue_origin": "github",
"issue_number": null,
"domain": "automation",
"bullet_points": [],
"created_at": "2026-03-17"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fixed an unhandled error when a user source user logs out and their refresh token is already expired.",
"issue_origin": "github",
"issue_number": null,
"domain": "builder",
"bullet_points": [],
"created_at": "2026-03-17"
}
10 changes: 7 additions & 3 deletions web-frontend/modules/core/services/userSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ export default (client) => {
},
blacklistToken(refreshToken) {
// Yes, we use the same service as the main auth.
return client.post('/user-source-token-blacklist/', {
refresh_token: refreshToken,
})
return client.post(
'/user-source-token-blacklist/',
{
refresh_token: refreshToken,
},
{ skipAuthRefresh: true }
)
},
}
}
11 changes: 10 additions & 1 deletion web-frontend/modules/core/store/userSourceUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,16 @@ export const actions = {
commit('LOGOFF', { application })

if (refreshToken && invalidateToken) {
await UserSourceService(this.$client).blacklistToken(refreshToken)
try {
await UserSourceService(this.$client).blacklistToken(refreshToken)
} catch (e) {
// blacklistToken() could return a 401 ERROR_INVALID_REFRESH_TOKEN
// error if the refresh token has already expired. We swallow the
// error here because the user source session has already been cleared.
if (e.response?.status !== 401) {
throw e
}
}
}
},

Expand Down
Loading