The error you're encountering (`OSError: Socket is closed`) occurs because the SFTP connection
and transport are closed **before** the file streaming begins. In the `/download` endpoint:
- The `file_generator` function is a generator that opens and reads from the SFTP file lazily
(during response streaming).
- By closing `sftp` and `transport` immediately after defining the generator, the underlying socket
becomes invalid when the ASGI server (Uvicorn) later iterates over the generator in a threadpool
to send chunks.
- Paramiko's SFTP sessions are not designed to survive closure like this; the connection must
remain open for the entire duration of the file read/stream.
This is a common issue when mixing synchronous I/O libraries (like Paramiko) with async
FastAPI responses. Closing early invalidates the channel/socket.
### Solution
- **Move the connection inside the generator**: Establish the SSH/SFTP connection *inside*
`file_generator()`. This keeps it open during iteration. Clean up (close) in a `finally` block after
yielding all chunks.
- **Handle exceptions gracefully**: Wrap the generator logic to raise HTTP exceptions on SFTP
errors without crashing the ASGI app.
- **Use binary mode consistently**: Ensure `'rb'` and `'wb'` for ZIP files (binary data).
- **Additional fixes**:
- Use `io.BytesIO` or direct chunk yielding for efficiency.
- Add a small delay or keep-alive if network timeouts are suspected (but primary issue is
premature close).
- For large files, this streams efficiently without full memory load.
- In production, switch to an async SFTP library like `asyncssh` to avoid threadpool overhead
entirely (Paramiko is sync and blocks).
### Updated `/download` Endpoint Code
Replace your existing `@app.get("/download")` with this:
```python
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import StreamingResponse
import paramiko
import os
from typing import Generator
# ... (rest of your code, including SERVER_IP, etc.)
@app.get("/download")
async def download_zip(filename: str = Query(..., description="Name of the ZIP file to download
from server")):
if not filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Only ZIP files are supported")
remote_path = os.path.join(REMOTE_DOWNLOAD_DIR, filename).replace("\\", "/")
def file_generator() -> Generator[bytes, None, None]:
transport = None
sftp = None
remote_file = None
try:
# Establish connection INSIDE the generator (keeps socket alive during streaming)
transport = paramiko.Transport((SERVER_IP, 22))
transport.connect(username=USERNAME, password=PASSWORD)
sftp = paramiko.SFTPClient.from_transport(transport)
# Check existence (optional, but good for early fail)
try:
file_attrs = sftp.stat(remote_path)
if file_attrs.st_size == 0:
raise HTTPException(status_code=404, detail="File is empty")
except IOError:
raise HTTPException(status_code=404, detail=f"File '{filename}' not found on server")
# Open and stream in chunks
remote_file = sftp.open(remote_path, 'rb')
while True:
chunk = remote_file.read(1024 * 1024) # 1MB chunks; adjust as needed
if not chunk:
break
yield chunk
except Exception as e:
# Re-raise as HTTPException for FastAPI to handle cleanly
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=f"Stream failed: {str(e)}")
finally:
# Clean up connections even if error occurs during streaming
try:
if remote_file:
remote_file.close()
except:
pass
try:
if sftp:
sftp.close()
except:
pass
try:
if transport:
transport.close()
except:
pass
return StreamingResponse(file_generator(), media_type="application/zip", headers={"Content-
Disposition": f"attachment; filename={filename}"})
```
### How This Fixes It
- **Connection Lifetime**: The transport/sftp now lives until the generator finishes yielding (i.e.,
entire file streamed). Closure happens in `finally` after the last chunk.
- **Error Propagation**: Exceptions inside the generator bubble up as HTTP 500/404, avoiding
raw socket errors in logs.
- **Streaming Safety**: Uses a generator with `yield`, compatible with `StreamingResponse`.
Runs in a threadpool via AnyIO (as before), but now socket stays open.
- **Resource Cleanup**: `finally` ensures no leaks even on client disconnect or errors.
### Testing Steps
1. Restart Uvicorn: `uvicorn main:app --reload` (replace `main` with your filename).
2. Trigger download:
- Browser: `http://localhost:8000/download?filename=yourfile.zip` (downloads directly; save to
`E:/dip/name/` manually).
- Curl: `curl -o E:/dip/name/yourfile.zip http://localhost:8000/download?filename=yourfile.zip`
3. Check logs: If file exists on server, it should stream without socket closure error.
4. If timeout persists (e.g., slow network):
- Increase chunk size or add `transport.set_keepalive(30)` after connect.
- Verify server connectivity: `ping 192.168.1.6` or manual SFTP test with `sftp
[email protected]`.
### Additional Recommendations
- **Security**: Hardcoded creds are risky – move to env vars: `import os; PASSWORD =
os.getenv("SSH_PASS")`. Use SSH keys for paramiko
(`pkey=paramiko.RSAKey.from_private_key_file(...)`).
- **Async Alternative**: For better performance, install `asyncssh` and rewrite with async
generators (no threadpool blocking).
- **File Listing**: If needed, add a `/list` endpoint to list files in `REMOTE_DOWNLOAD_DIR` via
`sftp.listdir()`.
- **Production**: Add auth (e.g., FastAPI dependencies with API keys), rate limiting, and logging
(e.g., `logging` module).
If this doesn't resolve or you get a new error (e.g., auth fail), share the updated traceback or
server logs!