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

guan404ming 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 50271018585 Fixed "AttributeError: 'NoneType' object has no attribute 
'close'" for SmtpHook (#62409)
50271018585 is described below

commit 50271018585fda6765a638f3c4070d1bd927614f
Author: Svyatoslav Pchelintsev <[email protected]>
AuthorDate: Thu Jun 18 12:18:39 2026 +0300

    Fixed "AttributeError: 'NoneType' object has no attribute 'close'" for 
SmtpHook (#62409)
    
    * Fixed the bug, added a test
    
    * Fixed the other 3 retry loops and updated tests
    
    * Made the documentation consistent
    
    * Made retry_limit consistent across all use of SMTP
    
    * Fixed line length
    
    * Updated logging, made loops better
---
 .../src/airflow/config_templates/config.yml        |  3 +-
 airflow-core/src/airflow/utils/email.py            |  4 +-
 airflow-core/tests/unit/utils/test_email.py        |  6 +--
 providers/smtp/docs/connections/smtp.rst           |  2 +-
 .../smtp/src/airflow/providers/smtp/hooks/smtp.py  |  8 ++--
 providers/smtp/tests/unit/smtp/hooks/test_smtp.py  | 43 ++++++++++++++++++----
 6 files changed, 48 insertions(+), 18 deletions(-)

diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index a288c4fe6ed..86e422451a0 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -2453,7 +2453,8 @@ smtp:
       default: "30"
     smtp_retry_limit:
       description: |
-        Defines the maximum number of times Airflow will attempt to connect to 
the SMTP server.
+        Defines the number of times Airflow will attempt to connect to the 
SMTP server after the first
+        attempt.
       version_added: 2.0.0
       type: integer
       example: ~
diff --git a/airflow-core/src/airflow/utils/email.py 
b/airflow-core/src/airflow/utils/email.py
index 7376e14baf8..393841efa3d 100644
--- a/airflow-core/src/airflow/utils/email.py
+++ b/airflow-core/src/airflow/utils/email.py
@@ -257,8 +257,8 @@ def send_mime_email(
         log.debug("No user/password found for SMTP, so logging in with no 
authentication.")
 
     if not dryrun:
-        for attempt in range(1, smtp_retry_limit + 1):
-            log.info("Email alerting: attempt %s", str(attempt))
+        for attempt in range(smtp_retry_limit + 1):
+            log.info("Email alerting: attempt %s", str(attempt + 1))
             try:
                 smtp_conn = _get_smtp_connection(smtp_host, smtp_port, 
smtp_timeout, smtp_ssl)
             except smtplib.SMTPServerDisconnected:
diff --git a/airflow-core/tests/unit/utils/test_email.py 
b/airflow-core/tests/unit/utils/test_email.py
index c45e83d6e77..f73edeb3679 100644
--- a/airflow-core/tests/unit/utils/test_email.py
+++ b/airflow-core/tests/unit/utils/test_email.py
@@ -324,7 +324,7 @@ class TestEmailSmtp:
             port=conf.getint("smtp", "SMTP_PORT"),
             timeout=conf.getint("smtp", "SMTP_TIMEOUT"),
         )
-        assert mock_smtp.call_count == conf.getint("smtp", "SMTP_RETRY_LIMIT")
+        assert mock_smtp.call_count == conf.getint("smtp", "SMTP_RETRY_LIMIT") 
+ 1
         assert not mock_smtp_ssl.called
         assert not mock_smtp.return_value.starttls.called
         assert not mock_smtp.return_value.login.called
@@ -348,7 +348,7 @@ class TestEmailSmtp:
             context=create_default_context.return_value,
         )
         assert create_default_context.called
-        assert mock_smtp_ssl.call_count == conf.getint("smtp", 
"SMTP_RETRY_LIMIT")
+        assert mock_smtp_ssl.call_count == conf.getint("smtp", 
"SMTP_RETRY_LIMIT") + 1
         assert not mock_smtp.called
         assert not mock_smtp_ssl.return_value.starttls.called
         assert not mock_smtp_ssl.return_value.login.called
@@ -377,7 +377,7 @@ class TestEmailSmtp:
             host=conf.get("smtp", "SMTP_HOST"), port=conf.getint("smtp", 
"SMTP_PORT"), timeout=custom_timeout
         )
         assert not mock_smtp_ssl.called
-        assert mock_smtp.call_count == 10
+        assert mock_smtp.call_count == custom_retry_limit + 1
 
     @mock.patch("smtplib.SMTP_SSL")
     @mock.patch("smtplib.SMTP")
diff --git a/providers/smtp/docs/connections/smtp.rst 
b/providers/smtp/docs/connections/smtp.rst
index 575636c77ab..2d0ff2add52 100644
--- a/providers/smtp/docs/connections/smtp.rst
+++ b/providers/smtp/docs/connections/smtp.rst
@@ -71,7 +71,7 @@ Configuring the Connection
     * ``disable_ssl`` *(bool)* – Disable SSL/TLS entirely. Default ``false``.
     * ``disable_tls`` *(bool)* – Skip ``STARTTLS``. Default ``false``.
     * ``timeout`` *(int)* – Socket timeout (seconds). Default ``30``.
-    * ``retry_limit`` *(int)* – Connection attempts before raising. Default 
``5``.
+    * ``retry_limit`` *(int)* – Number of retries after the first attempt 
before raising. Default ``5``.
     * ``ssl_context`` – ``"default"`` | ``"none"``
       See :ref:`howto/connection:smtp:ssl-context`.
 
diff --git a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py 
b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
index 870dbc47835..991d13c1abc 100644
--- a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
+++ b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
@@ -118,7 +118,7 @@ class SmtpHook(BaseHook):
             except AirflowNotFoundException:
                 raise AirflowException("SMTP connection is not found.")
 
-            for attempt in range(1, self.smtp_retry_limit + 1):
+            for attempt in range(self.smtp_retry_limit + 1):
                 try:
                     self._smtp_client = self._build_client()
                 except smtplib.SMTPServerDisconnected:
@@ -163,7 +163,7 @@ class SmtpHook(BaseHook):
             except AirflowNotFoundException:
                 raise AirflowException("SMTP connection is not found.")
 
-            for attempt in range(1, self.smtp_retry_limit + 1):
+            for attempt in range(self.smtp_retry_limit + 1):
                 try:
                     async_client = await self._abuild_client()
                     self._smtp_client = async_client
@@ -432,7 +432,7 @@ class SmtpHook(BaseHook):
             # Casting here to make MyPy happy.
             smtp_client = cast("smtplib.SMTP_SSL | smtplib.SMTP", 
self._smtp_client)
 
-            for attempt in range(1, self.smtp_retry_limit + 1):
+            for attempt in range(self.smtp_retry_limit + 1):
                 try:
                     smtp_client.sendmail(
                         from_addr=msg["from_email"],
@@ -501,7 +501,7 @@ class SmtpHook(BaseHook):
         smtp_client = cast("aiosmtplib.SMTP", self._smtp_client)
 
         if not dryrun:
-            for attempt in range(1, self.smtp_retry_limit + 1):
+            for attempt in range(self.smtp_retry_limit + 1):
                 try:
                     #  The async version of sendmail only supports positional 
arguments for some reason.
                     await smtp_client.sendmail(
diff --git a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py 
b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
index 2c560ab3d4b..13e494306c7 100644
--- a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
+++ b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
@@ -53,6 +53,7 @@ ACCESS_TOKEN = "test-token"
 
 CONN_ID_DEFAULT = "smtp_default"
 CONN_ID_NONSSL = "smtp_nonssl"
+CONN_ID_0_RETRIES = "smtp_0_retries"
 CONN_ID_SSL_EXTRA = "smtp_ssl_extra"
 CONN_ID_OAUTH = "smtp_oauth2"
 
@@ -106,6 +107,17 @@ class TestSmtpHook:
                 extra=json.dumps(dict(disable_ssl=True, 
from_email=FROM_EMAIL)),
             )
         )
+        create_connection_without_db(
+            Connection(
+                conn_id=CONN_ID_0_RETRIES,
+                conn_type=CONN_TYPE,
+                host=SMTP_HOST,
+                login=SMTP_LOGIN,
+                password=SMTP_PASSWORD,
+                port=NONSSL_PORT,
+                extra=json.dumps(dict(from_email=FROM_EMAIL, retry_limit=0, 
disable_ssl=True)),
+            )
+        )
         create_connection_without_db(
             Connection(
                 conn_id=CONN_ID_OAUTH,
@@ -134,6 +146,7 @@ class TestSmtpHook:
         [
             pytest.param(CONN_ID_DEFAULT, True, DEFAULT_PORT, True, 
id="ssl-connection"),
             pytest.param(CONN_ID_NONSSL, False, NONSSL_PORT, False, 
id="non-ssl-connection"),
+            pytest.param(CONN_ID_0_RETRIES, False, NONSSL_PORT, False, 
id="0-retries-connection"),
         ],
     )
     @patch(smtplib_string)
@@ -144,8 +157,10 @@ class TestSmtpHook:
         """Test sync connection with different configurations."""
         mock_conn = _create_fake_smtp(mock_smtplib, use_ssl=use_ssl)
 
-        with SmtpHook(smtp_conn_id=conn_id):
-            pass
+        smtp_hook = SmtpHook(smtp_conn_id=conn_id)
+        assert smtp_hook._smtp_client is None
+        with smtp_hook:
+            assert smtp_hook._smtp_client is not None
 
         if create_context:
             assert create_default_context.called
@@ -388,7 +403,7 @@ class TestSmtpHook:
                     html_content=TEST_BODY,
                 )
 
-        assert mock_smtp_ssl().sendmail.call_count == DEFAULT_RETRY_LIMIT
+        assert mock_smtp_ssl().sendmail.call_count == DEFAULT_RETRY_LIMIT + 1
 
     @patch("email.message.Message.as_string")
     @patch("smtplib.SMTP_SSL")
@@ -441,7 +456,7 @@ class TestSmtpHook:
         )
         assert expected_call in mock_smtp_ssl.call_args_list
         assert create_default_context.called
-        assert mock_smtp_ssl().sendmail.call_count == 10
+        assert mock_smtp_ssl().sendmail.call_count == custom_retry_limit + 1
 
     @patch(smtplib_string)
     def test_oauth2_auth_called(self, mock_smtplib):
@@ -599,6 +614,17 @@ class TestSmtpHookAsync:
                 extra=json.dumps(dict(disable_ssl=True, 
from_email=FROM_EMAIL)),
             )
         )
+        create_connection_without_db(
+            Connection(
+                conn_id=CONN_ID_0_RETRIES,
+                conn_type=CONN_TYPE,
+                host=SMTP_HOST,
+                login=SMTP_LOGIN,
+                password=SMTP_PASSWORD,
+                port=NONSSL_PORT,
+                extra=json.dumps(dict(from_email=FROM_EMAIL, retry_limit=0, 
disable_ssl=True)),
+            )
+        )
 
     @pytest.fixture
     def mock_smtp_client(self):
@@ -647,14 +673,17 @@ class TestSmtpHookAsync:
         [
             pytest.param(CONN_ID_NONSSL, NONSSL_PORT, False, 
id="non-ssl-connection"),
             pytest.param(CONN_ID_DEFAULT, DEFAULT_PORT, True, 
id="ssl-connection"),
+            pytest.param(CONN_ID_0_RETRIES, NONSSL_PORT, False, 
id="0-retries-connection"),
         ],
     )
     async def test_async_connection(
         self, mock_smtp, mock_smtp_client, mock_get_connection, conn_id, 
expected_port, expected_ssl
     ):
         """Test async connection with different configurations."""
-        async with SmtpHook(smtp_conn_id=conn_id) as hook:
-            assert hook is not None
+        smtp_hook = SmtpHook(smtp_conn_id=conn_id)
+        assert smtp_hook._smtp_client is None
+        async with smtp_hook:
+            assert smtp_hook._smtp_client is not None
 
         mock_smtp.assert_called_once()
         call_kwargs = mock_smtp.call_args.kwargs
@@ -698,7 +727,7 @@ class TestSmtpHookAsync:
                 False,
                 id="success_after_retries",
             ),
-            pytest.param(SERVER_DISCONNECTED_ERROR, DEFAULT_RETRY_LIMIT, True, 
id="max_retries_exceeded"),
+            pytest.param(SERVER_DISCONNECTED_ERROR, DEFAULT_RETRY_LIMIT + 1, 
True, id="max_retries_exceeded"),
         ],
     )
     @pytest.mark.asyncio

Reply via email to