Skip to content

Commit a3fc764

Browse files
committed
fix(uploads): abort multipart on get-part-urls failure; retry register on transient errors
1 parent 11907c6 commit a3fc764

2 files changed

Lines changed: 62 additions & 27 deletions

File tree

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createLogger } from '@sim/logger'
2+
import { sleep } from '@sim/utils/helpers'
23
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
34
import { toast } from '@/components/emcn'
4-
import { isApiClientError } from '@/lib/api/client/errors'
5+
import { ApiClientError, isApiClientError } from '@/lib/api/client/errors'
56
import { requestJson } from '@/lib/api/client/request'
67
import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits'
78
import {
@@ -267,23 +268,52 @@ async function uploadWorkspaceFile(
267268
throw error
268269
}
269270

270-
const data = await requestJson(registerWorkspaceFileContract, {
271-
params: { id: workspaceId },
272-
body: {
273-
key: result.key,
274-
name: result.name,
275-
size: result.size,
276-
contentType: result.contentType,
277-
},
278-
signal,
279-
})
271+
const data = await registerWithRetry(workspaceId, result, signal)
280272

281273
if (!data.success || !data.file) {
282274
throw new Error(data.error || 'Failed to register file')
283275
}
284276
return { success: true, file: data.file }
285277
}
286278

279+
const REGISTER_MAX_ATTEMPTS = 3
280+
const REGISTER_RETRY_DELAY_MS = 500
281+
282+
/**
283+
* Register the uploaded object with bounded retries. The server-side handler
284+
* is idempotent (existing-record short-circuit), so safely retrying handles
285+
* dropped responses that would otherwise orphan the object in storage.
286+
*/
287+
async function registerWithRetry(
288+
workspaceId: string,
289+
result: { key: string; name: string; size: number; contentType: string },
290+
signal?: AbortSignal
291+
) {
292+
let lastError: unknown
293+
for (let attempt = 1; attempt <= REGISTER_MAX_ATTEMPTS; attempt++) {
294+
try {
295+
return await requestJson(registerWorkspaceFileContract, {
296+
params: { id: workspaceId },
297+
body: {
298+
key: result.key,
299+
name: result.name,
300+
size: result.size,
301+
contentType: result.contentType,
302+
},
303+
signal,
304+
})
305+
} catch (error) {
306+
lastError = error
307+
if (signal?.aborted) throw error
308+
const isTransient =
309+
!(error instanceof ApiClientError) || (error.status >= 500 && error.status < 600)
310+
if (!isTransient || attempt === REGISTER_MAX_ATTEMPTS) throw error
311+
await sleep(REGISTER_RETRY_DELAY_MS * attempt)
312+
}
313+
}
314+
throw lastError
315+
}
316+
287317
export function useUploadWorkspaceFile() {
288318
const queryClient = useQueryClient()
289319

apps/sim/lib/uploads/client/direct-upload.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -382,26 +382,31 @@ const uploadViaMultipart = async (
382382
}
383383
}
384384

385-
// boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/complete/abort outside the contract layer
386-
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
387-
method: 'POST',
388-
headers: { 'Content-Type': 'application/json' },
389-
body: JSON.stringify({ uploadToken, partNumbers }),
390-
signal,
391-
})
385+
let presignedUrls: PartUrl[]
386+
try {
387+
// boundary-raw-fetch: multipart upload control plane uses action query strings; sequenced with initiate/complete/abort outside the contract layer
388+
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
389+
method: 'POST',
390+
headers: { 'Content-Type': 'application/json' },
391+
body: JSON.stringify({ uploadToken, partNumbers }),
392+
signal,
393+
})
394+
395+
if (!partUrlsResponse.ok) {
396+
throw new DirectUploadError(
397+
`Failed to get part URLs: ${partUrlsResponse.statusText}`,
398+
'MULTIPART_ERROR',
399+
undefined,
400+
partUrlsResponse.status
401+
)
402+
}
392403

393-
if (!partUrlsResponse.ok) {
404+
;({ presignedUrls } = (await partUrlsResponse.json()) as { presignedUrls: PartUrl[] })
405+
} catch (err) {
394406
await abortMultipart()
395-
throw new DirectUploadError(
396-
`Failed to get part URLs: ${partUrlsResponse.statusText}`,
397-
'MULTIPART_ERROR',
398-
undefined,
399-
partUrlsResponse.status
400-
)
407+
throw err
401408
}
402409

403-
const { presignedUrls } = (await partUrlsResponse.json()) as { presignedUrls: PartUrl[] }
404-
405410
const completedBytes = new Array<number>(numParts).fill(0)
406411
const reportProgress = () => {
407412
const loaded = completedBytes.reduce((a, b) => a + b, 0)

0 commit comments

Comments
 (0)