feat: handle download limit error gracefully

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-06 07:26:48 +02:00
1 parent 9e61819453
commit fd7c26a760
10 files changed
+171 -43

No files matched your search

+46 -19
View File
@@ -12,6 +12,8 @@ import {
parseMakerworldModelUrl,
selectMakerworldProfiles,
type MakerworldProfileScope,
MAKERWORLD_DOWNLOAD_LIMIT_CODE,
MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE,
} from "./makerworldCloudApi";
import { fetchMakerworldCollectionTitle, parseMakerworldCollectionUrl } from "./makerworldCollections";
import {
@@ -40,18 +42,24 @@ type PrintablesCollectionImportJobBody = ImportRequestBody & { model_ids: string
type MakerworldProfilesImportJobBody = ImportRequestBody & { scope: Exclude<MakerworldProfileScope, "url"> };
// Why a design failed, so a batch reads as one clear cause instead of "N failed". Once a CAPTCHA
// ("rateLimited") or auth failure hits, every remaining item fails the same way.
type ImportFailureReason = "unavailable" | "rateLimited" | "auth" | "other";
// ("rateLimited"), the daily download limit or an auth failure hits, every remaining item fails
// the same way.
type ImportFailureReason = "unavailable" | "rateLimited" | "downloadLimit" | "auth" | "other";
type StopReason = "rateLimited" | "downloadLimit" | "auth";
function classifyImportFailure(err: unknown): ImportFailureReason {
if (err instanceof HttpError) {
if (err.status === 403 || err.status === 404) return "unavailable";
if (err.status === 429) return "rateLimited";
if (err.status === 429) return err.code === MAKERWORLD_DOWNLOAD_LIMIT_CODE ? "downloadLimit" : "rateLimited";
if (err.status === 401) return "auth";
}
return "other";
}
function asStopReason(reason: ImportFailureReason): StopReason | null {
return reason === "rateLimited" || reason === "downloadLimit" || reason === "auth" ? reason : null;
}
async function markJobFailed(jobId: string, err: unknown): Promise<void> {
const message = err instanceof Error ? err.message : "Import failed";
console.error(`Import job ${jobId} failed:`, err);
@@ -64,10 +72,10 @@ type ProfileImportTotals = {
alreadyInLibrary: number;
failed: number;
printId: string | null;
stopReason: "rateLimited" | "auth" | null;
stopReason: StopReason | null;
};
/** Imports each profile URL onto one print, in order. Once a CAPTCHA ("rateLimited") or auth
/** Imports each profile URL onto one print, in order. Once a CAPTCHA, download-limit or auth
* failure hits, every remaining profile fails the same way without another MakerWorld call. */
async function importProfileUrlsSequentially(
userId: string,
@@ -98,8 +106,7 @@ async function importProfileUrlsSequentially(
else totals.imported++;
} catch (err) {
totals.failed++;
const reason = classifyImportFailure(err);
if (reason === "rateLimited" || reason === "auth") totals.stopReason = reason;
totals.stopReason = asStopReason(classifyImportFailure(err));
}
}
totals.processed++;
@@ -165,6 +172,10 @@ export async function runMakerworldProfilesImportJob(
bodyParts.push(
`the rest blocked by a MakerWorld CAPTCHA challenge — this usually clears in 1-4 hours, then import the model again to add the missing profiles`,
);
} else if (stopReason === "downloadLimit") {
bodyParts.push(
`the rest not downloaded — MakerWorld's daily download limit was reached, import the model again tomorrow to add the missing profiles`,
);
} else if (stopReason === "auth") {
bodyParts.push(
`the rest failed because your MakerWorld session expired — update the cookie in Settings and import again`,
@@ -195,6 +206,7 @@ export async function runCollectionImportJob(
let processed = 0;
let unavailable = 0;
let rateLimited = 0;
let downloadLimited = 0;
let authFailed = 0;
const failed: string[] = [];
const successPrintIds: string[] = [];
@@ -210,6 +222,10 @@ export async function runCollectionImportJob(
makerworldPaceMs: IMPORT_MAKERWORLD_CALL_DELAY_MS,
};
try {
// Every later download would hit the same limit, so don't spend more requests on it.
if (downloadLimited) {
throw new HttpError(429, MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE, MAKERWORLD_DOWNLOAD_LIMIT_CODE);
}
const { print, alreadyImported } = await importPrintFromUrl(userId, modelUrl, itemBody);
successPrintIds.push(print.id);
if (alreadyImported) alreadyInLibrary++;
@@ -219,6 +235,7 @@ export async function runCollectionImportJob(
const reason = classifyImportFailure(err);
if (reason === "unavailable") unavailable++;
else if (reason === "rateLimited") rateLimited++;
else if (reason === "downloadLimit") downloadLimited++;
else if (reason === "auth") authFailed++;
} finally {
processed++;
@@ -275,13 +292,18 @@ export async function runCollectionImportJob(
const label = collectionTitle ? `"${collectionTitle}"` : "a MakerWorld collection";
const bodyParts: string[] = [];
if (alreadyInLibrary) bodyParts.push(`${alreadyInLibrary} already in your library`);
const otherFailed = failed.length - unavailable - rateLimited - authFailed;
const otherFailed = failed.length - unavailable - rateLimited - downloadLimited - authFailed;
if (unavailable) bodyParts.push(`${unavailable} unavailable (private, deleted, or hidden)`);
if (rateLimited) {
bodyParts.push(
`${rateLimited} blocked by a MakerWorld CAPTCHA challenge (too many requests at once) — this usually clears in 1-4 hours, then retry the same collection`,
);
}
if (downloadLimited) {
bodyParts.push(
`${downloadLimited} not downloaded — MakerWorld's daily download limit was reached, retry the same collection tomorrow`,
);
}
if (authFailed)
bodyParts.push(
`${authFailed} failed because your MakerWorld session expired — update the cookie in Settings and retry`,
@@ -698,7 +720,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
let profilesImported = 0;
let profilesProcessed = 0;
let printId: string | null = null;
let stopReason: "rateLimited" | "auth" | null = null;
let stopReason: StopReason | null = null;
let processed = (await countJobItems(jobId, "DONE")) + (await countJobItems(jobId, "FAILED"));
for (const item of items) {
@@ -716,10 +738,11 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
while (!settled) {
attempts++;
if (stopReason) {
itemError =
stopReason === "rateLimited"
? "blocked by a MakerWorld CAPTCHA challenge — retry once it clears (usually 1-4 hours)"
: "the MakerWorld session expired — update the cookie in Settings, then retry";
itemError = {
rateLimited: "blocked by a MakerWorld CAPTCHA challenge — retry once it clears (usually 1-4 hours)",
downloadLimit: "MakerWorld's daily download limit was reached — retry tomorrow",
auth: "the MakerWorld session expired — update the cookie in Settings, then retry",
}[stopReason];
settled = true;
break;
}
@@ -742,10 +765,11 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
itemError = `${result.failed} of ${result.processed} print profiles failed`;
if (result.stopReason) {
stopReason = result.stopReason;
itemError +=
result.stopReason === "rateLimited"
? " (MakerWorld CAPTCHA challenge — retry once it clears)"
: " (the MakerWorld session expired — update the cookie in Settings, then retry)";
itemError += {
rateLimited: " (MakerWorld CAPTCHA challenge — retry once it clears)",
downloadLimit: " (MakerWorld's daily download limit — retry tomorrow)",
auth: " (the MakerWorld session expired — update the cookie in Settings, then retry)",
}[result.stopReason];
}
settled = true;
} else {
@@ -754,8 +778,7 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
}
} catch (err) {
itemError = failureMessage(err);
const reason = classifyImportFailure(err);
if (reason === "rateLimited" || reason === "auth") stopReason = reason;
stopReason = asStopReason(classifyImportFailure(err)) ?? stopReason;
if (!isTransientImportFailure(err) || attempts >= LINKS_MAX_ATTEMPTS) {
settled = true;
} else {
@@ -809,6 +832,10 @@ async function runLinks(jobId: string, userId: string, body: LinksImportJobBody)
bodyParts.push(
`the rest blocked by a MakerWorld CAPTCHA challenge — this usually clears in 1-4 hours, then retry the failed links`,
);
} else if (stopReason === "downloadLimit") {
bodyParts.push(
`the rest not downloaded — MakerWorld's daily download limit was reached, retry the failed links tomorrow`,
);
} else if (stopReason === "auth") {
bodyParts.push(
`the rest failed because your MakerWorld session expired — update the cookie in Settings, then retry`,
+9 -3
View File
@@ -10,7 +10,12 @@ import {
IMPORT_USER_AGENT,
} from "../config";
import { isJsonContentType } from "../utils/fileUtils";
import { isCaptchaChallenge, MakerworldCaptchaError } from "./makerworldCaptcha";
import {
isCaptchaChallenge,
isDownloadLimitReply,
MakerworldCaptchaError,
MakerworldDownloadLimitError,
} from "./makerworldCaptcha";
import {
extractJsonFromBrowserBody,
fetchViaFlaresolverr,
@@ -592,11 +597,12 @@ function makerworldModelIdFromUrl(url: string): string | null {
}
}
/** A CAPTCHA reply (HTTP 418) is thrown, not treated as "no URL": the fallbacks after it would only
* hit the same block and end in a misleading "No downloadable model file found". */
/** A CAPTCHA reply (HTTP 418) or the daily download limit is thrown, not treated as "no URL": the
* fallbacks after it would only hit the same block and end in a misleading error. */
function makerworldDownloadUrlOrCaptcha(data: unknown, apiUrl: string): string | null {
const url = data ? extractDownloadUrlFromResponse(data, apiUrl) : null;
if (!url && isCaptchaChallenge(data)) throw new MakerworldCaptchaError();
if (!url && isDownloadLimitReply(data)) throw new MakerworldDownloadLimitError();
return url;
}
+8
View File
@@ -40,6 +40,8 @@ import {
MakerworldAuthError,
MakerworldCaptchaError,
makerworldCaptchaCooloffActive,
MAKERWORLD_DOWNLOAD_LIMIT_CODE,
MakerworldDownloadLimitError,
parseMakerworldModelUrl,
resolveMakerworldViaCloudApi,
completeMakerworldAuthor,
@@ -286,6 +288,9 @@ async function tryMakerworldCloudApi(url: string, body: ImportRequestBody): Prom
return resolved;
} catch (err) {
if (err instanceof MakerworldCaptchaError) throw new HttpError(429, err.message);
if (err instanceof MakerworldDownloadLimitError) {
throw new HttpError(429, err.message, MAKERWORLD_DOWNLOAD_LIMIT_CODE);
}
// 400, not 401: the frontend treats any 401 as an expired Thingport session and logs out.
if (err instanceof MakerworldAuthError) throw new HttpError(400, err.message);
throw err;
@@ -382,6 +387,9 @@ export async function openImportResponse(
resolved = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie, requestedInstanceId);
} catch (err) {
if (err instanceof MakerworldCaptchaError) throw new HttpError(429, err.message);
if (err instanceof MakerworldDownloadLimitError) {
throw new HttpError(429, err.message, MAKERWORLD_DOWNLOAD_LIMIT_CODE);
}
throw err;
}
if (resolved) {
+22
View File
@@ -41,6 +41,28 @@ export class MakerworldCaptchaError extends Error {
}
}
// The per-account daily download cap: HTTP 400 with {"code":-1,"error":"You've reached your daily
// download limit."}. Every later download fails the same way until it resets.
export function isDownloadLimitReply(data: unknown): boolean {
if (!isRecord(data)) return false;
return ["error", "message", "msg"].some(
(key) => typeof data[key] === "string" && /download limit/i.test(data[key] as string),
);
}
export const MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE =
"MakerWorld daily download limit reached for this account. Try again tomorrow.";
// The HttpError code, so batch runners can tell it from a CAPTCHA (both are 429).
export const MAKERWORLD_DOWNLOAD_LIMIT_CODE = "MAKERWORLD_DOWNLOAD_LIMIT";
export class MakerworldDownloadLimitError extends Error {
constructor() {
super(MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE);
this.name = "MakerworldDownloadLimitError";
}
}
export class MakerworldAuthError extends Error {
constructor() {
super("Your MakerWorld session has expired or was rejected. Update the cookie in Settings and try again.");
@@ -15,9 +15,11 @@ import { maybeSleep, sleep } from "../utils/concurrency";
import { HttpError } from "../utils/fileUtils";
import {
isCaptchaChallenge,
isDownloadLimitReply,
makerworldCaptchaCooloffActive,
MakerworldAuthError,
MakerworldCaptchaError,
MakerworldDownloadLimitError,
noteCaptchaChallenge,
} from "./makerworldCaptcha";
@@ -26,6 +28,9 @@ export {
makerworldCaptchaCooloffActive,
MakerworldAuthError,
MakerworldCaptchaError,
MAKERWORLD_DOWNLOAD_LIMIT_CODE,
MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE,
MakerworldDownloadLimitError,
} from "./makerworldCaptcha";
// makerworld.com puts Cloudflare and a Geetest CAPTCHA in front of download resolution;
@@ -364,6 +369,7 @@ export async function resolveMakerworldViaCloudApi(
noteCaptchaChallenge();
throw new MakerworldCaptchaError();
}
if (isDownloadLimitReply(downloadResult.data)) throw new MakerworldDownloadLimitError();
if (downloadResult.status === 401 || downloadResult.status === 403) throw new MakerworldAuthError();
if (downloadResult.status !== 200 || !isRecord(downloadResult.data)) return null;
const body = downloadResult.data;
+17
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
MakerworldCaptchaError,
makerworldCaptchaCooloffActive,
MakerworldDownloadLimitError,
resolveMakerworldViaCloudApi,
} from "../src/services/makerworldCloudApi";
@@ -47,6 +48,22 @@ describe("resolveMakerworldViaCloudApi", () => {
expect(makerworldCaptchaCooloffActive()).toBe(false);
});
it("throws MakerworldDownloadLimitError on the daily download limit reply", async () => {
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
if (url.includes("/design-service/design/")) return jsonResponse(200, VALID_DESIGN);
if (url.includes("/iot-service/api/user/profile/")) {
return jsonResponse(400, { code: -1, error: "You've reached your daily download limit." });
}
throw new Error(`Unexpected fetch to ${url}`);
}) as unknown as typeof fetch;
await expect(resolveMakerworldViaCloudApi("321", null, "test-token")).rejects.toBeInstanceOf(
MakerworldDownloadLimitError,
);
expect(makerworldCaptchaCooloffActive()).toBe(false);
});
it("throws MakerworldCaptchaError and starts the cooloff on a real CAPTCHA challenge, then short-circuits further calls without hitting the network again", async () => {
const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
+5 -3
View File
@@ -128,8 +128,9 @@ function claimStep(tabId: number): Promise<MakerworldJob | null> {
return claimed;
}
/** A CAPTCHA pauses the run on this model for the user to solve; any other failure stops it, since
* it almost always means MakerWorld rejected the request, and more requests would make it worse. */
/** A CAPTCHA pauses the run on this model for the user to solve; any other failure, the daily
* download limit included, stops it, since it almost always means MakerWorld rejected the
* request, and more requests would make it worse. */
export async function advanceJob(tabId: number): Promise<void> {
const job = await claimStep(tabId);
if (!job) return;
@@ -142,7 +143,8 @@ export async function advanceJob(tabId: number): Promise<void> {
let resolveError: string | null = null;
try {
const reply = await withTimeout(sendToTab(tabId, "RESOLVE_MAKERWORLD_DOWNLOAD_URL"), DOWNLOAD_RESOLVE_TIMEOUT_MS);
// The content script only replies with an error for a CAPTCHA; the backend would hit it too.
// The content script only replies with an error for a CAPTCHA or the daily download limit;
// the backend would hit it too.
if (reply && reply.ok) resolved = reply.data;
else if (reply) resolveError = reply.error;
} catch {}
+4 -3
View File
@@ -6,7 +6,7 @@ import { listen, send, type ContentMessages } from "../shared/messages";
import { CONFIG_CHANGE_KEYS } from "../shared/storage";
import { classifyUrl } from "../shared/urls";
import { setContext, type LibraryState } from "./context";
import { MakerworldCaptchaError, resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
import { isMakerworldBlockingError, resolveMakerworldDownloadUrl } from "./makerworld/downloadResolver";
import { offerNormalizedDownload, unmountNormalizedDownload } from "./makerworld/normalizedDownload";
import { mountJobOverlay, mountPausedJobOverlay } from "./overlays";
import { loadPanel } from "./panels";
@@ -155,10 +155,11 @@ if (!window.thingportGrabInjected) {
});
listen<ContentMessages>({
// A CAPTCHA comes back as an error reply so the guided import can say so.
// A CAPTCHA or the daily download limit comes back as an error reply so the guided import can
// say so.
RESOLVE_MAKERWORLD_DOWNLOAD_URL: () =>
resolveMakerworldDownloadUrl(location.href).catch((err) => {
if (err instanceof MakerworldCaptchaError) throw err;
if (isMakerworldBlockingError(err)) throw err;
return null;
}),
MAKERWORLD_JOB_UPDATED: () => {
@@ -1,7 +1,8 @@
// Resolves the MakerWorld file URL in the page instead of the backend, whose resolution calls are
// what trip MakerWorld's account-wide CAPTCHA. Same-origin fetches carry the real session and look
// like the page's own traffic. Returns null on any failure so the backend can resolve it instead,
// except a CAPTCHA, which is thrown: the backend would only hit the same wall.
// except a CAPTCHA or the daily download limit, which are thrown: the backend would only hit the
// same wall.
import type { ResolvedDownload } from "../../shared/messages";
import { send } from "../../shared/messages";
@@ -56,6 +57,29 @@ export class MakerworldCaptchaError extends Error {
}
}
export const MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE =
"MakerWorld daily download limit reached for this account. Try again tomorrow.";
/** The per-account daily cap: HTTP 400 with {"code":-1,"error":"You've reached your daily download
* limit."}. Every later download fails the same way until it resets. */
export class MakerworldDownloadLimitError extends Error {
constructor() {
super(MAKERWORLD_DOWNLOAD_LIMIT_MESSAGE);
this.name = "MakerworldDownloadLimitError";
}
}
/** Errors that end an import outright: retrying or falling back to the backend can't get past them. */
export function isMakerworldBlockingError(err: unknown): boolean {
return err instanceof MakerworldCaptchaError || err instanceof MakerworldDownloadLimitError;
}
function looksLikeDownloadLimit(data: unknown): boolean {
if (!data || typeof data !== "object") return false;
const { error, message, msg } = data as Record<string, unknown>;
return [error, message, msg].some((value) => typeof value === "string" && /download limit/i.test(value));
}
/** Only asked of a body without a download URL, whose filename could otherwise say "robot". */
function looksLikeCaptcha(data: unknown): boolean {
if (!data || typeof data !== "object") return false;
@@ -74,9 +98,10 @@ async function fetchMakerworldApiJson(url: string, nonce: string | null): Promis
const data: unknown = await res.json();
if (extractDownloadUrl(data)) return data;
if (looksLikeCaptcha(data)) throw new MakerworldCaptchaError();
if (looksLikeDownloadLimit(data)) throw new MakerworldDownloadLimitError();
return res.ok ? data : null;
} catch (err) {
if (err instanceof MakerworldCaptchaError) throw err;
if (isMakerworldBlockingError(err)) throw err;
return null;
} finally {
clearTimeout(timeout);
@@ -269,7 +294,7 @@ export async function resolveMakerworldDownloadUrl(
: { downloadUrl: viaClick.url, instanceId };
}
return resolveFromPageApi(pageUrl).catch((err) => {
if (err instanceof MakerworldCaptchaError) throw err;
if (isMakerworldBlockingError(err)) throw err;
return null;
});
}
@@ -281,6 +306,9 @@ export async function resolveMakerworldProfileDownload(
): Promise<ResolvedDownload | null> {
const page = await loadMakerworldDesignForPage(pageUrl);
if (!page) return null;
const downloadUrl = await fetchInstanceDownloadUrl(page.design, instanceId).catch(() => null);
const downloadUrl = await fetchInstanceDownloadUrl(page.design, instanceId).catch((err) => {
if (err instanceof MakerworldDownloadLimitError) throw err;
return null;
});
return downloadUrl ? { downloadUrl, instanceId, design: makerworldDesignForImport(page.design) } : null;
}
+22 -11
View File
@@ -4,7 +4,11 @@
import type { InspectResult, ZipEntriesResult } from "../../shared/api";
import { request } from "../../shared/messages";
import { ctx } from "../context";
import { resolveMakerworldDownloadUrl, resolveMakerworldProfileDownload } from "../makerworld/downloadResolver";
import {
isMakerworldBlockingError,
resolveMakerworldDownloadUrl,
resolveMakerworldProfileDownload,
} from "../makerworld/downloadResolver";
import {
currentMakerworldProfileTitle,
loadMakerworldDesignForPage,
@@ -215,11 +219,11 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
const { url, instanceUrl, classification, title } = ctx();
renderFunStatus(importingPhrases(classification.provider));
const collectionId = await collectionIdFor(choice);
const resolved =
classification.provider === "makerworld" && classification.type === "model"
? await resolveMakerworldDownloadUrl(url).catch(() => null)
: null;
try {
const resolved =
classification.provider === "makerworld" && classification.type === "model"
? await resolveMakerworldDownloadUrl(url).catch(nullUnlessBlocking)
: null;
// One message so import and collection filing finish even if the page is gone.
const print = await request("IMPORT_SINGLE", { url, entries: opts?.entries, collectionId, resolved, title });
const link = print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`;
@@ -241,8 +245,14 @@ async function runDirectImport(opts?: { entries?: string[] }): Promise<void> {
}
}
/** Null lets the backend resolve it; a CAPTCHA or the download limit would stop it too. */
function nullUnlessBlocking(err: unknown): null {
if (isMakerworldBlockingError(err)) throw err;
return null;
}
/** The first profile creates (or finds) the model; later ones are added as files. Stops on a
* CAPTCHA, which would fail every later one. */
* CAPTCHA or the daily download limit, which would fail every later one. */
async function runProfilesImport(scope: MakerworldProfileScope, collectionId: string | null): Promise<void> {
const { url, instanceUrl, title } = ctx();
const page = await loadMakerworldDesignForPage(url);
@@ -261,11 +271,11 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
renderPanel(statusHtml(`Importing print profile ${index + 1} of ${ids.length}…`));
if (index > 0) await new Promise((resolve) => setTimeout(resolve, PROFILE_GAP_MS));
// The page's Download button gives exactly the link's profile.
const resolved =
index === 0
? await resolveMakerworldDownloadUrl(url).catch(() => null)
: await resolveMakerworldProfileDownload(url, instanceId).catch(() => null);
try {
const resolved =
index === 0
? await resolveMakerworldDownloadUrl(url).catch(nullUnlessBlocking)
: await resolveMakerworldProfileDownload(url, instanceId).catch(nullUnlessBlocking);
const print = await request("IMPORT_SINGLE", {
url: index === 0 ? url : profileUrl(url, instanceId),
collectionId,
@@ -278,7 +288,8 @@ async function runProfilesImport(scope: MakerworldProfileScope, collectionId: st
} catch (err) {
failed++;
lastError = err;
if (err instanceof Error && /captcha/i.test(err.message)) {
// The backend's errors arrive as plain text.
if (err instanceof Error && /captcha|download limit/i.test(err.message)) {
failed += ids.length - index - 1;
break;
}