Address remaining multipart review feedback

This commit is contained in:
vineyardbovines
2026-07-29 08:22:25 -04:00
parent e7296e4f8e
commit 53ad5d97b7
5 changed files with 92 additions and 34 deletions
+6 -18
View File
@@ -3,7 +3,7 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {type CompressedVideo} from '#/lib/media/video/types' import {type CompressedVideo} from '#/lib/media/video/types'
import {shouldRetryError} from '#/lib/strings/errors' import {isRetryableHttpStatus, shouldRetryError} from '#/lib/strings/errors'
import {getServiceAuthToken} from '../upload.shared' import {getServiceAuthToken} from '../upload.shared'
import {mimeToExt} from '../util' import {mimeToExt} from '../util'
import { import {
@@ -19,6 +19,7 @@ import {getMissingParts, planParts} from './planParts'
import {createChunkReader} from './readChunk' import {createChunkReader} from './readChunk'
import {createUploadPart} from './uploadPart' import {createUploadPart} from './uploadPart'
import {uploadParts} from './uploadParts' import {uploadParts} from './uploadParts'
import {delay} from './utils'
export class MultipartFallbackError extends Error {} export class MultipartFallbackError extends Error {}
@@ -87,6 +88,7 @@ export async function uploadVideoMultipart({
) )
} }
// Preserve TypeScript's narrowing inside the recovery callback.
const activeReader = reader const activeReader = reader
if (!activeReader) throw new Error('Video chunk reader is unavailable') if (!activeReader) throw new Error('Video chunk reader is unavailable')
return await finishAndRecover({ return await finishAndRecover({
@@ -165,8 +167,8 @@ async function finishAndRecover({
} }
return await abortThenFallbackOrResolve(jobId, token, finishError) return await abortThenFallbackOrResolve(jobId, token, finishError)
case 'finishing': case 'finishing':
// Finalization owns the reservation and may already have assembled // The service may have assembled the upload even though the finish
// the object. Retrying is idempotent; legacy fallback is unsafe. // request failed. Poll and retry instead of starting a second upload.
await delay(1000, signal) await delay(1000, signal)
continue continue
case 'failed': case 'failed':
@@ -215,7 +217,7 @@ function isRetryableStatusError(err: unknown) {
(err instanceof MultipartUploadError && (err instanceof MultipartUploadError &&
(err.error === 'ServiceOverloaded' || (err.error === 'ServiceOverloaded' ||
err.status === undefined || err.status === undefined ||
err.status >= 500)) isRetryableHttpStatus(err.status)))
) )
} }
@@ -293,17 +295,3 @@ async function getServiceAuthTokenWithRetry(
function throwIfAborted(signal: AbortSignal) { function throwIfAborted(signal: AbortSignal) {
if (signal.aborted) throw new AbortError() if (signal.aborted) throw new AbortError()
} }
function delay(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
function onAbort() {
clearTimeout(timer)
reject(new AbortError())
}
signal.addEventListener('abort', onAbort, {once: true})
})
}
@@ -1,4 +1,5 @@
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {MultipartUploadError} from './api'
import {type ChunkReader, type UploadPartFn} from './types' import {type ChunkReader, type UploadPartFn} from './types'
import {uploadParts} from './uploadParts' import {uploadParts} from './uploadParts'
@@ -66,7 +67,7 @@ describe('uploadParts', () => {
const n = (attemptsByPart.get(part.partNumber) ?? 0) + 1 const n = (attemptsByPart.get(part.partNumber) ?? 0) + 1
attemptsByPart.set(part.partNumber, n) attemptsByPart.set(part.partNumber, n)
if (part.partNumber === 2 && n === 1) { if (part.partNumber === 2 && n === 1) {
return Promise.reject(new Error('transient')) return Promise.reject(new TypeError('transient network error'))
} }
return Promise.resolve({ return Promise.resolve({
partNumber: part.partNumber, partNumber: part.partNumber,
@@ -87,9 +88,59 @@ describe('uploadParts', () => {
expect(results).toHaveLength(3) expect(results).toHaveLength(3)
}) })
it('retries rate-limited parts', async () => {
let attempts = 0
const uploadPart: UploadPartFn = ({part}) => {
attempts++
if (attempts === 1) {
return Promise.reject(
new MultipartUploadError('rate limited', 'RateLimitExceeded', 429),
)
}
return Promise.resolve({
partNumber: part.partNumber,
sizeBytes: part.size,
})
}
await uploadParts({
parts: parts.slice(0, 1),
reader: fakeReader(),
uploadPart,
totalBytes: 10,
setProgress: () => {},
signal: new AbortController().signal,
})
expect(attempts).toBe(2)
})
it('does not retry a non-retryable response', async () => {
const uploadPart = jest.fn<
ReturnType<UploadPartFn>,
Parameters<UploadPartFn>
>(() =>
Promise.reject(
new MultipartUploadError('bad request', 'InvalidRequest', 400),
),
)
await expect(
uploadParts({
parts: parts.slice(0, 1),
reader: fakeReader(),
uploadPart,
totalBytes: 10,
setProgress: () => {},
signal: new AbortController().signal,
}),
).rejects.toThrow('bad request')
expect(uploadPart).toHaveBeenCalledTimes(1)
})
it('throws after exhausting attempts', async () => { it('throws after exhausting attempts', async () => {
const uploadPart: UploadPartFn = () => const uploadPart: UploadPartFn = () =>
Promise.reject(new Error('always fails')) Promise.reject(new TypeError('always fails'))
await expect( await expect(
uploadParts({ uploadParts({
+12 -12
View File
@@ -1,5 +1,7 @@
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {isRetryableHttpStatus} from '#/lib/strings/errors'
import {createProgressAggregator} from './aggregateProgress' import {createProgressAggregator} from './aggregateProgress'
import {MultipartUploadError} from './api'
import {MULTIPART_CONCURRENCY, MULTIPART_MAX_ATTEMPTS} from './constants' import {MULTIPART_CONCURRENCY, MULTIPART_MAX_ATTEMPTS} from './constants'
import { import {
type ChunkReader, type ChunkReader,
@@ -7,6 +9,7 @@ import {
type PartUploadResult, type PartUploadResult,
type UploadPartFn, type UploadPartFn,
} from './types' } from './types'
import {delay} from './utils'
/** /**
* Uploads every part with a concurrency cap and per-part retry, aggregating * Uploads every part with a concurrency cap and per-part retry, aggregating
@@ -118,6 +121,7 @@ async function uploadPartWithRetry({
throw new AbortError() throw new AbortError()
} }
lastError = err lastError = err
if (!isRetryablePartError(err)) throw err
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
await delay(500 * 2 ** (attempt - 1), signal) await delay(500 * 2 ** (attempt - 1), signal)
} }
@@ -126,16 +130,12 @@ async function uploadPartWithRetry({
throw lastError throw lastError
} }
function delay(ms: number, signal: AbortSignal) { function isRetryablePartError(err: unknown) {
return new Promise<void>((resolve, reject) => { return (
const timer = setTimeout(() => { err instanceof TypeError ||
signal.removeEventListener('abort', onAbort) (err instanceof MultipartUploadError &&
resolve() (err.error === 'ServiceOverloaded' ||
}, ms) err.status === undefined ||
function onAbort() { isRetryableHttpStatus(err.status)))
clearTimeout(timer) )
reject(new AbortError())
}
signal.addEventListener('abort', onAbort, {once: true})
})
} }
+15
View File
@@ -0,0 +1,15 @@
import {AbortError} from '#/lib/async/cancelable'
export function delay(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
function onAbort() {
clearTimeout(timer)
reject(new AbortError())
}
signal.addEventListener('abort', onAbort, {once: true})
})
}
+6 -2
View File
@@ -88,6 +88,10 @@ export function isCancelledError(e: unknown) {
// TODO Replace this with error.shouldRetry() when available. -dsb // TODO Replace this with error.shouldRetry() when available. -dsb
const RETRYABLE_ERRORS = [408, 425, 429, 500, 502, 503, 504, 522, 524] const RETRYABLE_ERRORS = [408, 425, 429, 500, 502, 503, 504, 522, 524]
export function shouldRetryError(e: unknown) { export function isRetryableHttpStatus(status: number) {
return e instanceof XRPCError && RETRYABLE_ERRORS.includes(e.status) return RETRYABLE_ERRORS.includes(status)
}
export function shouldRetryError(e: unknown) {
return e instanceof XRPCError && isRetryableHttpStatus(e.status)
} }