This is an automated email from the ASF dual-hosted git repository.

amoghrajesh 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 a8031341e0e Resubmit lost Databricks runs instead of failing every 
retry (#76718)
a8031341e0e is described below

commit a8031341e0e52baf3fb0a88b79767cbf7f6b1eba
Author: Amogh Desai <[email protected]>
AuthorDate: Tue Nov 10 10:08:08 2026 +0530

    Resubmit lost Databricks runs instead of failing every retry (#76718)
---
 .../src/airflow/providers/databricks/exceptions.py |  5 +-
 .../providers/databricks/hooks/databricks_base.py  |  6 +-
 .../providers/databricks/operators/databricks.py   | 38 +++++++++--
 .../tests/unit/databricks/hooks/test_databricks.py | 25 ++++++++
 .../unit/databricks/operators/test_databricks.py   | 73 +++++++++++++++++++++-
 5 files changed, 136 insertions(+), 11 deletions(-)

diff --git 
a/providers/databricks/src/airflow/providers/databricks/exceptions.py 
b/providers/databricks/src/airflow/providers/databricks/exceptions.py
index 01d47523929..88e44011a0b 100644
--- a/providers/databricks/src/airflow/providers/databricks/exceptions.py
+++ b/providers/databricks/src/airflow/providers/databricks/exceptions.py
@@ -51,9 +51,12 @@ class 
DatabricksAgentInvocationTimeout(DatabricksAgentInvocationError):
 class DatabricksApiError(AirflowException):
     """Raised when a Databricks REST API call returns an error response."""
 
-    def __init__(self, message: str, *, http_status_code: int | None = None) 
-> None:
+    def __init__(
+        self, message: str, *, http_status_code: int | None = None, 
error_code: str | None = None
+    ) -> None:
         super().__init__(message)
         self.http_status_code = http_status_code
+        self.error_code = error_code
 
 
 class DatabricksUnityMCPError(DatabricksApiError):
diff --git 
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
 
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
index d5dcedaa451..f9a637c4f12 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks_base.py
@@ -1388,7 +1388,11 @@ class BaseDatabricksHook(BaseHook):
         except requests_exceptions.HTTPError as e:
             if wrap_http_errors:
                 msg = f"Response: {e.response.content.decode()}, Status Code: 
{e.response.status_code}"
-                raise DatabricksApiError(msg, 
http_status_code=e.response.status_code) from e
+                raise DatabricksApiError(
+                    msg,
+                    http_status_code=e.response.status_code,
+                    error_code=self._get_error_code(e) or None,
+                ) from e
             raise
 
     async def _a_do_api_call(self, endpoint_info: tuple[str, str], json: 
dict[str, Any] | None = None):
diff --git 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
index 0f01fa2ae55..b17259b6775 100644
--- 
a/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
+++ 
b/providers/databricks/src/airflow/providers/databricks/operators/databricks.py
@@ -23,6 +23,7 @@ import ast
 import copy
 import hashlib
 import json as json_utils
+import re
 import time
 from abc import ABC, abstractmethod
 from collections.abc import Mapping, Sequence
@@ -82,6 +83,19 @@ XCOM_JOB_ID_KEY = "job_id"
 XCOM_RUN_PAGE_URL_KEY = "run_page_url"
 XCOM_STATEMENT_ID_KEY = "statement_id"
 
+# jobs/runs/get answers 400 INVALID_PARAMETER_VALUE for a run it no longer 
knows about, never 404.
+# These are the two message forms the Databricks SDK overrides to 
ResourceDoesNotExist; the code
+# alone also covers malformed input, which must stay an error rather than 
trigger a resubmit.
+_RUN_MISSING_MESSAGE_PATTERN = re.compile(r"Run .* does not exist|Run: .* in 
job: .* doesn't exist")
+
+
+def _is_run_missing_error(exc: DatabricksApiError) -> bool:
+    """Whether ``exc`` means Databricks has no record of the run (expired, 
deleted, or foreign)."""
+    if exc.error_code == "RESOURCE_DOES_NOT_EXIST":
+        return True
+    return exc.error_code == "INVALID_PARAMETER_VALUE" and 
bool(_RUN_MISSING_MESSAGE_PATTERN.search(str(exc)))
+
+
 # Mapping from Airflow TriggerRule to Databricks RunIf condition.
 # Only non-default rules are included; ALL_SUCCESS is the Databricks default
 # and is omitted to keep the task JSON minimal.
@@ -1024,9 +1038,15 @@ class DatabricksSubmitRunOperator(ResumableJobMixin, 
BaseOperator):
         try:
             run_info = self._hook.get_run(external_id)
         except DatabricksApiError as e:
-            # A stored run whose history expired (or was deleted) returns 404. 
Report a sentinel that
-            # is neither active nor succeeded so the mixin resubmits fresh 
instead of failing the task.
-            if e.http_status_code == 404:
+            # Neither active nor succeeded, so the mixin resubmits instead of 
failing every retry.
+            if _is_run_missing_error(e):
+                self.log.warning(
+                    "Databricks has no record of run %s, so a fresh run will 
be submitted. "
+                    "If the workspace connection changed, the original run may 
still be going there. "
+                    "Databricks said: %s",
+                    external_id,
+                    e,
+                )
                 return "NOT_FOUND:"
             raise
         state = RunState(**run_info["state"])
@@ -1520,9 +1540,15 @@ class DatabricksRunNowOperator(ResumableJobMixin, 
BaseOperator):
         try:
             run_info = self._hook.get_run(external_id)
         except DatabricksApiError as e:
-            # A stored run whose history expired (or was deleted) returns 404. 
Report a sentinel that
-            # is neither active nor succeeded so the mixin resubmits fresh 
instead of failing the task.
-            if e.http_status_code == 404:
+            # Neither active nor succeeded, so the mixin resubmits instead of 
failing every retry.
+            if _is_run_missing_error(e):
+                self.log.warning(
+                    "Databricks has no record of run %s, so a fresh run will 
be submitted. "
+                    "If the workspace connection changed, the original run may 
still be going there. "
+                    "Databricks said: %s",
+                    external_id,
+                    e,
+                )
                 return "NOT_FOUND:"
             raise
         state = RunState(**run_info["state"])
diff --git 
a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py 
b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
index ac6d0705777..b73a6990de9 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
@@ -402,6 +402,31 @@ class TestDatabricksHook:
         # simple backcompat check, so that we do not break existing code that 
expects AirflowException
         assert isinstance(exc_info.value, AirflowException)
 
+    @pytest.mark.parametrize(
+        ("body", "expected_error_code"),
+        [
+            (
+                {"error_code": "INVALID_PARAMETER_VALUE", "message": "Run 1 
does not exist."},
+                "INVALID_PARAMETER_VALUE",
+            ),
+            ({"message": "no code in this body"}, None),
+        ],
+    )
+    @mock.patch("airflow.providers.databricks.hooks.databricks_base.requests")
+    def test_failing_do_api_call_carries_error_code(self, mock_requests, body, 
expected_error_code):
+        hook = DatabricksHook(retry_args=DEFAULT_RETRY_ARGS)
+        response = mock.MagicMock()
+        response.status_code = 400
+        response.json.return_value = body
+        response.content = json.dumps(body).encode()
+        response.raise_for_status.side_effect = 
requests_exceptions.HTTPError(response=response)
+        mock_requests.post.side_effect = itertools.repeat(response)
+
+        with pytest.raises(DatabricksApiError) as exc_info:
+            hook._do_api_call(SUBMIT_RUN_ENDPOINT, {})
+
+        assert exc_info.value.error_code == expected_error_code
+
     def test_do_api_call_succeeds_after_retrying(self):
         hook = DatabricksHook(retry_args=DEFAULT_RETRY_ARGS)
 
diff --git 
a/providers/databricks/tests/unit/databricks/operators/test_databricks.py 
b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
index 40672c42f2e..1f284e530f9 100644
--- a/providers/databricks/tests/unit/databricks/operators/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/operators/test_databricks.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import copy
 import hashlib
+import json
 from datetime import datetime, timedelta
 from typing import Any
 from unittest import mock
@@ -47,6 +48,7 @@ from airflow.providers.databricks.operators.databricks import 
(
     DatabricksSubmitRunOperator,
     DatabricksTaskBaseOperator,
     DatabricksTaskOperator,
+    _is_run_missing_error,
 )
 from airflow.providers.databricks.triggers.databricks import (
     DatabricksExecutionTrigger,
@@ -345,6 +347,39 @@ def make_run_with_state_mock(
     )
 
 
+def make_api_error(status_code: int, error_code: str, message: str) -> 
DatabricksApiError:
+    body = json.dumps({"error_code": error_code, "message": message})
+    return DatabricksApiError(
+        f"Response: {body}, Status Code: {status_code}",
+        http_status_code=status_code,
+        error_code=error_code,
+    )
+
+
+class TestIsRunMissingError:
+    @pytest.mark.parametrize(
+        ("status_code", "error_code", "message", "expected"),
+        [
+            (400, "INVALID_PARAMETER_VALUE", "Run 999 does not exist.", True),
+            (400, "INVALID_PARAMETER_VALUE", "Run: 999 in job: 42 doesn't 
exist", True),
+            (400, "RESOURCE_DOES_NOT_EXIST", "Run 999 does not exist.", True),
+            (404, "RESOURCE_DOES_NOT_EXIST", "Run 999 does not exist.", True),
+            # A status code on its own proves nothing: an unauthenticated 
proxy can answer 404.
+            (404, "", "Run 999 does not exist.", False),
+            # Same code, but bad input rather than a lost run: must stay an 
error.
+            (400, "INVALID_PARAMETER_VALUE", "Invalid value for run_id: 
'abc'", False),
+            (403, "PERMISSION_DENIED", "User not authorized", False),
+            (500, "INTERNAL_ERROR", "Run 999 does not exist.", False),
+        ],
+    )
+    def test_detects_only_a_lost_run(self, status_code, error_code, message, 
expected):
+        assert _is_run_missing_error(make_api_error(status_code, error_code, 
message)) is expected
+
+    def test_unparsed_error_code_is_not_a_lost_run(self):
+        exc = DatabricksApiError("Response: Bad Gateway, Status Code: 502", 
http_status_code=502)
+        assert _is_run_missing_error(exc) is False
+
+
 class TestDatabricksCreateJobsOperator:
     def test_init_with_named_parameters(self):
         """
@@ -2229,7 +2264,7 @@ class TestDatabricksSubmitRunOperatorDurable:
         db_mock = db_mock_class.return_value
         db_mock.submit_run.return_value = RUN_ID
         db_mock.get_run.side_effect = [
-            DatabricksApiError("Response: RESOURCE_DOES_NOT_EXIST, Status 
Code: 404", http_status_code=404),
+            make_api_error(400, "INVALID_PARAMETER_VALUE", "Run 999 does not 
exist."),
             self._state("TERMINATED", "SUCCESS"),
         ]
         task_store = MagicMock(spec_set=["get", "set"])
@@ -2240,6 +2275,23 @@ class TestDatabricksSubmitRunOperatorDurable:
         task_store.set.assert_called_once_with("databricks_run_id", RUN_ID)
         assert op.run_id == RUN_ID
 
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def 
test_does_not_resubmit_when_the_stored_run_id_is_rejected_as_malformed(self, 
db_mock_class):
+        op = DatabricksSubmitRunOperator(
+            task_id=TASK_ID, json={"new_cluster": NEW_CLUSTER, 
"notebook_task": NOTEBOOK_TASK}
+        )
+        db_mock = db_mock_class.return_value
+        db_mock.get_run.side_effect = make_api_error(
+            400, "INVALID_PARAMETER_VALUE", "Invalid value for run_id: 'abc'"
+        )
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = 999
+
+        with pytest.raises(DatabricksApiError):
+            op.execute(self._context(task_store))
+
+        db_mock.submit_run.assert_not_called()
+
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_durable_false_never_touches_task_state_store(self, db_mock_class):
         op = DatabricksSubmitRunOperator(
@@ -3584,9 +3636,9 @@ class TestDatabricksRunNowOperatorDurable:
         op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
         db_mock = db_mock_class.return_value
         db_mock.run_now.return_value = RUN_ID
-        # the stored run's history has expired -> get_run 404s; the fresh run 
then polls to success.
+        # the stored run's history has expired -> get_run rejects it; the 
fresh run polls to success.
         db_mock.get_run.side_effect = [
-            DatabricksApiError("Response: RESOURCE_DOES_NOT_EXIST, Status 
Code: 404", http_status_code=404),
+            make_api_error(400, "INVALID_PARAMETER_VALUE", "Run 999 does not 
exist."),
             self._state("TERMINATED", "SUCCESS"),
         ]
         task_store = MagicMock(spec_set=["get", "set"])
@@ -3598,6 +3650,21 @@ class TestDatabricksRunNowOperatorDurable:
         task_store.set.assert_called_once_with("databricks_run_now_id", RUN_ID)
         assert op.run_id == RUN_ID
 
+    
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
+    def 
test_does_not_resubmit_when_the_stored_run_id_is_rejected_as_malformed(self, 
db_mock_class):
+        op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID)
+        db_mock = db_mock_class.return_value
+        db_mock.get_run.side_effect = make_api_error(
+            400, "INVALID_PARAMETER_VALUE", "Invalid value for run_id: 'abc'"
+        )
+        task_store = MagicMock(spec_set=["get", "set"])
+        task_store.get.return_value = 999
+
+        with pytest.raises(DatabricksApiError):
+            op.execute(self._context(task_store))
+
+        db_mock.run_now.assert_not_called()
+
     
@mock.patch("airflow.providers.databricks.operators.databricks.DatabricksHook")
     def test_durable_false_never_touches_task_state_store(self, db_mock_class):
         op = DatabricksRunNowOperator(task_id=TASK_ID, job_id=JOB_ID, 
durable=False)

Reply via email to