Skip to content

Commit 91c343e

Browse files
committed
fix(files): put streamed files and rendered documents on one byte budget
The budget only counted rendered documents, so streamed entries never reserved their declared bytes. A mixed selection could clear the up-front declared-size gate and still ship close to two full limits: ordinary files up to the cap, plus documents drawing a fresh cap of their own. Streamed entries ship exactly what they declared, so their share is known before anything is read and is now reserved up front; documents draw from what is left. The budget-exhausted rejection also quoted declared sizes, which for a selection of small sources reads as a tiny total exceeding the limit. That case has no knowable byte count — the documents have not been rendered — so it now says what happened without inventing a number.
1 parent 2b55eb6 commit 91c343e

2 files changed

Lines changed: 61 additions & 4 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/download/route.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,42 @@ describe('workspace files download route', () => {
191191
expect(body.error).not.toContain('Selected files total')
192192
})
193193

194+
it('counts streamed files against the same budget as rendered documents', async () => {
195+
// 200 MB of ordinary files leaves 50 MB of the 250 MB budget for documents.
196+
mockListWorkspaceFiles.mockResolvedValue([
197+
{ ...workspaceFile('f1', 'clip.mp4'), size: 200 * MB },
198+
generatedDocument('f2', 'report.docx'),
199+
])
200+
mockFetchServableWorkspaceFileBuffer.mockResolvedValue({
201+
buffer: Buffer.from('PKdoc'),
202+
contentType: 'application/octet-stream',
203+
})
204+
205+
await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context))
206+
207+
// Without reserving the streamed bytes the document would get the full 50 MB
208+
// ceiling, letting the archive ship 250 MB of documents on top of 200 MB of video.
209+
expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB)
210+
})
211+
212+
it('rejects when streamed files leave no budget for a document', async () => {
213+
mockListWorkspaceFiles.mockResolvedValue([
214+
{ ...workspaceFile('f1', 'clip.mp4'), size: 249 * MB },
215+
generatedDocument('f2', 'report.docx'),
216+
])
217+
mockFetchServableWorkspaceFileBuffer.mockRejectedValue(
218+
new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 })
219+
)
220+
221+
const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context)
222+
223+
expect(response.status).toBe(400)
224+
const body = await response.json()
225+
expect(body.error).toContain('once documents are rendered')
226+
// No byte count: the rendered total is not knowable, so quoting one would mislead.
227+
expect(body.error).not.toContain('Selected files total')
228+
})
229+
194230
it('blames the shared budget once earlier documents have consumed it', async () => {
195231
mockListWorkspaceFiles.mockResolvedValue([
196232
generatedDocument('f1', 'first.docx'),
@@ -208,7 +244,7 @@ describe('workspace files download route', () => {
208244

209245
expect(response.status).toBe(400)
210246
const body = await response.json()
211-
expect(body.error).toContain('Selected files total')
247+
expect(body.error).toContain('once documents are rendered')
212248
expect(body.error).not.toContain('second.docx')
213249
})
214250

@@ -237,7 +273,7 @@ describe('workspace files download route', () => {
237273
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
238274
})
239275

240-
await GET(requestFor('fileIds=f1'), context)
276+
await zipFrom(await GET(requestFor('fileIds=f1'), context))
241277

242278
expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB)
243279
})

apps/sim/app/api/workspaces/[id]/files/download/route.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ function selectionTooLargeResponse(bytes: number): NextResponse {
8787
)
8888
}
8989

90+
/**
91+
* The rendered archive would exceed the limit even though the declared sizes did not —
92+
* generated documents render to more than the source they declared, so no accurate byte
93+
* count exists to quote here.
94+
*/
95+
function archiveTooLargeResponse(): NextResponse {
96+
return NextResponse.json(
97+
{
98+
error: `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.`,
99+
},
100+
{ status: 400 }
101+
)
102+
}
103+
90104
function collectDescendantFolderIds(
91105
selectedFolderIds: string[],
92106
folders: Array<{ id: string; parentId: string | null }>
@@ -153,6 +167,13 @@ export const GET = withRouteHandler(
153167
return selectionTooLargeResponse(declaredBytes)
154168
}
155169

170+
// Streamed entries ship exactly what they declared, so their share of the budget is
171+
// known up front and is reserved here. Documents then draw from what is left —
172+
// one budget across both kinds, or the archive could ship two full limits' worth.
173+
const reservedForStreamed = filesToZip
174+
.filter((file) => !needsRendering(file))
175+
.reduce((sum, file) => sum + file.size, 0)
176+
156177
// Generated documents are resolved before the archive starts: once the first byte
157178
// is written the status code is committed, so anything that can still fail the
158179
// request has to fail here. Their buffers are held until the archive is assembled,
@@ -164,7 +185,7 @@ export const GET = withRouteHandler(
164185
for (const file of filesToZip) {
165186
if (!needsRendering(file)) continue
166187

167-
const remaining = MAX_ZIP_DOWNLOAD_BYTES - renderedBytes
188+
const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes)
168189
// A source's declared size says nothing about what it renders to, so the cap is
169190
// the per-document ceiling, bounded by what is left of the budget.
170191
const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES)
@@ -184,7 +205,7 @@ export const GET = withRouteHandler(
184205
},
185206
{ status: 400 }
186207
)
187-
: selectionTooLargeResponse(declaredBytes)
208+
: archiveTooLargeResponse()
188209
}
189210
// Pending artifacts are collected so the 409 can name all of them; anything
190211
// else dooms the request and waiting cannot fix it.

0 commit comments

Comments
 (0)