This is an automated email from the ASF dual-hosted git repository.
shahar1 pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this
push:
new c64c0374e39 [airflow-ctl/v0-1-test] Stop airflowctl from resending
writes after ambiguous failures (#74382) (#76072)
c64c0374e39 is described below
commit c64c0374e393072384cf340b845f96267a5152ea
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 30 18:21:28 2026 +0000
[airflow-ctl/v0-1-test] Stop airflowctl from resending writes after
ambiguous failures (#74382) (#76072)
Retry writes only when the server could not have received them.
Warn when a write may have succeeded despite an error.
(cherry picked from commit 60a240a45114ec1d7f4583f051a005eabe706473)
Co-authored-by: Shawn Sun <[email protected]>
---
airflow-ctl/docs/cli-and-env-variables-ref.rst | 5 ++
airflow-ctl/src/airflowctl/api/client.py | 31 ++++++++--
airflow-ctl/src/airflowctl/ctl/cli_config.py | 22 ++++++-
airflow-ctl/tests/airflow_ctl/api/test_client.py | 69 ++++++++++++++++++++++
.../tests/airflow_ctl/ctl/test_cli_config.py | 46 +++++++++++++++
5 files changed, 168 insertions(+), 5 deletions(-)
diff --git a/airflow-ctl/docs/cli-and-env-variables-ref.rst
b/airflow-ctl/docs/cli-and-env-variables-ref.rst
index 4841a801188..0e242dfdb7a 100644
--- a/airflow-ctl/docs/cli-and-env-variables-ref.rst
+++ b/airflow-ctl/docs/cli-and-env-variables-ref.rst
@@ -70,6 +70,11 @@ Environment Variables
only used if you are using the Airflow API and have not set up
authentication using a different method. The default value is 3.
+ Only read-only calls (``GET``, ``HEAD`` and ``OPTIONS``) are retried after
a server error or
+ a lost response. Calls that change state, such as ``POST``, ``PUT``,
``PATCH`` and ``DELETE``,
+ are retried only when the connection to the server could not be
established, so that a
+ change the server may already have applied is not sent twice.
+
.. envvar:: AIRFLOW_CLI_API_RETRY_WAIT_MIN
The minimum amount of time to wait between API retries in seconds.
diff --git a/airflow-ctl/src/airflowctl/api/client.py
b/airflow-ctl/src/airflowctl/api/client.py
index 5ca17140357..71283b798b4 100644
--- a/airflow-ctl/src/airflowctl/api/client.py
+++ b/airflow-ctl/src/airflowctl/api/client.py
@@ -318,12 +318,35 @@ class BearerAuth(httpx.Auth):
yield request
+_REPLAY_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
+
+
+def _request_method(error: httpx.HTTPError) -> str | None:
+ """
+ Return the method of the request behind ``error``.
+
+ httpx leaves ``.request`` unset for some errors and reading it then
raises, so return None for those.
+ """
+ try:
+ return error.request.method
+ except RuntimeError:
+ return None
+
+
def _should_retry_api_request(exception: BaseException) -> bool:
- """Determine if an API request should be retried based on the exception
type."""
- if isinstance(exception, httpx.HTTPStatusError):
- return exception.response.status_code >= 500
+ """
+ Determine if an API request can be sent again after it failed.
- return isinstance(exception, httpx.RequestError)
+ A request that never reached the server can always be sent again.
Otherwise the server may
+ already have applied it, so only read-only methods are sent again.
+ """
+ if isinstance(exception, (httpx.ConnectError, httpx.ConnectTimeout,
httpx.PoolTimeout)):
+ return True
+ if not isinstance(exception, (httpx.HTTPStatusError, httpx.RequestError)):
+ return False
+ if isinstance(exception, httpx.HTTPStatusError) and
exception.response.status_code < 500:
+ return False
+ return _request_method(exception) in _REPLAY_SAFE_METHODS
def _get_int_env(name: str, default: int, minimum: int = 0) -> int:
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 869d7c96ba9..e5108aefd59 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -38,7 +38,7 @@ import httpx
import rich
import airflowctl.api.datamodels.generated as generated_datamodels
-from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind,
provide_api_client
+from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind,
_request_method, provide_api_client
from airflowctl.api.operations import BaseOperations, ServerResponseError
from airflowctl.ctl.console_formatting import AirflowConsole, is_data_sequence
from airflowctl.ctl.utils.yaml import safe_load
@@ -66,6 +66,22 @@ def lazy_load_command(import_path: str) -> Callable:
return command
+_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
+
+
+def _warn_if_write_may_have_applied(error: httpx.HTTPError) -> None:
+ """Warn that a failed write is not retried because the server may already
have applied it."""
+ if isinstance(error, httpx.HTTPStatusError) and error.response.status_code
< 500:
+ return
+ method = _request_method(error)
+ if method in _WRITE_METHODS:
+ rich.print(
+ f"[yellow]The {method} request failed, but it may already have
been applied on the server. "
+ "Check the server state before retrying.[/yellow]",
+ file=sys.stderr,
+ )
+
+
def safe_call_command(function: Callable, args: Iterable[Arg]) -> None:
if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
rich.print(
@@ -91,11 +107,13 @@ def safe_call_command(function: Callable, args:
Iterable[Arg]) -> None:
f"[red]Server response error: {e}. "
"Please check if the server is running and the API URL is
correct.[/red]"
)
+ _warn_if_write_may_have_applied(e)
sys.exit(1)
except httpx.ReadTimeout as e:
rich.print(f"[red]Read timeout error: {e}[/red]")
if "timed out" in str(e):
rich.print("[red]Please check if the server is running and the API
ready to accept calls.[/red]")
+ _warn_if_write_may_have_applied(e)
sys.exit(1)
except ServerResponseError as e:
rich.print(f"Server response error: {e}")
@@ -105,6 +123,7 @@ def safe_call_command(function: Callable, args:
Iterable[Arg]) -> None:
"Please check the command and its parameters. "
"If you need help, run the command with --help."
)
+ _warn_if_write_may_have_applied(e)
sys.exit(1)
# Must stay below ``ServerResponseError``, which subclasses it. Responses
the client could not
# turn into a ``ServerResponseError`` -- a 3xx, or a 4xx/5xx whose body is
not JSON -- reach us
@@ -116,6 +135,7 @@ def safe_call_command(function: Callable, args:
Iterable[Arg]) -> None:
"[red]The server answered with a redirect, which airflowctl
does not follow. "
"Please check that the API URL you logged in with points at
the Airflow API server.[/red]"
)
+ _warn_if_write_may_have_applied(e)
sys.exit(1)
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py
b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index 049c384b652..a87616efae3 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -34,12 +34,15 @@ import structlog.testing
import tenacity
import time_machine
from httpx import URL
+from tenacity import stop_after_attempt, wait_none
from airflowctl.api.client import (
Client,
ClientKind,
Credentials,
_bounded_get_new_password,
+ _request_method,
+ _should_retry_api_request,
get_client,
get_json_error,
provide_api_client,
@@ -250,6 +253,72 @@ class TestClient:
assert client.base_url == URL(expected_base_url)
+class TestRequestRetries:
+ @pytest.fixture(autouse=True)
+ def configure_retries(self, monkeypatch):
+ monkeypatch.setattr(Client.request.retry, "wait", wait_none())
+ monkeypatch.setattr(Client.request.retry, "stop",
stop_after_attempt(3))
+
+ @pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "POST",
"PUT", "PATCH", "DELETE", "TRACE"])
+ @pytest.mark.parametrize("status_code", [500, 503])
+ @pytest.mark.parametrize("json_response", [True, False])
+ def test_server_errors_only_replay_safe_methods(self, method, status_code,
json_response):
+ requests = []
+
+ def handle_request(request):
+ requests.append(request)
+ if json_response:
+ return httpx.Response(status_code, json={"detail": "boom"})
+ return httpx.Response(status_code, text="boom")
+
+ with Client(base_url="http://localhost",
transport=httpx.MockTransport(handle_request)) as client:
+ with pytest.raises(httpx.HTTPStatusError):
+ client.request(method, "/operation")
+
+ assert len(requests) == (3 if method in {"GET", "HEAD", "OPTIONS"}
else 1)
+
+ @pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "POST",
"PUT", "PATCH", "DELETE", "TRACE"])
+ @pytest.mark.parametrize(
+ ("error_type", "before_send"),
+ [
+ (httpx.ConnectError, True),
+ (httpx.ConnectTimeout, True),
+ (httpx.PoolTimeout, True),
+ (httpx.ReadTimeout, False),
+ (httpx.WriteTimeout, False),
+ (httpx.ReadError, False),
+ (httpx.WriteError, False),
+ (httpx.RemoteProtocolError, False),
+ ],
+ )
+ def test_transport_errors_only_replay_safe_requests(self, method,
error_type, before_send):
+ requests = []
+
+ def handle_request(request):
+ requests.append(request)
+ raise error_type("connection failed", request=request)
+
+ with Client(base_url="http://localhost",
transport=httpx.MockTransport(handle_request)) as client:
+ with pytest.raises(error_type):
+ client.request(method, "/operation")
+
+ assert len(requests) == (3 if before_send or method in {"GET", "HEAD",
"OPTIONS"} else 1)
+
+ @pytest.mark.parametrize("error", [httpx.ReadTimeout("timed out"),
httpx.ReadError("disconnected")])
+ def test_missing_request_does_not_allow_replay(self, error):
+ assert not _should_retry_api_request(error)
+
+
+class TestRequestMethod:
+ def test_returns_the_method_of_the_attached_request(self):
+ error = httpx.ReadTimeout("timed out", request=httpx.Request("PATCH",
"http://localhost/dags/d"))
+
+ assert _request_method(error) == "PATCH"
+
+ def test_returns_none_when_httpx_attached_no_request(self):
+ assert _request_method(httpx.ReadTimeout("timed out")) is None
+
+
class TestCredentials:
@patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
@patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_SAVE"})
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
index 6ba7ac3e9f2..8955e6ab406 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -547,6 +547,52 @@ class TestCommandFactory:
class TestCliConfigMethods:
+ @pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS", "POST",
"PUT", "PATCH", "DELETE"])
+ @pytest.mark.parametrize(
+ ("failure", "ambiguous"),
+ [
+ (httpx.ReadTimeout, True),
+ (httpx.ReadError, True),
+ (httpx.RemoteProtocolError, True),
+ ("json-500", True),
+ ("plain-503", True),
+ ("json-400", False),
+ ("plain-401", False),
+ ("redirect", False),
+ ("connection", False),
+ ],
+ )
+ def test_safe_call_command_warns_only_for_ambiguous_writes(self, method,
failure, ambiguous, capsys):
+ request = httpx.Request(method, "http://localhost/operation")
+ error: Exception
+ if failure == "connection":
+ error = AirflowCtlConnectionException("Connection refused. Is the
API server running?")
+ elif isinstance(failure, str):
+ status_code = {
+ "json-500": 500,
+ "plain-503": 503,
+ "json-400": 400,
+ "plain-401": 401,
+ "redirect": 302,
+ }[failure]
+ response = httpx.Response(status_code, request=request,
json={"detail": "boom"})
+ error_type = ServerResponseError if failure.startswith("json-")
else httpx.HTTPStatusError
+ error = error_type("request failed", request=request,
response=response)
+ else:
+ error = failure("request failed", request=request)
+
+ def raise_error(_args):
+ raise error
+
+ with pytest.raises(SystemExit) as ctx:
+ safe_call_command(raise_error, args=argparse.Namespace())
+
+ assert ctx.value.code == 1
+ stderr = " ".join(capsys.readouterr().err.split())
+ warning_expected = ambiguous and method in {"POST", "PUT", "PATCH",
"DELETE"}
+ assert ("may already have been applied" in stderr) is warning_expected
+ assert ("Check the server state before retrying" in stderr) is
warning_expected
+
@pytest.mark.parametrize(
"raised_exception",
[