diff --git a/backend/src/baserow/contrib/automation/nodes/handler.py b/backend/src/baserow/contrib/automation/nodes/handler.py index 1e6475eb5c..d155e19884 100644 --- a/backend/src/baserow/contrib/automation/nodes/handler.py +++ b/backend/src/baserow/contrib/automation/nodes/handler.py @@ -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 @@ -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, @@ -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() @@ -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 @@ -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, diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py b/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py index 4b19c00bb9..3d829f2f97 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_dispatch_async.py @@ -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 = ( @@ -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) @@ -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( diff --git a/changelog/entries/unreleased/bug/ensure_service_errors_are_logged_as_warnings.json b/changelog/entries/unreleased/bug/ensure_service_errors_are_logged_as_warnings.json new file mode 100644 index 0000000000..cca5d6ae8c --- /dev/null +++ b/changelog/entries/unreleased/bug/ensure_service_errors_are_logged_as_warnings.json @@ -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" +} diff --git a/changelog/entries/unreleased/bug/fixed_a_bug_where_node_simulation_errors_werent_immediately_.json b/changelog/entries/unreleased/bug/fixed_a_bug_where_node_simulation_errors_werent_immediately_.json new file mode 100644 index 0000000000..73aec66628 --- /dev/null +++ b/changelog/entries/unreleased/bug/fixed_a_bug_where_node_simulation_errors_werent_immediately_.json @@ -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" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/bug/fixed_an_unhandled_error_when_a_user_source_user_logs_out_an.json b/changelog/entries/unreleased/bug/fixed_an_unhandled_error_when_a_user_source_user_logs_out_an.json new file mode 100644 index 0000000000..947f63f1a4 --- /dev/null +++ b/changelog/entries/unreleased/bug/fixed_an_unhandled_error_when_a_user_source_user_logs_out_an.json @@ -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" +} \ No newline at end of file diff --git a/web-frontend/modules/core/services/userSource.js b/web-frontend/modules/core/services/userSource.js index 262e833245..eaef32140b 100644 --- a/web-frontend/modules/core/services/userSource.js +++ b/web-frontend/modules/core/services/userSource.js @@ -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 } + ) }, } } diff --git a/web-frontend/modules/core/store/userSourceUser.js b/web-frontend/modules/core/store/userSourceUser.js index cfb282b259..4afba6b216 100644 --- a/web-frontend/modules/core/store/userSourceUser.js +++ b/web-frontend/modules/core/store/userSourceUser.js @@ -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 + } + } } },