This is an automated email from the ASF dual-hosted git repository.
Lee-W 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 787e323ca46 Support custom redaction and message length cap in
LLMRetryPolicy (#70830)
787e323ca46 is described below
commit 787e323ca462841a0c098b2be494c20c4b585642
Author: Wei Lee <[email protected]>
AuthorDate: Fri Aug 7 22:57:50 2026 +0800
Support custom redaction and message length cap in LLMRetryPolicy (#70830)
---
providers/common/ai/docs/retry_policies.rst | 74 +++++++++++--
.../airflow/providers/common/ai/policies/retry.py | 79 +++++++++----
.../ai/tests/unit/common/ai/policies/test_retry.py | 122 +++++++++++++++++++++
3 files changed, 244 insertions(+), 31 deletions(-)
diff --git a/providers/common/ai/docs/retry_policies.rst
b/providers/common/ai/docs/retry_policies.rst
index b498050b685..c1962e41ce8 100644
--- a/providers/common/ai/docs/retry_policies.rst
+++ b/providers/common/ai/docs/retry_policies.rst
@@ -71,8 +71,9 @@ How it works
When a task fails, ``LLMRetryPolicy``:
1. Sends the exception message to the configured LLM. By default, the message
- is first masked through Airflow's secrets masker (see ``redact_exception``
- below) before it is added to the prompt.
+ is first masked through Airflow's secrets masker (see ``redactor`` below)
+ and truncated to ``max_exception_length`` characters before it is added
+ to the prompt.
2. The LLM classifies the error into a category (``rate_limit``, ``auth``,
``network``, ``data``, ``transient``, ``permanent``)
3. Based on the classification, returns RETRY (with a suggested delay) or FAIL
@@ -158,22 +159,71 @@ Parameters
* - ``timeout``
- 30.0
- Max seconds to wait for the LLM response before falling back.
+ * - ``redactor``
+ - None (uses ``redact_registered_secrets``)
+ - Callable ``(str) -> str`` applied to the exception's string
+ representation before it is added to the classification prompt. The
+ default only masks values already registered via ``mask_secret()``
+ (e.g. connection passwords Airflow captured while resolving the
+ failing task's connections) -- it is not general-purpose PII
+ detection and will not catch arbitrary sensitive strings that were
+ never registered as secrets. Passing a custom callable **replaces**
+ the default masker entirely rather than stacking on top of it.
* - ``redact_exception``
- True
- - When ``True``, the exception's string representation is passed through
- Airflow's secrets masker before being added to the classification
- prompt. This only masks values already registered via
- ``mask_secret()`` (e.g. connection passwords Airflow captured while
- resolving the failing task's connections) -- it is not general-purpose
- PII detection and will not catch arbitrary sensitive strings that were
- never registered as secrets. Set to ``False`` only if you are certain
- your exception messages contain no sensitive data and you need the
- raw text for accurate classification.
+ - Whether to redact the exception's string representation before it is
+ added to the classification prompt. Set to ``False`` to disable
+ redaction entirely. Raises ``ValueError`` at construction time if
+ combined with an explicit ``redactor``.
+ * - ``max_exception_length``
+ - 4096
+ - Maximum number of characters of the (already redacted) exception
+ message included in the prompt. Longer messages are truncated with a
+ trailing ``"... (truncated)"`` marker. Must be a positive integer.
+
+Custom redactors
+----------------
+
+The default ``redactor`` only masks values already registered with Airflow's
+secrets masker via ``mask_secret()``. It does not detect free-text PII --
+email addresses, customer names, account numbers -- that were never
+registered as secrets. If your task's exception messages can contain that
+kind of data, supply your own ``redactor`` callable. It **replaces** the
+default masker rather than running in addition to it, so combine your own
+logic with
:func:`~airflow.providers.common.ai.policies.retry.redact_registered_secrets`
+yourself if you still want known-secret masking too:
+
+.. code-block:: python
+
+ import re
+
+ from airflow.providers.common.ai.policies.retry import
redact_registered_secrets
+
+ EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
+
+
+ def redact_emails_and_secrets(message: str) -> str:
+ return redact_registered_secrets(EMAIL_RE.sub("<email>", message))
+
+
+ llm_policy = LLMRetryPolicy(
+ llm_conn_id="pydanticai_default",
+ redactor=redact_emails_and_secrets,
+ max_exception_length=2048, # keep long tracebacks from inflating
token cost
+ )
+
+To disable redaction entirely (for example, if you are certain your
+exception messages contain no sensitive data and need the raw text for
+accurate classification), pass ``redact_exception=False``:
+
+.. code-block:: python
+
+ LLMRetryPolicy(llm_conn_id="pydanticai_default", redact_exception=False)
Local LLM support
-----------------
-By default, ``redact_exception`` already masks known secrets before the
+By default, the built-in ``redactor`` already masks known secrets before the
exception data reaches the LLM provider. For environments where exception
data must not leave your own infrastructure at all -- even in masked form --
point to a local model via Ollama or vLLM instead, so the classification
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
b/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
index 768e16270e2..864e5b6dad4 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
@@ -24,7 +24,7 @@ from __future__ import annotations
import logging
from datetime import timedelta
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, cast
from pydantic import BaseModel
@@ -43,12 +43,14 @@ except ImportError:
) from None
if TYPE_CHECKING:
+ from collections.abc import Callable
+
from airflow.sdk.definitions.context import Context
from airflow.sdk.definitions.retry_policy import RetryRule
log = logging.getLogger(__name__)
-__all__ = ["ErrorClassification", "LLMRetryPolicy"]
+__all__ = ["ErrorClassification", "LLMRetryPolicy",
"redact_registered_secrets"]
DEFAULT_INSTRUCTIONS = (
"You are an error classifier for a data pipeline system. "
@@ -79,6 +81,12 @@ class ErrorClassification(BaseModel):
"""Brief explanation of the classification decision."""
+def redact_registered_secrets(message: str) -> str:
+ """Mask values registered via ``mask_secret()``; the default ``redactor``
for :class:`LLMRetryPolicy`."""
+ # redact() is typed for arbitrary containers; a str in always yields a str
out.
+ return cast("str", redact(message))
+
+
class LLMRetryPolicy(RetryPolicy):
"""
Retry policy that uses an LLM to classify errors and decide retry
behaviour.
@@ -102,12 +110,24 @@ class LLMRetryPolicy(RetryPolicy):
falling back. Defaults to 30s. The LLM provider's own timeout
(e.g. 600s for Anthropic) is much longer; this keeps the retry
decision path fast even when the provider is degraded.
- :param redact_exception: When ``True`` (the default), the exception's
- string representation is passed through Airflow's secrets masker
- (:func:`~airflow.sdk.log.redact`) before being added to the prompt.
- Set to ``False`` only if you are certain your exception messages
- contain no sensitive data and you need the raw text for accurate
- classification.
+ :param redactor: Callable applied to the exception's string representation
+ before it is added to the classification prompt. Defaults to
+
:func:`~airflow.providers.common.ai.policies.retry.redact_registered_secrets`,
+ which only masks values already registered via ``mask_secret()``.
+ Pass a custom callable to replace the default masking entirely --
+ for example to redact free-text PII the secrets masker cannot see.
+ To disable masking altogether, use ``redact_exception=False`` --
+ not ``redactor=None``.
+ :param redact_exception: Whether to redact the exception's string
+ representation before it is added to the classification prompt.
+ Defaults to ``True``. Set to ``False`` to send the raw exception
+ text as-is. Passing ``redact_exception=False`` together with
+ an explicit ``redactor`` raises ``ValueError`` at construction time,
+ since the two settings would otherwise conflict silently.
+ :param max_exception_length: Maximum number of characters of the
+ (already redacted) exception message included in the prompt. Longer
+ messages are truncated with a trailing ``"... (truncated)"`` marker.
+ Must be a positive integer. Defaults to 4096.
.. warning::
The exception's string representation is sent to the configured
@@ -115,15 +135,18 @@ class LLMRetryPolicy(RetryPolicy):
etc.) as part of the classification prompt, so it may leak whatever
the failing task put in the exception message — connection strings,
credential fragments, PII, or other secrets. By default
- ``_classify()`` runs the message through Airflow's secrets masker
- (:func:`~airflow.sdk.log.redact`, controlled by ``redact_exception``),
- which masks values already registered via ``mask_secret()`` (for
- example, connection passwords Airflow captured while resolving the
- failing task's connections). This does **not** perform
- general-purpose PII detection and will not catch arbitrary sensitive
- strings that were never registered as secrets. You are still
- responsible for confirming that your task's exception messages are
- safe to send to a third-party LLM provider.
+ ``_classify()`` runs the message through
+
:func:`~airflow.providers.common.ai.policies.retry.redact_registered_secrets`
+ via ``redactor``, which masks values already registered via
+ ``mask_secret()`` (for example, connection passwords Airflow
+ captured while resolving the failing task's connections). This does
+ **not** perform general-purpose PII detection and will not catch
+ arbitrary sensitive strings that were never registered as secrets --
+ for free-text PII (emails, customer names, etc.) supply your own
+ ``redactor``, or pass ``redact_exception=False`` to disable
+ redaction altogether. You are still responsible for confirming that
+ your task's exception messages are safe to send to a third-party
+ LLM provider.
"""
def __init__(
@@ -134,14 +157,29 @@ class LLMRetryPolicy(RetryPolicy):
fallback_rules: list[RetryRule] | None = None,
timeout: float = 30.0,
*,
+ redactor: Callable[[str], str] | None = None,
redact_exception: bool = True,
+ max_exception_length: int = 4096,
) -> None:
+ if max_exception_length <= 0:
+ raise ValueError(f"max_exception_length must be a positive
integer, got {max_exception_length}")
+ if not redact_exception and redactor is not None:
+ raise ValueError(
+ "redactor must not be set when redact_exception=False --
passing an explicit "
+ "redactor while also disabling redaction is contradictory.
Either drop "
+ "redact_exception=False to keep using redactor, or drop
redactor to disable "
+ "redaction entirely."
+ )
self.llm_conn_id = llm_conn_id
self.model_id = model_id
self.instructions = instructions or DEFAULT_INSTRUCTIONS
self.fallback_rules = fallback_rules
self.timeout = timeout
+ self.redactor: Callable[[str], str] | None = (
+ None if not redact_exception else redactor if redactor is not None
else redact_registered_secrets
+ )
self.redact_exception = redact_exception
+ self.max_exception_length = max_exception_length
def evaluate(
self,
@@ -174,11 +212,14 @@ class LLMRetryPolicy(RetryPolicy):
instructions=self.instructions,
)
- exception_message = redact(str(exception)) if self.redact_exception
else str(exception)
+ # Redact before truncating -- truncating first could cut a registered
secret in half.
+ message = self.redactor(str(exception)) if self.redactor is not None
else str(exception)
+ if len(message) > self.max_exception_length:
+ message = f"{message[: self.max_exception_length]}... (truncated)"
prompt = (
f"Classify this error from a data pipeline task "
f"(attempt {try_number} of {max_tries}):\n\n"
- f"{type(exception).__name__}: {exception_message}"
+ f"{type(exception).__name__}: {message}"
)
from pydantic_ai.settings import ModelSettings
diff --git a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
index 6175938e07a..9b38eb243e1 100644
--- a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
+++ b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
@@ -28,6 +28,7 @@ pytest.importorskip("airflow.sdk.definitions.retry_policy",
reason="RetryPolicy
from airflow.providers.common.ai.policies.retry import (
ErrorClassification,
LLMRetryPolicy,
+ redact_registered_secrets,
)
from airflow.sdk._shared.secrets_masker import reset_secrets_masker
from airflow.sdk.definitions.retry_policy import RetryAction, RetryRule
@@ -48,6 +49,18 @@ def _make_mock_agent(category, should_retry, delay=0,
reasoning="test"):
return mock_agent
[email protected]_redact
+def test_redact_registered_secrets_masks_only_registered_values():
+ """Docs tell Dag authors to import and wrap this, so both the name and its
narrow scope are contracts."""
+ reset_secrets_masker()
+ mask_secret("super-secret-conn-password")
+
+ assert (
+ redact_registered_secrets("contact [email protected] with
super-secret-conn-password")
+ == "contact [email protected] with ***"
+ )
+
+
class TestLLMClassifyDecisions:
"""Test that _classify maps LLM classification to correct
RetryDecisions."""
@@ -154,6 +167,115 @@ class TestLLMClassifyDecisions:
prompt = mock_agent.run_sync.call_args[0][0]
assert secret_value in prompt
+ @pytest.mark.enable_redact
+ @patch("airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook",
autospec=True)
+ def test_explicit_redactor_none_still_applies_default_masking(self,
mock_hook_cls):
+ """redactor=None means "use the default masker" -- the same as
omitting it."""
+ reset_secrets_masker()
+ secret_value = "super-secret-conn-password"
+ mask_secret(secret_value)
+
+ mock_agent = _make_mock_agent("auth", should_retry=False)
+ mock_hook_cls.return_value.create_agent.return_value = mock_agent
+
+ policy = LLMRetryPolicy(llm_conn_id="test", redactor=None)
+ policy.evaluate(
+ ConnectionError(f"could not authenticate with password
{secret_value}"),
+ try_number=1,
+ max_tries=3,
+ )
+
+ prompt = mock_agent.run_sync.call_args[0][0]
+ assert secret_value not in prompt
+ assert "***" in prompt
+
+ def test_redact_exception_false_with_explicit_redactor_raises(self):
+ with pytest.raises(ValueError, match="redactor must not be set when
redact_exception=False"):
+ LLMRetryPolicy(llm_conn_id="test", redact_exception=False,
redactor=lambda message: message)
+
+ @pytest.mark.enable_redact
+ @patch("airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook",
autospec=True)
+ def test_custom_redactor_replaces_masker_instead_of_stacking(self,
mock_hook_cls):
+ """A custom redactor replaces the secrets masker entirely -- it is not
applied on top."""
+ reset_secrets_masker()
+ secret_value = "super-secret-conn-password"
+ mask_secret(secret_value)
+
+ mock_agent = _make_mock_agent("auth", should_retry=False)
+ mock_hook_cls.return_value.create_agent.return_value = mock_agent
+
+ policy = LLMRetryPolicy(llm_conn_id="test", redactor=lambda s:
s.replace("authenticate", "REDACTED"))
+ policy.evaluate(
+ ConnectionError(f"could not authenticate with password
{secret_value}"),
+ try_number=1,
+ max_tries=3,
+ )
+
+ prompt = mock_agent.run_sync.call_args[0][0]
+ # The registered secret is untouched by the masker...
+ assert secret_value in prompt
+ # ...but the custom redactor's own transformation did apply.
+ assert "REDACTED" in prompt
+
+ @pytest.mark.parametrize(
+ ("max_exception_length", "message_length", "expect_truncated"),
+ [
+ pytest.param(4096, 4096, False, id="default-limit-exact-fit"),
+ pytest.param(4096, 5000, True, id="default-limit-exceeded"),
+ pytest.param(10, 20, True, id="custom-limit-exceeded"),
+ pytest.param(10, 5, False, id="custom-limit-under"),
+ ],
+ )
+ @patch("airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook",
autospec=True)
+ def test_message_truncated_when_over_max_exception_length(
+ self, mock_hook_cls, max_exception_length, message_length,
expect_truncated
+ ):
+ mock_agent = _make_mock_agent("data", should_retry=False)
+ mock_hook_cls.return_value.create_agent.return_value = mock_agent
+
+ policy = LLMRetryPolicy(
+ llm_conn_id="test", redact_exception=False,
max_exception_length=max_exception_length
+ )
+ policy.evaluate(ValueError("x" * message_length), try_number=1,
max_tries=3)
+
+ prompt = mock_agent.run_sync.call_args[0][0]
+ assert ("... (truncated)" in prompt) is expect_truncated
+ if expect_truncated:
+ assert f"{'x' * max_exception_length}... (truncated)" in prompt
+ else:
+ assert "x" * message_length in prompt
+
+ @pytest.mark.enable_redact
+ @patch("airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook",
autospec=True)
+ def test_truncation_happens_after_redaction(self, mock_hook_cls):
+ """Redact-then-truncate must not equal truncate-then-redact for this
input.
+
+ The secret sits right at the truncation boundary: truncating first
would slice
+ it in half so the masker could no longer recognize and mask it.
+ """
+ reset_secrets_masker()
+ secret_value = "super-secret-conn-password"
+ mask_secret(secret_value)
+ max_exception_length = 20
+ # Padding places the secret so it straddles the truncation boundary.
+ padding = "a" * (max_exception_length - 5)
+ message = f"{padding}{secret_value}"
+
+ mock_agent = _make_mock_agent("auth", should_retry=False)
+ mock_hook_cls.return_value.create_agent.return_value = mock_agent
+
+ policy = LLMRetryPolicy(llm_conn_id="test",
max_exception_length=max_exception_length)
+ policy.evaluate(ConnectionError(message), try_number=1, max_tries=3)
+
+ prompt = mock_agent.run_sync.call_args[0][0]
+ assert secret_value not in prompt
+ assert "***" in prompt
+
+ @pytest.mark.parametrize("max_exception_length", [0, -1, -100])
+ def test_non_positive_max_exception_length_raises(self,
max_exception_length):
+ with pytest.raises(ValueError, match="max_exception_length must be a
positive integer"):
+ LLMRetryPolicy(llm_conn_id="test",
max_exception_length=max_exception_length)
+
@patch("airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook",
autospec=True)
def test_custom_instructions_forwarded_to_agent(self, mock_hook_cls):
mock_hook_cls.return_value.create_agent.return_value =
_make_mock_agent("x", False)