This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 688c25b7a26 Skip Tableau refresh/task run on 409093 resource conflict
(#69382)
688c25b7a26 is described below
commit 688c25b7a262e948500c91e8d6b0215e3576f999
Author: Jaya Haryono Manik <[email protected]>
AuthorDate: Thu Aug 13 20:16:05 2026 +0700
Skip Tableau refresh/task run on 409093 resource conflict (#69382)
Add `skip_on_conflict` support and improve conflict messages.
---
providers/tableau/docs/operators.rst | 1 +
.../airflow/providers/tableau/operators/tableau.py | 58 ++++++--
.../tests/unit/tableau/operators/test_tableau.py | 148 ++++++++++++++++++++-
3 files changed, 192 insertions(+), 15 deletions(-)
diff --git a/providers/tableau/docs/operators.rst
b/providers/tableau/docs/operators.rst
index 9bcc830b761..ab6141ce3dd 100644
--- a/providers/tableau/docs/operators.rst
+++ b/providers/tableau/docs/operators.rst
@@ -39,6 +39,7 @@ Using the Operator
| **timeout**: maximum total time in seconds to wait for a blocking refresh
before giving up with a ``TimeoutError``. **float** - Default: **None** (wait
indefinitely)
| **exponential_backoff**: grow the wait between status checks by 50% each
time, starting from ``check_interval``, instead of staying fixed. **bool** -
Default: **False**
| **max_check_interval**: maximum interval in seconds between two consecutive
status checks when ``exponential_backoff`` is enabled. **float** - Default:
**None** (uncapped)
+| **skip_on_conflict**: When ``True``, treat a Tableau ``409093 Resource
Conflict`` error while triggering a refresh or running an extract refresh task
(a refresh/run for the same resource is already queued or running) as a skipped
task instead of a failure. Applies when ``method="refresh"`` and when
``method="run"`` on ``tasks``. **bool** - Default: **False**
| **tableau_conn_id**: The credentials to authenticate to the Tableau Server.
**str** - Default: **tableau_default**
|
|
diff --git
a/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
b/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
index f7158f95e42..bcf09798f7c 100644
--- a/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
+++ b/providers/tableau/src/airflow/providers/tableau/operators/tableau.py
@@ -19,11 +19,12 @@ from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING
-from tableauserverclient import JobItem
+from tableauserverclient import JobItem, ServerResponseError
from airflow.providers.common.compat.sdk import (
AirflowException,
AirflowOptionalProviderFeatureException,
+ AirflowSkipException,
BaseOperator,
)
from airflow.providers.tableau.hooks.tableau import (
@@ -35,6 +36,12 @@ from airflow.providers.tableau.hooks.tableau import (
if TYPE_CHECKING:
from airflow.providers.common.compat.sdk import Context
+# Tableau REST API error code returned when an extract refresh -- triggered
directly via
+# datasources/workbooks "refresh" or via running a scheduled extract refresh
task ("tasks.run")
+# -- is requested for a resource that already has one queued or running.
+# See:
https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_errors.htm
+RESOURCE_CONFLICT_ERROR_CODE = "409093"
+
RESOURCES_METHODS = {
"datasources": ["delete", "refresh"],
"groups": ["delete"],
@@ -75,6 +82,11 @@ class TableauOperator(BaseOperator):
when ``exponential_backoff`` is enabled. ``None`` leaves the growth
uncapped.
:param incremental_refresh: Whether to perform an incremental refresh
instead of a full refresh.
Only applies to datasource and workbook refresh operations. Defaults
to False (full refresh).
+ :param skip_on_conflict: When ``True``, treat a Tableau ``409093 Resource
Conflict`` error
+ while triggering a refresh, or while running an extract refresh task,
as a skipped task
+ instead of a failure. Raised when a refresh/run for the same resource
is already queued
+ or running. Applies to ``method="refresh"`` and to ``method="run"`` on
``tasks``.
+ Defaults to ``False``, so the conflict fails the task as before.
:param tableau_conn_id: The :ref:`Tableau Connection id
<howto/connection:tableau>`
containing the credentials to authenticate to the Tableau Server.
"""
@@ -98,6 +110,7 @@ class TableauOperator(BaseOperator):
exponential_backoff: bool = False,
max_check_interval: float | None = None,
incremental_refresh: bool = False,
+ skip_on_conflict: bool = False,
tableau_conn_id: str = "tableau_default",
**kwargs,
) -> None:
@@ -113,6 +126,7 @@ class TableauOperator(BaseOperator):
self.site_id = site_id
self.blocking_refresh = blocking_refresh
self.incremental_refresh = incremental_refresh
+ self.skip_on_conflict = skip_on_conflict
self.tableau_conn_id = tableau_conn_id
def execute(self, context: Context) -> str:
@@ -147,24 +161,40 @@ class TableauOperator(BaseOperator):
if self.resource == "tasks" and self.method == "run":
task_item = resource.get_by_id(resource_id)
- response_bytes = method(task_item)
+ try:
+ response_bytes = method(task_item)
+ except ServerResponseError as e:
+ if self.skip_on_conflict and e.code ==
RESOURCE_CONFLICT_ERROR_CODE:
+ raise AirflowSkipException(
+ f"Tableau task {resource_id} run is already queued
or running "
+ f"({e.code}: {e.summary}); skipping this task."
+ ) from e
+ raise
job_items = JobItem.from_response(response_bytes,
tableau_hook.server.namespace)
if not job_items:
raise ValueError("Tableau tasks.run returned no JobItem in
response")
job_id = job_items[0].id
elif self.method == "refresh":
- if self.incremental_refresh:
- try:
- response = method(resource_id, incremental=True)
- except TypeError as e:
- if "incremental" in str(e):
- raise AirflowOptionalProviderFeatureException(
- "Incremental refresh requires
tableauserverclient>=0.35. "
- "Please upgrade: pip install
'tableauserverclient>=0.35'"
- ) from e
- raise
- else:
- response = method(resource_id)
+ try:
+ if self.incremental_refresh:
+ try:
+ response = method(resource_id, incremental=True)
+ except TypeError as e:
+ if "incremental" in str(e):
+ raise AirflowOptionalProviderFeatureException(
+ "Incremental refresh requires
tableauserverclient>=0.35. "
+ "Please upgrade: pip install
'tableauserverclient>=0.35'"
+ ) from e
+ raise
+ else:
+ response = method(resource_id)
+ except ServerResponseError as e:
+ if self.skip_on_conflict and e.code ==
RESOURCE_CONFLICT_ERROR_CODE:
+ raise AirflowSkipException(
+ f"Tableau {self.resource} refresh is already
queued or running "
+ f"({e.code}: {e.summary}); skipping this task."
+ ) from e
+ raise
job_id = response.id
else:
response = method(resource_id)
diff --git a/providers/tableau/tests/unit/tableau/operators/test_tableau.py
b/providers/tableau/tests/unit/tableau/operators/test_tableau.py
index 759a0344c01..6b27de71d1e 100644
--- a/providers/tableau/tests/unit/tableau/operators/test_tableau.py
+++ b/providers/tableau/tests/unit/tableau/operators/test_tableau.py
@@ -19,8 +19,13 @@ from __future__ import annotations
from unittest.mock import Mock, patch
import pytest
+from tableauserverclient import ServerResponseError
-from airflow.providers.common.compat.sdk import AirflowException,
AirflowOptionalProviderFeatureException
+from airflow.providers.common.compat.sdk import (
+ AirflowException,
+ AirflowOptionalProviderFeatureException,
+ AirflowSkipException,
+)
from airflow.providers.tableau.hooks.tableau import TableauJobFinishCode
from airflow.providers.tableau.operators.tableau import TableauOperator
@@ -530,3 +535,144 @@ class TestTableauOperator:
exponential_backoff=True,
max_check_interval=120,
)
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def test_resource_refresh_skip_on_conflict_raises_skip_exception(self,
mock_tableau_hook):
+ """
+ Test that a 409093 Resource Conflict on a Tableau resource refresh is
turned into an
+ AirflowSkipException when skip_on_conflict=True.
+ """
+ mock_tableau_hook.get_all = Mock(return_value=self.mock_datasources)
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.datasources.refresh.side_effect =
ServerResponseError(
+ "409093", "Resource Conflict", "Job is already queued. Not queuing
a duplicate."
+ )
+
+ operator = TableauOperator(
+ find="ds_2",
+ resource="datasources",
+ skip_on_conflict=True,
+ **self.kwargs,
+ )
+
+ with pytest.raises(AirflowSkipException):
+ operator.execute(context={})
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def
test_incremental_resource_refresh_skip_on_conflict_raises_skip_exception(self,
mock_tableau_hook):
+ """
+ Test that a 409093 Resource Conflict on a Tableau incremental resource
refresh is turned into an
+ AirflowSkipException when skip_on_conflict=True.
+ """
+ mock_tableau_hook.get_all = Mock(return_value=self.mock_datasources)
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.datasources.refresh.side_effect =
ServerResponseError(
+ "409093", "Resource Conflict", "Job is already queued. Not queuing
a duplicate."
+ )
+ operator = TableauOperator(
+ find="ds_2",
+ resource="datasources",
+ incremental_refresh=True,
+ skip_on_conflict=True,
+ **self.kwargs,
+ )
+ with pytest.raises(AirflowSkipException):
+ operator.execute(context={})
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def test_resource_refresh_conflict_not_skipped_by_default(self,
mock_tableau_hook):
+ """
+ Test that a 409093 Resource Conflict on a Tableau resource refresh
still fails the
+ task when skip_on_conflict is left at its default (False), preserving
pre-existing
+ behavior.
+ """
+ mock_tableau_hook.get_all = Mock(return_value=self.mock_datasources)
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.datasources.refresh.side_effect =
ServerResponseError(
+ "409093", "Resource Conflict", "Job is already queued. Not queuing
a duplicate."
+ )
+
+ operator = TableauOperator(
+ find="ds_2",
+ resource="datasources",
+ **self.kwargs,
+ )
+
+ with pytest.raises(ServerResponseError):
+ operator.execute(context={})
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def
test_resource_refresh_skip_on_conflict_does_not_swallow_other_server_errors(self,
mock_tableau_hook):
+ """
+ Test that skip_on_conflict on a Tableau resource refresh only catches
the 409093
+ conflict code; other ServerResponseErrors still fail the task.
+ """
+ mock_tableau_hook.get_all = Mock(return_value=self.mock_datasources)
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.datasources.refresh.side_effect =
ServerResponseError(
+ "500000", "Internal Server Error", "Something else went wrong."
+ )
+
+ operator = TableauOperator(
+ find="ds_2",
+ resource="datasources",
+ skip_on_conflict=True,
+ **self.kwargs,
+ )
+
+ with pytest.raises(ServerResponseError):
+ operator.execute(context={})
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def test_task_run_skip_on_conflict_raises_skip_exception(self,
mock_tableau_hook):
+ """
+ Test that a 409093 Resource Conflict on a Tableau task run is turned
into an
+ AirflowSkipException when skip_on_conflict=True.
+ """
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.tasks.get_by_id = Mock(return_value=Mock())
+ mock_tableau_hook.server.tasks.run.side_effect = ServerResponseError(
+ "409093", "Resource Conflict", "Job is already queued. Not queuing
a duplicate."
+ )
+
+ kwargs = {**self.kwargs, "method": "run", "match_with": "id"}
+ operator = TableauOperator(find="task-abc", resource="tasks",
skip_on_conflict=True, **kwargs)
+
+ with pytest.raises(AirflowSkipException):
+ operator.execute(context={})
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def test_task_run_conflict_not_skipped_by_default(self, mock_tableau_hook):
+ """
+ Test that a 409093 Resource Conflict on a Tableau task run still fails
the task when
+ skip_on_conflict is left at its default (False), preserving
pre-existing behavior.
+ """
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.tasks.get_by_id = Mock(return_value=Mock())
+ mock_tableau_hook.server.tasks.run.side_effect = ServerResponseError(
+ "409093", "Resource Conflict", "Job is already queued. Not queuing
a duplicate."
+ )
+
+ kwargs = {**self.kwargs, "method": "run", "match_with": "id"}
+ operator = TableauOperator(find="task-abc", resource="tasks", **kwargs)
+
+ with pytest.raises(ServerResponseError):
+ operator.execute(context={})
+
+ @patch("airflow.providers.tableau.operators.tableau.TableauHook")
+ def
test_task_run_skip_on_conflict_does_not_swallow_other_server_errors(self,
mock_tableau_hook):
+ """
+ Test that skip_on_conflict on a Tableau task run only catches the
409093 conflict
+ code; other ServerResponseErrors still fail the task.
+ """
+ mock_tableau_hook.return_value.__enter__ =
Mock(return_value=mock_tableau_hook)
+ mock_tableau_hook.server.tasks.get_by_id = Mock(return_value=Mock())
+ mock_tableau_hook.server.tasks.run.side_effect = ServerResponseError(
+ "500000", "Internal Server Error", "Something else went wrong."
+ )
+
+ kwargs = {**self.kwargs, "method": "run", "match_with": "id"}
+ operator = TableauOperator(find="task-abc", resource="tasks",
skip_on_conflict=True, **kwargs)
+
+ with pytest.raises(ServerResponseError):
+ operator.execute(context={})