Upload: clearer proxy 413 error, clean up after interrupted uploads

- Show a readable message when a reverse proxy rejects the archive with 413
- Delete the freshly created game if its archive upload fails
- Turn ClientDisconnect during upload into a short 400 instead of a traceback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:30:43 +03:00
1 parent a97b46079e
commit 5d9ddb6376
3 files changed
+12 -3

No files matched your search

+5 -1
View File
@@ -2,6 +2,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require from app.deps import DB, CurrentUser, has_perm, require
from app.models import GameVersion, Save, User from app.models import GameVersion, Save, User
@@ -57,10 +58,13 @@ async def upload_version(
async for chunk in request.stream(): async for chunk in request.stream():
f.write(chunk) f.write(chunk)
size += len(chunk) size += len(chunk)
except Exception: except Exception as e:
remove_upload(version.id) remove_upload(version.id)
await db.delete(version) await db.delete(version)
await db.commit() await db.commit()
if isinstance(e, ClientDisconnect):
# usually a proxy in front cut the body off (e.g. its own 413)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None
raise raise
if size == 0: if size == 0:
remove_upload(version.id) remove_upload(version.id)
+3 -1
View File
@@ -98,7 +98,9 @@ export function uploadRaw<T>(method: string, url: string, file: Blob,
/* empty */ /* empty */
} }
if (xhr.status >= 200 && xhr.status < 300) resolve(data); if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText)); else if (xhr.status === 413 && !data?.detail)
reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)"));
else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`)));
}; };
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file); xhr.send(file);
+4 -1
View File
@@ -16,8 +16,9 @@ export function NewGame() {
if (!file) return; if (!file) return;
setError(""); setError("");
setProgress(0); setProgress(0);
let game: Game | null = null;
try { try {
const game = await api.post<Game>("/api/games", { game = await api.post<Game>("/api/games", {
title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""), title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
description, description,
tags: tags.split(",").map((t) => t.trim()).filter(Boolean), tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
@@ -26,6 +27,8 @@ export function NewGame() {
file, setProgress); file, setProgress);
navigate(`/games/${game.id}`); navigate(`/games/${game.id}`);
} catch (err) { } catch (err) {
// don't leave an empty game card behind when the archive never arrived
if (game) await api.del(`/api/games/${game.id}`).catch(() => {});
setError((err as Error).message); setError((err as Error).message);
setProgress(null); setProgress(null);
} }