fix: harden image upload reliability
This commit is contained in:
parent
13a6e3df83
commit
935f07d6ec
@ -6191,6 +6191,7 @@ export function CanvasChatPanel({
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
|
||||||
@ -6263,6 +6264,7 @@ export function CanvasChatPanel({
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
|
||||||
@ -6435,6 +6437,7 @@ export function CanvasChatPanel({
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
|
||||||
@ -8801,6 +8804,7 @@ export function CanvasChatPanel({
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('purpose', 'canvas-upload');
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
|
||||||
@ -9052,6 +9056,7 @@ export function CanvasChatPanel({
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('purpose', 'canvas-upload');
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
|
||||||
|
|||||||
@ -1087,6 +1087,7 @@ async function uploadChatAttachmentBlob(blob: Blob, displayName: string) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const contentType = uploadBlob.type || "image/jpeg";
|
const contentType = uploadBlob.type || "image/jpeg";
|
||||||
formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType));
|
formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType));
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
let payload: any = null;
|
let payload: any = null;
|
||||||
@ -6370,6 +6371,7 @@ export function ImageNode({
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", blob, `annotated-${Date.now()}.png`);
|
formData.append("file", blob, `annotated-${Date.now()}.png`);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
const uploadResponse = await fetchUploadWithRetry(formData);
|
const uploadResponse = await fetchUploadWithRetry(formData);
|
||||||
if (uploadResponse.ok) {
|
if (uploadResponse.ok) {
|
||||||
const result = await uploadResponse.json();
|
const result = await uploadResponse.json();
|
||||||
@ -6406,6 +6408,7 @@ export function ImageNode({
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
|
formData.append("file", blob, `canvas-resize-${Date.now()}.png`);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
const uploadResponse = await fetchUploadWithRetry(formData);
|
const uploadResponse = await fetchUploadWithRetry(formData);
|
||||||
|
|
||||||
|
|||||||
@ -138,6 +138,7 @@ export function TextNode({
|
|||||||
const dimensions = await getPastedImageDimensions(file);
|
const dimensions = await getPastedImageDimensions(file);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
|||||||
@ -604,6 +604,7 @@ function getFlowBoardImageDimensions(file: File) {
|
|||||||
async function uploadFlowBoardImageFile(file: File) {
|
async function uploadFlowBoardImageFile(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
formData.append("purpose", "canvas-upload");
|
||||||
|
|
||||||
const response = await fetchUploadWithRetry(formData);
|
const response = await fetchUploadWithRetry(formData);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -1,8 +1,70 @@
|
|||||||
const DEFAULT_MAX_RETRIES = 3;
|
const DEFAULT_MAX_RETRIES = 3;
|
||||||
const MAX_BACKOFF_MS = 8000;
|
const MAX_BACKOFF_MS = 8000;
|
||||||
|
const MAX_CONCURRENT_UPLOADS = 3;
|
||||||
|
|
||||||
function sleep(ms: number) {
|
const RETRYABLE_UPLOAD_STATUSES = new Set([
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
408,
|
||||||
|
425,
|
||||||
|
429,
|
||||||
|
500,
|
||||||
|
502,
|
||||||
|
503,
|
||||||
|
504,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let activeUploadCount = 0;
|
||||||
|
const uploadQueue: Array<() => void> = [];
|
||||||
|
|
||||||
|
function createAbortError() {
|
||||||
|
const error = new Error("Upload aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.reject(createAbortError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = globalThis.setTimeout(() => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
reject(createAbortError());
|
||||||
|
};
|
||||||
|
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireUploadSlot() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const start = () => {
|
||||||
|
activeUploadCount += 1;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeUploadCount < MAX_CONCURRENT_UPLOADS) {
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
uploadQueue.push(start);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withUploadSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
await acquireUploadSlot();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
activeUploadCount = Math.max(0, activeUploadCount - 1);
|
||||||
|
uploadQueue.shift()?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRetryAfterMs(value: string | null): number | null {
|
function parseRetryAfterMs(value: string | null): number | null {
|
||||||
@ -23,6 +85,41 @@ function computeBackoffMs(attempt: number, retryAfterMs: number | null) {
|
|||||||
return exp + jitter;
|
return exp + jitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRetryableUploadStatus(status: number) {
|
||||||
|
return RETRYABLE_UPLOAD_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown, signal?: AbortSignal) {
|
||||||
|
return (
|
||||||
|
signal?.aborted ||
|
||||||
|
(error instanceof Error && error.name === "AbortError")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFormData(formData: FormData) {
|
||||||
|
const copy = new FormData();
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
copy.append(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename =
|
||||||
|
typeof File !== "undefined" && value instanceof File
|
||||||
|
? value.name
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
copy.append(key, value, filename);
|
||||||
|
} else {
|
||||||
|
copy.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchUploadWithRetry(
|
export async function fetchUploadWithRetry(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
options?: {
|
options?: {
|
||||||
@ -32,29 +129,40 @@ export async function fetchUploadWithRetry(
|
|||||||
) {
|
) {
|
||||||
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||||
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
return withUploadSlot(async () => {
|
||||||
const response = await fetch("/api/files/upload", {
|
for (let attempt = 0; ; attempt++) {
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
signal: options?.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 429 || attempt >= maxRetries) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
|
|
||||||
if (!retryAfterMs) {
|
|
||||||
try {
|
try {
|
||||||
const payload = await response.clone().json();
|
const response = await fetch("/api/files/upload", {
|
||||||
const retryAfterSec = Number(payload?.retryAfter || 0);
|
method: "POST",
|
||||||
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
body: cloneFormData(formData),
|
||||||
retryAfterMs = Math.round(retryAfterSec * 1000);
|
signal: options?.signal,
|
||||||
}
|
});
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const delayMs = computeBackoffMs(attempt, retryAfterMs);
|
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
||||||
await sleep(delayMs);
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
|
||||||
|
if (!retryAfterMs) {
|
||||||
|
try {
|
||||||
|
const payload = await response.clone().json();
|
||||||
|
const retryAfterSec = Number(payload?.retryAfter || 0);
|
||||||
|
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
||||||
|
retryAfterMs = Math.round(retryAfterSec * 1000);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = computeBackoffMs(attempt, retryAfterMs);
|
||||||
|
await sleep(delayMs, options?.signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = computeBackoffMs(attempt, null);
|
||||||
|
await sleep(delayMs, options?.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user