Back off from e621 rate limits and pace requests more conservatively
e621 intermittently answers 429 to the IQDB endpoint; browsers hide that
status behind CORS ('Access-Control-Allow-Origin missing'), so the SPA
cannot read it. Treat every network-level failure as a possible rate limit
and pause all e621 traffic for a minute. The cooldown is shared through
localStorage so extra tabs respect it, requests are spaced 1.5s apart
instead of 1s, user-cancelled requests do not trigger a cooldown, and the
upload queue waits the cooldown out with a countdown instead of looking
stuck.
Server side: the per-process e621 gap goes from 0.5s to 1s so two gunicorn
workers cannot together exceed e621's 2/s hard limit.
This commit is contained in:
@@ -21,6 +21,7 @@ import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
E621Error,
|
||||
e621CooldownRemainingMs,
|
||||
e621ErrorMessage,
|
||||
effectiveCredentials,
|
||||
fetchPost,
|
||||
@@ -823,6 +824,15 @@ export default function UploadPage() {
|
||||
setIqdbError(null);
|
||||
try {
|
||||
while (iqdbQueueRef.current.length > 0) {
|
||||
// Wait out a rate-limit cooldown before touching e621 again.
|
||||
const cooldown = e621CooldownRemainingMs();
|
||||
if (cooldown > 0) {
|
||||
setIqdbError(
|
||||
`e621 rate limit — resuming in ${Math.ceil(cooldown / 1000)}s`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, cooldown + 250));
|
||||
setIqdbError(null);
|
||||
}
|
||||
const tempId = iqdbQueueRef.current.shift() as string;
|
||||
const cached =
|
||||
queryClient.getQueryData<TempUpload[]>(["uploads"]) ?? [];
|
||||
|
||||
@@ -89,13 +89,50 @@ export function effectiveCredentials(
|
||||
const GIT_HASH = typeof __GIT_HASH__ === "string" ? __GIT_HASH__ : "dev";
|
||||
const CLIENT_VERSION = `J621/${GIT_HASH} (JakeBreath)`;
|
||||
|
||||
// e621 allows 2 requests/second hard, 1/second sustained. Serialize every
|
||||
// request through a queue with a 1s minimum gap.
|
||||
// e621 allows 2 requests/second hard, 1/second sustained — and the IQDB
|
||||
// endpoint is stricter, so stay comfortably under it. Serialize every request
|
||||
// through a queue with a minimum gap.
|
||||
let lastRequestAt = 0;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/** A hung request would block the whole serialized queue forever. */
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const REQUEST_GAP_MS = 1500;
|
||||
|
||||
/** A 429 (or a CORS-blocked failure) pauses every e621 call for a while. */
|
||||
const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
||||
const COOLDOWN_KEY = "j621.e621.cooldown";
|
||||
let cooldownUntil = 0;
|
||||
|
||||
function storedCooldown(): number {
|
||||
try {
|
||||
return Number(window.localStorage.getItem(COOLDOWN_KEY)) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function e621CooldownRemainingMs(): number {
|
||||
return Math.max(0, Math.max(cooldownUntil, storedCooldown()) - Date.now());
|
||||
}
|
||||
|
||||
function startCooldown(): void {
|
||||
cooldownUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS;
|
||||
try {
|
||||
window.localStorage.setItem(COOLDOWN_KEY, String(cooldownUntil));
|
||||
} catch {
|
||||
// Storage disabled; the in-memory cooldown still applies.
|
||||
}
|
||||
}
|
||||
|
||||
function isRateLimitError(error: unknown): boolean {
|
||||
if (error instanceof E621Error) {
|
||||
return error.status === 429 || error.status === 503;
|
||||
}
|
||||
// Browsers hide the status of a CORS-blocked response; a rate-limited 429
|
||||
// arrives here as a network error.
|
||||
return error instanceof TypeError || error instanceof DOMException;
|
||||
}
|
||||
|
||||
function schedule<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = queue.then(task, task);
|
||||
@@ -107,7 +144,11 @@ function schedule<T>(task: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
async function throttle(): Promise<void> {
|
||||
const wait = Math.max(0, lastRequestAt + 1000 - Date.now());
|
||||
const wait = Math.max(
|
||||
0,
|
||||
lastRequestAt + REQUEST_GAP_MS - Date.now(),
|
||||
e621CooldownRemainingMs(),
|
||||
);
|
||||
if (wait > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
@@ -177,6 +218,12 @@ export function e621Request<T>(
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
// Cancelled requests (navigation, unmounted query) are not rate limits.
|
||||
if (!options.signal?.aborted && isRateLimitError(error)) {
|
||||
startCooldown();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", forwardAbort);
|
||||
@@ -209,7 +256,7 @@ export function e621ErrorMessage(error: unknown): string {
|
||||
return "e621 did not answer in time.";
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
return "Could not reach e621. Check the base URL and your connection.";
|
||||
return "Could not reach e621. If this keeps happening it is usually the rate limit — wait a minute and retry.";
|
||||
}
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
|
||||
Reference in New Issue
Block a user