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)}"'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user