fix: upscale times out behind Cloudflare Tunnel due to blocking HTTP request

The upscale route held the HTTP connection open for the full duration of
Python sidecar processing (30-300s). Behind proxies with connection
timeouts (Cloudflare Tunnel: 100s), this caused HTTP 524 errors.

The route now returns 202 Accepted immediately after upload validation
and processes in the background. The result (downloadUrl, sizes, etc.)
is delivered via the existing SSE progress channel. The frontend detects
the 202 and waits for the SSE completion event instead of reading the
XHR response body. A reconnect-safe completion store ensures results
survive brief SSE disconnects.

Closes #106
This commit is contained in:
SnapOtter
2026-04-30 23:43:55 +08:00
parent 9d8c3f4027
commit 4900d8a4fe
4 changed files with 223 additions and 97 deletions
+47 -7
View File
@@ -94,6 +94,7 @@ export function useToolProcessor(toolId: string) {
// Generate client job ID for SSE correlation
const clientJobId = generateId();
let asyncMode = false;
// For AI tools, open SSE before uploading
if (isAiTool) {
@@ -104,8 +105,42 @@ export function useToolProcessor(toolId: string) {
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
// Scale server progress (0-100) into 15-100 range
if (data.type !== "single") return;
if (data.phase === "complete" && data.result) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
processedFilename: null,
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (data.phase === "failed" && asyncMode) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (typeof data.percent === "number") {
const scaled = 15 + (data.percent / 100) * 85;
setProgress((prev) => ({
...prev,
@@ -120,11 +155,13 @@ export function useToolProcessor(toolId: string) {
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
if (!asyncMode) {
es.close();
eventSourceRef.current = null;
}
};
} catch {
// EventSource creation failed proceed without SSE
// EventSource creation failed -- proceed without SSE
}
}
@@ -209,6 +246,11 @@ export function useToolProcessor(toolId: string) {
};
xhr.onload = () => {
if (xhr.status === 202) {
asyncMode = true;
return;
}
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
@@ -220,8 +262,6 @@ export function useToolProcessor(toolId: string) {
try {
const result: ProcessResult = JSON.parse(xhr.responseText);
setWarning(result.warning ?? null);
// Write result to the entry that was being processed (captured at
// request time), not whatever entry happens to be selected now.
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,