0.37.2: stream folder zip-downloads to fix 504 on large folders
A big folder hit a 504 Gateway Timeout: the zip was built into a temp
file *before* any response was sent, so for large folders the backend
stayed silent past nginx's proxy_read_timeout.
Now the zip is streamed as it's built, end to end:
- file_service.open_archive() returns (filename, byte iterator); _iter_zip
walks the dir and yields zip bytes incrementally via a small drain
buffer, writing each file in 1 MiB chunks (bounded memory, valid CRCs).
Same hardening as before — only real regular files; FIFOs/sockets/
devices/symlinks skipped without open(); per-file read errors skipped.
- /api/files/download and /agent/files/download return a StreamingResponse
(no temp file). The agent proxy streams the agent response straight
through (agent_service.stream_download), pulling the first chunk eagerly
so an offline/bad-token agent still yields a clean status before 200.
- Files page: streamed downloads have no Content-Length, so the progress
bar shows the running downloaded byte count ("Downloading … 12.3 MB")
instead of a percentage, after the initial "Preparing …".
Verified end to end via TestClient (200, application/zip, valid zip,
2 MiB file intact, FIFO skipped, no hang).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0dc430bb2a
commit
5ac9f15de4
+22
-11
@@ -6,7 +6,7 @@ import os
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
from starlette.background import BackgroundTask
|
||||
@@ -791,19 +791,30 @@ async def agent_files_download(
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
agent = _get_or_404(session, agent_id)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False)
|
||||
tmp.close()
|
||||
# Stream the agent's response straight through (works for single files and
|
||||
# for on-the-fly folder zips), so nothing is staged to disk and the
|
||||
# download starts immediately. Pull the first chunk eagerly so a failed
|
||||
# agent (offline / bad token / 404) still surfaces a clean HTTP status
|
||||
# before we commit to a 200 streaming response.
|
||||
chunks = agent_service.stream_download(
|
||||
session, agent, "/agent/files/download", params={"path": path}
|
||||
)
|
||||
try:
|
||||
await agent_service.download_to_file(
|
||||
session, agent, "/agent/files/download", tmp.name, params={"path": path}
|
||||
)
|
||||
first = await chunks.__anext__()
|
||||
except StopAsyncIteration:
|
||||
first = b""
|
||||
except AgentError as exc:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
_raise(exc)
|
||||
return FileResponse(
|
||||
tmp.name, media_type="application/octet-stream", filename=os.path.basename(path),
|
||||
background=BackgroundTask(os.unlink, tmp.name),
|
||||
|
||||
async def body():
|
||||
yield first
|
||||
async for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{os.path.basename(path)}"'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ from fastapi import (
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from starlette.background import BackgroundTask
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
@@ -32,6 +31,12 @@ from services import audit_service, device_service, file_service
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
|
||||
def _attachment(filename: str) -> str:
|
||||
"""A safe ``Content-Disposition`` value for an arbitrary filename."""
|
||||
safe = filename.replace("\\", "_").replace('"', "_")
|
||||
return f'attachment; filename="{safe}"'
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
@@ -71,10 +76,12 @@ def download(
|
||||
_user: User = Depends(get_current_user),
|
||||
):
|
||||
if _guard(file_service.is_dir, path):
|
||||
tmp, filename = _guard(file_service.archive_dir, path)
|
||||
return FileResponse(
|
||||
tmp, filename=filename, media_type="application/zip",
|
||||
background=BackgroundTask(os.unlink, tmp),
|
||||
filename, chunks = _guard(file_service.open_archive, path)
|
||||
# Stream the zip as it's built so the response starts immediately
|
||||
# (large folders no longer hit the proxy's read timeout).
|
||||
return StreamingResponse(
|
||||
chunks, media_type="application/zip",
|
||||
headers={"Content-Disposition": _attachment(filename)},
|
||||
)
|
||||
real, filename = _guard(file_service.resolve_download, path)
|
||||
return FileResponse(real, filename=filename, media_type="application/octet-stream")
|
||||
|
||||
Reference in New Issue
Block a user