fix(submit): 分块上传的预算检查复用一分钟内读过的 blob 大小,存储读不到时回 503 让面板自动重试

This commit is contained in:
Lemon-miaow committed 2026-09-27 04:13:48 +08:00
1 parent 2f920cf1c9
commit d2730c6e5a
13 files changed
+335 -25

No files matched your search

@@ -73,6 +73,7 @@
"uploads_unavailable": "Uploads aren't available right now.",
"upload_busy": "Another upload of this submission is still running — wait a moment and try again.",
"upload_offset_mismatch": "The upload fell out of step with the server — try again to pick up where it stopped.",
"uploads_store_unavailable": "The uploads store did not answer — try again in a moment; what was already sent is kept.",
"part_too_large": "A piece of the upload was larger than the server accepts.",
"backup_unavailable": "Backups aren't available right now.",
"account_retired": "This account has been retired — sign in with the account it was migrated to.",
@@ -73,6 +73,7 @@
"uploads_unavailable": "上传功能当前不可用。",
"upload_busy": "这个投稿的另一次上传仍在进行——请稍等片刻再试。",
"upload_offset_mismatch": "上传进度与服务器对不上——重试即可从中断处接着传。",
"uploads_store_unavailable": "上传存储暂时没有响应——稍后重试即可,已传的部分会保留。",
"part_too_large": "上传的某一片超过了服务器接受的大小。",
"backup_unavailable": "备份功能当前不可用。",
"account_retired": "该账户已退役——请使用迁移后的账户登录。",
+7
View File
@@ -660,6 +660,13 @@ describe("image whitelist and builds wire shapes", () => {
expect(humanizeError({ code: "submission_cooldown" })).toMatch(/try again/i);
});
it("says an uploads store that did not answer is worth another try", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ status: 503, code: "uploads_store_unavailable" })).toBe(
"The uploads store did not answer — try again in a moment; what was already sent is kept.",
);
});
it("withdrawSubmission DELETEs /me/submissions/{id}", async () => {
const sub = { id: "sub-4", status: "pending_review" };
const fetchSpy = fakeFetch(sub);
+2
View File
@@ -1170,6 +1170,8 @@ export function humanizeError(e: unknown): string {
return t("upload_busy");
case "upload_offset_mismatch":
return t("upload_offset_mismatch");
case "uploads_store_unavailable":
return t("uploads_store_unavailable");
case "part_too_large":
return t("part_too_large");
case "backup_unavailable":
+19
View File
@@ -134,6 +134,16 @@ describe("uploadContext", () => {
expect((await sentParts()).map(([o]) => o)).toEqual([0, 4, 8]);
});
it("sends a part again when the uploads store did not answer the budget check", async () => {
calls.getContextUpload.mockResolvedValueOnce(at(0)).mockResolvedValueOnce(at(4));
acceptParts();
calls.putContextPart.mockImplementationOnce(async (_id, offset: number, part: Blob) => at(offset + part.size));
calls.putContextPart.mockRejectedValueOnce({ status: 503, code: "uploads_store_unavailable", message: "" });
await uploadContext("sub-1", FILE, { sleep });
expect((await sentParts()).map(([o]) => o)).toEqual([0, 4, 4, 8]);
expect(calls.completeContextUpload).toHaveBeenCalledTimes(1);
});
it("gives up at once on a refusal the next attempt cannot outlast", async () => {
calls.getContextUpload.mockResolvedValue(at(0));
const refused = { status: 400, code: "bad_request", message: "context must be a gzip-compressed tarball" };
@@ -216,6 +226,15 @@ describe("uploadContext completion", () => {
expect(sleep.mock.calls.map((c) => c[0])).toEqual([1000, 2000]);
});
it("completes again when the uploads store did not answer, the staged bytes still all there", async () => {
calls.getContextUpload.mockResolvedValueOnce(at(0)).mockResolvedValue(at(10));
calls.completeContextUpload
.mockRejectedValueOnce({ status: 503, code: "uploads_store_unavailable", message: "" })
.mockResolvedValueOnce({ id: "sub-1" });
await uploadContext("sub-1", FILE, { sleep });
expect(calls.completeContextUpload).toHaveBeenCalledTimes(2);
});
it("surfaces the real refusal when completing again answers with one", async () => {
calls.getContextUpload.mockResolvedValueOnce(at(0)).mockResolvedValue(at(10));
const quota = { status: 403, code: "submission_quota_exceeded", message: "budget" };
+4 -2
View File
@@ -28,14 +28,16 @@ export const MAX_ATTEMPTS = 8;
export const MAX_COMPLETE_WAITS = 40;
// A transient answer is one the next attempt can outlast: no response at all, a
// tunnel or ingress page in place of the API's (upstream_unavailable), or a 409
// that means "ask where the upload stands and send again".
// tunnel or ingress page in place of the API's (upstream_unavailable), an uploads
// store that did not answer the budget check (uploads_store_unavailable), or a
// 409 that means "ask where the upload stands and send again".
export function isTransient(e: unknown): boolean {
const err = e as Partial<ApiError> | null;
if (!err || typeof err.code !== "string") return false;
return (
err.code === "network_error" ||
err.code === "upstream_unavailable" ||
err.code === "uploads_store_unavailable" ||
err.code === "upload_busy" ||
err.code === "upload_offset_mismatch"
);
+2 -2
View File
@@ -1971,7 +1971,7 @@ export interface paths {
get: operations["getContextUpload"];
/**
* Append one part of your chunked context upload.
* @description The body is the part's raw bytes, at most part_max_bytes (32 MiB). offset is where they start: 0 starts the upload over, and anything else must equal the staged length, or the answer is 409 upload_offset_mismatch and the client reads GET for where to resume. The first part must open with the gzip magic (400). The staged total meets the same context cap (400) and storage budget (403) as a single upload. A part that breaks off is cut back off, so the staged bytes are always a prefix of the file. One request per upload at a time (409 upload_busy). Staged bytes untouched for 24 hours are deleted.
* @description The body is the part's raw bytes, at most part_max_bytes (32 MiB). offset is where they start: 0 starts the upload over, and anything else must equal the staged length, or the answer is 409 upload_offset_mismatch and the client reads GET for where to resume. The first part must open with the gzip magic (400). The staged total meets the same context cap (400) and storage budget (403) as a single upload. A part that breaks off is cut back off, so the staged bytes are always a prefix of the file. One request per upload at a time (409 upload_busy). Staged bytes untouched for 24 hours are deleted. The budget check reads blob sizes remembered for up to a minute; when a size has to be read and the uploads store does not answer, the answer is 503 uploads_store_unavailable with Retry-After, and the same part can be sent again.
*/
put: operations["putContextUploadPart"];
post?: never;
@@ -1992,7 +1992,7 @@ export interface paths {
put?: never;
/**
* Store your staged chunked upload as the submission's build context.
* @description Runs every check of POST /api/v1/me/submissions/{id}/context on the staged bytes (format, cap, budget, room), records the digest the same way, and deletes the staged copy. Holds the same per-user upload cooldown (429) and writes the same submission.upload audit event. Nothing staged is 400. After a failure the staged bytes stay, for a retry.
* @description Runs every check of POST /api/v1/me/submissions/{id}/context on the staged bytes (format, cap, budget, room), records the digest the same way, and deletes the staged copy. Holds the same per-user upload cooldown (429) and writes the same submission.upload audit event. Nothing staged is 400. After a failure the staged bytes stay, for a retry. The budget is checked against every blob's size read from the uploads store; a store that does not answer is 503 uploads_store_unavailable with Retry-After.
*/
post: operations["completeContextUpload"];
delete?: never;