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

shahar1 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 fe8e5dfef07 Close S3 download_file handle once the download finishes 
or fails (#69084)
fe8e5dfef07 is described below

commit fe8e5dfef077c57a5df271bc4c090300273994c1
Author: itej13 <[email protected]>
AuthorDate: Mon Jun 29 01:48:36 2026 +0530

    Close S3 download_file handle once the download finishes or fails (#69084)
    
    S3Hook.download_file opened a local file (or NamedTemporaryFile with
    delete=False), streamed the object into it, and returned the path
    without ever closing the handle, relying on garbage collection to
    release the descriptor. On a download error the handle leaked the same
    way. Closing it in a finally releases the descriptor on every path while
    keeping the downloaded file on disk for the caller.
---
 .../src/airflow/providers/amazon/aws/hooks/s3.py       | 18 ++++++++++++------
 .../amazon/tests/unit/amazon/aws/hooks/test_s3.py      | 15 +++++++++++++++
 2 files changed, 27 insertions(+), 6 deletions(-)

diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
index 616506e2134..626ac19730d 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/s3.py
@@ -1629,12 +1629,18 @@ class S3Hook(AwsBaseHook):
         extra_args = {**self.extra_args}
         if self._requester_pays:
             extra_args["RequestPayer"] = "requester"
-        s3_obj.download_fileobj(
-            file,
-            ExtraArgs=extra_args,
-            Config=self.transfer_config,
-        )
-        file.flush()
+        try:
+            s3_obj.download_fileobj(
+                file,
+                ExtraArgs=extra_args,
+                Config=self.transfer_config,
+            )
+            file.flush()
+        finally:
+            # The file is created with delete=False / opened by us, so closing 
the
+            # handle keeps the data on disk while releasing the descriptor 
even when
+            # the download raises.
+            file.close()
         get_hook_lineage_collector().add_input_asset(
             context=self, scheme="s3", asset_kwargs={"bucket": bucket_name, 
"key": key}
         )
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
index 8b2d70c0c09..c4cc20d2231 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_s3.py
@@ -1530,6 +1530,21 @@ class TestAwsS3Hook:
 
         assert mock_file.name == output_file
 
+    @mock.patch("airflow.providers.amazon.aws.hooks.s3.NamedTemporaryFile")
+    def test_download_file_closes_handle_on_error(self, mock_temp_file, 
tmp_path):
+        mock_file = mock_temp_file.return_value
+        mock_file.name = str(tmp_path / "airflow_tmp_test_s3_hook")
+        s3_hook = S3Hook(aws_conn_id="s3_test")
+        s3_hook.check_for_key = Mock(return_value=True)
+        s3_obj = Mock()
+        s3_obj.download_fileobj = Mock(side_effect=OSError("download failed"))
+        s3_hook.get_key = Mock(return_value=s3_obj)
+
+        with pytest.raises(OSError, match="download failed"):
+            s3_hook.download_file(key="test_key", bucket_name="test_bucket")
+
+        mock_file.close.assert_called_once_with()
+
     @mock.patch("airflow.providers.amazon.aws.hooks.s3.NamedTemporaryFile")
     def test_download_file_exposes_lineage(self, mock_temp_file, tmp_path, 
hook_lineage_collector):
         mock_file = mock_temp_file.return_value

Reply via email to