Repository: jschneier/django-storages

Issue:
SFTPStorage doesn't reconnect after ssh connection is closed
We faced with this issue in one of our projects. 
After storage server reboots celery workers tries to save files using socket to ssh-connection which have been closed.
I want to add reconnect logic in this class (I will send a PR)

```
Traceback (most recent call last):
  File "/app/python/lib/python3.6/site-packages/celery/app/trace.py", line 374, in trace_task
    R = retval = fun(*args, **kwargs)
  File "/app/python/lib/python3.6/site-packages/celery/app/trace.py", line 629, in __protected_call__
    return self.run(*args, **kwargs)
  File "/app/project/demo_app/tasks.py", line 20, in check_storage_task
    text = obj.data.read().decode()
  File "/app/python/lib/python3.6/site-packages/storages/backends/sftpstorage.py", line 217, in read
    self.file = self._storage._read(self._name)
  File "/app/python/lib/python3.6/site-packages/storages/backends/sftpstorage.py", line 104, in _read
    return self.sftp.open(remote_path, 'rb')
  File "/app/python/lib/python3.6/site-packages/paramiko/sftp_client.py", line 327, in open
    t, msg = self._request(CMD_OPEN, filename, imode, attrblock)
  File "/app/python/lib/python3.6/site-packages/paramiko/sftp_client.py", line 729, in _request
    num = self._async_request(type(None), t, *arg)
  File "/app/python/lib/python3.6/site-packages/paramiko/sftp_client.py", line 754, in _async_request
    self._send_packet(t, msg)
  File "/app/python/lib/python3.6/site-packages/paramiko/sftp.py", line 170, in _send_packet
    self._write_all(out)
  File "/app/python/lib/python3.6/site-packages/paramiko/sftp.py", line 133, in _write_all
    n = self.sock.send(out)
  File "/app/python/lib/python3.6/site-packages/paramiko/channel.py", line 767, in send
    return self._send(s, m)
  File "/app/python/lib/python3.6/site-packages/paramiko/channel.py", line 1133, in _send
    raise socket.error('Socket is closed')
OSError: Socket is closed
```


Discussion:

Resolving patch:
diff --git a/storages/backends/sftpstorage.py b/storages/backends/sftpstorage.py
--- a/storages/backends/sftpstorage.py
+++ b/storages/backends/sftpstorage.py
@@ -49,6 +49,7 @@ def __init__(self, host=None, params=None, interactive=None, file_mode=None,
         # for now it's all posix paths.  Maybe someday we'll support figuring
         # out if the remote host is windows.
         self._pathmod = posixpath
+        self._sftp = None
 
     def _connect(self):
         self._ssh = paramiko.SSHClient()
@@ -79,13 +80,13 @@ def _connect(self):
         except Exception as e:
             print(e)
 
-        if not hasattr(self, '_sftp'):
+        if self._ssh.get_transport():
             self._sftp = self._ssh.open_sftp()
 
     @property
     def sftp(self):
         """Lazy SFTP connection"""
-        if not hasattr(self, '_sftp'):
+        if not self._sftp or not self._ssh.get_transport().is_active():
             self._connect()
         return self._sftp
 


Test patch:
diff --git a/tests/test_sftp.py b/tests/test_sftp.py
--- a/tests/test_sftp.py
+++ b/tests/test_sftp.py
@@ -2,6 +2,7 @@
 import stat
 from datetime import datetime
 
+import paramiko
 from django.core.files.base import File
 from django.test import TestCase
 from django.utils.six import BytesIO
@@ -133,6 +134,24 @@ def test_url(self):
             self.storage._base_url = None
             self.storage.url('foo')
 
+    @patch('paramiko.transport.Transport', **{
+        'is_active.side_effect': (True, False)
+    })
+    @patch('storages.backends.sftpstorage.SFTPStorage._connect')
+    def test_sftp(self, connect, transport):
+        self.assertIsNone(self.storage.sftp)
+        self.assertTrue(connect.called)
+        connect.reset_mock()
+        self.storage._ssh = paramiko.SSHClient()
+        self.storage._ssh._transport = transport
+
+        self.storage._sftp = True
+        self.assertTrue(self.storage.sftp)
+        self.assertFalse(connect.called)
+
+        self.assertTrue(self.storage.sftp)
+        self.assertTrue(connect.called)
+
 
 class SFTPStorageFileTest(TestCase):
     def setUp(self):