fix(audit): back off and retry on 429 instead of recording the page as blocked (#562)

This commit is contained in:
Ben Senescu 2026-08-28 15:27:23 -04:00 committed by GitHub
parent accac73e16
commit bb099ad65a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 177 additions and 16 deletions

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
backOff,
IDLE_THROTTLE,
parseRetryAfterMs,
} from "@/server/lib/audit/crawl-throttle";
describe("parseRetryAfterMs", () => {
it("reads delay-seconds and HTTP-dates, ignores garbage", () => {
expect(parseRetryAfterMs("5")).toBe(5_000);
const inTenSeconds = new Date(Date.now() + 10_000).toUTCString();
const ms = parseRetryAfterMs(inTenSeconds);
expect(ms).toBeGreaterThan(8_000);
expect(ms).toBeLessThanOrEqual(10_000);
expect(parseRetryAfterMs("soon")).toBeUndefined();
expect(parseRetryAfterMs(null)).toBeUndefined();
});
});
describe("backOff", () => {
it("pauses for Retry-After within bounds and starts spacing launches", () => {
expect(backOff(IDLE_THROTTLE, 5_000, 1_000)).toEqual({
pausedUntil: 6_000,
launchGapMs: 250,
});
expect(backOff(IDLE_THROTTLE, undefined, 1_000).pausedUntil).toBe(3_000);
expect(backOff(IDLE_THROTTLE, 0, 1_000).pausedUntil).toBe(2_000);
expect(backOff(IDLE_THROTTLE, 3_600_000, 1_000).pausedUntil).toBe(21_000);
});
it("treats a burst during one pause as one signal, then doubles per episode", () => {
const first = backOff(IDLE_THROTTLE, 2_000, 1_000);
const burst = backOff(first, 2_000, 1_500);
expect(burst).toEqual({ pausedUntil: 3_500, launchGapMs: 250 });
let throttle = burst;
for (const now of [10_000, 20_000, 30_000, 40_000, 50_000]) {
throttle = backOff(throttle, 2_000, now);
}
expect(throttle.launchGapMs).toBe(4_000);
});
});

View File

@ -0,0 +1,58 @@
/**
* Site-wide backoff for rate limiting. A 429 means "too fast", not "go
* away": the whole crawl pauses for the server's Retry-After (or a default),
* every launch after that is spaced out, and the URL is retried a bounded
* number of times before it is recorded as blocked.
*/
export interface CrawlThrottle {
/** No fetch launches before this time (ms epoch). */
pausedUntil: number;
/** Minimum spacing between launches; 0 until the site rate-limits us. */
launchGapMs: number;
}
/** Retries per URL within one chunk before a 429 is recorded as blocked. */
export const MAX_RATE_LIMIT_RETRIES = 3;
const DEFAULT_PAUSE_MS = 2_000;
const MIN_PAUSE_MS = 1_000;
/** Longer Retry-After values are cut short: the chunk has a 90s deadline. */
const MAX_PAUSE_MS = 20_000;
const MIN_LAUNCH_GAP_MS = 250;
const MAX_LAUNCH_GAP_MS = 4_000;
export const IDLE_THROTTLE: CrawlThrottle = { pausedUntil: 0, launchGapMs: 0 };
/** `Retry-After` is either delay-seconds or an HTTP-date. */
export function parseRetryAfterMs(header: string | null): number | undefined {
if (!header) return undefined;
const value = header.trim();
if (/^\d+$/.test(value)) return Number(value) * 1000;
const at = Date.parse(value);
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
}
/**
* Register a 429. The concurrent fetches of one window tend to fail
* together, so a burst arriving while already paused counts as one signal:
* it can extend the pause but does not compound the launch gap.
*/
export function backOff(
throttle: CrawlThrottle,
retryAfterMs: number | undefined,
now = Date.now(),
): CrawlThrottle {
const pause = Math.min(
Math.max(retryAfterMs ?? DEFAULT_PAUSE_MS, MIN_PAUSE_MS),
MAX_PAUSE_MS,
);
const alreadyPaused = now < throttle.pausedUntil;
return {
pausedUntil: Math.max(throttle.pausedUntil, now + pause),
launchGapMs: alreadyPaused
? throttle.launchGapMs
: Math.min(
Math.max(throttle.launchGapMs * 2, MIN_LAUNCH_GAP_MS),
MAX_LAUNCH_GAP_MS,
),
};
}

View File

@ -146,6 +146,11 @@ export interface CrawledPageResult {
* response time is measured at headers and says nothing about body size. * response time is measured at headers and says nothing about body size.
*/ */
htmlBytes: number; htmlBytes: number;
/**
* Server-requested wait before retrying, from a 429's Retry-After header.
* Only set on 429 responses. Transient drives the crawl throttle.
*/
retryAfterMs?: number;
imagesTotal: number; imagesTotal: number;
imagesMissingAlt: number; imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>; images: Array<{ src: string | null; alt: string | null }>;

View File

@ -4,6 +4,7 @@ import type {
} from "@/server/lib/audit/types"; } from "@/server/lib/audit/types";
import { sha256Hex } from "@/server/lib/audit/ids"; import { sha256Hex } from "@/server/lib/audit/ids";
import { normalizeUrl } from "@/server/lib/audit/url-utils"; import { normalizeUrl } from "@/server/lib/audit/url-utils";
import { parseRetryAfterMs } from "@/server/lib/audit/crawl-throttle";
const CRAWL_USER_AGENT = "OpenSEO-Audit/1.0"; const CRAWL_USER_AGENT = "OpenSEO-Audit/1.0";
const MAX_HTML_BYTES = 1024 * 1024; const MAX_HTML_BYTES = 1024 * 1024;
@ -127,6 +128,10 @@ export async function crawlPage(
// The body was still fetched and buffered; report its size so the // The body was still fetched and buffered; report its size so the
// crawl window's byte budget sees blocked/error pages too. // crawl window's byte budget sees blocked/error pages too.
htmlBytes: body.length, htmlBytes: body.length,
retryAfterMs:
statusCode === 429
? parseRetryAfterMs(response.headers.get("retry-after"))
: undefined,
}); });
} }
@ -248,6 +253,7 @@ function emptyPageResult(input: {
crawlDepth: number | null; crawlDepth: number | null;
inSitemap: boolean; inSitemap: boolean;
htmlBytes?: number; htmlBytes?: number;
retryAfterMs?: number;
}): CrawledPageResult { }): CrawledPageResult {
return { return {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -275,6 +281,7 @@ function emptyPageResult(input: {
contentHash: null, contentHash: null,
isHtml: false, isHtml: false,
htmlBytes: input.htmlBytes ?? 0, htmlBytes: input.htmlBytes ?? 0,
retryAfterMs: input.retryAfterMs,
imagesTotal: 0, imagesTotal: 0,
imagesMissingAlt: 0, imagesMissingAlt: 0,
images: [], images: [],

View File

@ -19,6 +19,12 @@ import {
CRAWL_WINDOW, CRAWL_WINDOW,
RETRY_CRAWL_WINDOW, RETRY_CRAWL_WINDOW,
} from "@/server/lib/audit/crawl-window"; } from "@/server/lib/audit/crawl-window";
import {
backOff,
IDLE_THROTTLE,
MAX_RATE_LIMIT_RETRIES,
type CrawlThrottle,
} from "@/server/lib/audit/crawl-throttle";
import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers"; import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
import { pgStep } from "@/server/workflows/pgStep"; import { pgStep } from "@/server/workflows/pgStep";
import { CRAWL_CHUNK_STEP } from "@/server/workflows/auditStepConfigs"; import { CRAWL_CHUNK_STEP } from "@/server/workflows/auditStepConfigs";
@ -106,6 +112,9 @@ export async function runCrawlPhase(
// the site's page weight the hard way — on heavy-page sites that meant an // the site's page weight the hard way — on heavy-page sites that meant an
// exceededMemory death every ~200 pages. // exceededMemory death every ~200 pages.
let windowHint = CRAWL_WINDOW.initial; let windowHint = CRAWL_WINDOW.initial;
// Same for the launch gap a rate-limiting site taught us: relearning it
// per chunk would cost a fresh round of 429s every 200 pages.
let launchGapHint = IDLE_THROTTLE.launchGapMs;
while (pending > 0 && attemptedTotal < params.maxPages) { while (pending > 0 && attemptedTotal < params.maxPages) {
chunkNo += 1; chunkNo += 1;
@ -119,6 +128,7 @@ export async function runCrawlPhase(
chunkNo, chunkNo,
attemptedBefore: attemptedTotal, attemptedBefore: attemptedTotal,
startWindow: windowHint, startWindow: windowHint,
startLaunchGapMs: launchGapHint,
}), }),
); );
// Apply the chunk's counters even when it did no new work (a retried // Apply the chunk's counters even when it did no new work (a retried
@ -129,6 +139,7 @@ export async function runCrawlPhase(
// `?? initial`: an instance in flight across a deploy replays cached // `?? initial`: an instance in flight across a deploy replays cached
// step results from before endWindow existed. // step results from before endWindow existed.
windowHint = result.endWindow ?? CRAWL_WINDOW.initial; windowHint = result.endWindow ?? CRAWL_WINDOW.initial;
launchGapHint = result.endLaunchGapMs ?? IDLE_THROTTLE.launchGapMs;
// One zero-attempt chunk is normal (retry of a completed chunk number); // One zero-attempt chunk is normal (retry of a completed chunk number);
// two in a row means the frontier is unservable — stop with what we // two in a row means the frontier is unservable — stop with what we
// have instead of spinning forever. // have instead of spinning forever.
@ -145,12 +156,14 @@ async function runCrawlChunk(
chunkNo: number; chunkNo: number;
attemptedBefore: number; attemptedBefore: number;
startWindow: number; startWindow: number;
startLaunchGapMs: number;
}, },
): Promise<{ ): Promise<{
attemptedInChunk: number; attemptedInChunk: number;
attempted: number; attempted: number;
pending: number; pending: number;
endWindow: number; endWindow: number;
endLaunchGapMs: number;
}> { }> {
const { auditId, workflowInstanceId, origin, maxPages, robots, chunkNo } = const { auditId, workflowInstanceId, origin, maxPages, robots, chunkNo } =
input; input;
@ -171,6 +184,7 @@ async function runCrawlChunk(
attempted: stats.attempted, attempted: stats.attempted,
pending: stats.pending, pending: stats.pending,
endWindow: input.startWindow, endWindow: input.startWindow,
endLaunchGapMs: input.startLaunchGapMs,
}; };
} }
@ -187,6 +201,15 @@ async function runCrawlChunk(
let nextIndex = 0; let nextIndex = 0;
let attemptedInChunk = 0; let attemptedInChunk = 0;
const inFlight = new Set<Promise<void>>(); const inFlight = new Set<Promise<void>>();
// 429s are the site saying "slower", not "blocked": the URL goes back on
// the queue, the whole window pauses, and launches are spaced out.
let throttle: CrawlThrottle = {
...IDLE_THROTTLE,
launchGapMs: input.startLaunchGapMs,
};
let nextLaunchAt = 0;
const retryQueue: ClaimedUrl[] = [];
const retriesByUrl = new Map<string, number>();
let persistThreshold = FIRST_PERSIST_BATCH_SIZE; let persistThreshold = FIRST_PERSIST_BATCH_SIZE;
let batch: CrawledPageResult[] = []; let batch: CrawledPageResult[] = [];
// Persistence runs concurrently with fetching (pipelined) but sequentially // Persistence runs concurrently with fetching (pipelined) but sequentially
@ -222,6 +245,15 @@ async function runCrawlChunk(
const launch = (entry: ClaimedUrl) => { const launch = (entry: ClaimedUrl) => {
const promise = crawlPage(entry.url, entry.depth, entry.inSitemap) const promise = crawlPage(entry.url, entry.depth, entry.inSitemap)
.then((page) => { .then((page) => {
if (page.statusCode === 429) {
throttle = backOff(throttle, page.retryAfterMs);
const retries = retriesByUrl.get(entry.url) ?? 0;
if (retries < MAX_RATE_LIMIT_RETRIES) {
retriesByUrl.set(entry.url, retries + 1);
retryQueue.push(entry);
return;
}
}
attemptedInChunk += 1; attemptedInChunk += 1;
batch.push(page); batch.push(page);
if (batch.length >= persistThreshold) flush(); if (batch.length >= persistThreshold) flush();
@ -232,18 +264,26 @@ async function runCrawlChunk(
inFlight.add(promise); inFlight.add(promise);
}; };
const hasNext = () => retryQueue.length > 0 || nextIndex < claimed.length;
while (true) { while (true) {
while ( const now = Date.now();
inFlight.size < windowSize && const canLaunch =
nextIndex < claimed.length && hasNext() &&
Date.now() < deadlineAt now < deadlineAt &&
) { queuedPersists <= MAX_QUEUED_PERSIST_BATCHES;
// queuedPersists changes when persistChain settles. Keep it out of the if (canLaunch && inFlight.size < windowSize) {
// loop condition because the type-aware linter cannot see that async const waitMs = Math.min(
// mutation and flags the otherwise valid backpressure check. Math.max(throttle.pausedUntil, nextLaunchAt) - now,
if (queuedPersists > MAX_QUEUED_PERSIST_BATCHES) break; deadlineAt - now,
launch(claimed[nextIndex]); );
nextIndex += 1; if (waitMs > 0) {
await sleep(waitMs);
continue;
}
launch(retryQueue.shift() ?? claimed[nextIndex++]);
nextLaunchAt = Date.now() + throttle.launchGapMs;
continue;
} }
if (inFlight.size > 0) { if (inFlight.size > 0) {
await Promise.race(inFlight); await Promise.race(inFlight);
@ -254,8 +294,8 @@ async function runCrawlChunk(
// chunk is done (leases exhausted or soft deadline hit). // chunk is done (leases exhausted or soft deadline hit).
if ( if (
queuedPersists > MAX_QUEUED_PERSIST_BATCHES && queuedPersists > MAX_QUEUED_PERSIST_BATCHES &&
nextIndex < claimed.length && hasNext() &&
Date.now() < deadlineAt now < deadlineAt
) { ) {
await persistChain; await persistChain;
continue; continue;
@ -265,8 +305,12 @@ async function runCrawlChunk(
flush(); flush();
await persistChain; await persistChain;
// Leases we never launched (soft deadline) go back to the queue. // Leases we never launched or finished retrying (soft deadline) go back
const unattempted = claimed.slice(nextIndex).map((entry) => entry.url); // to the queue; the next chunk retries them with a fresh budget.
const unattempted = [
...retryQueue.map((entry) => entry.url),
...claimed.slice(nextIndex).map((entry) => entry.url),
];
if (unattempted.length > 0) { if (unattempted.length > 0) {
await scratchpad.releaseUrls(unattempted); await scratchpad.releaseUrls(unattempted);
} }
@ -280,9 +324,14 @@ async function runCrawlChunk(
attempted: stats.attempted, attempted: stats.attempted,
pending: stats.pending, pending: stats.pending,
endWindow: windowSize, endWindow: windowSize,
endLaunchGapMs: throttle.launchGapMs,
}; };
} }
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function persistCrawledPages(input: { async function persistCrawledPages(input: {
auditId: string; auditId: string;
workflowInstanceId: string; workflowInstanceId: string;

View File

@ -20,7 +20,7 @@ export const AUDIT_ISSUE_TYPES = {
severity: "critical", severity: "critical",
title: "Crawler was blocked", title: "Crawler was blocked",
explanation: explanation:
"The site returned a bot challenge or access denial (e.g. a Cloudflare challenge, 403, or 429) instead of the page. We report this honestly rather than pretending the page is broken — but it means this page could not be audited, and other crawlers like search engines may face similar friction.", "The site returned a bot challenge or access denial (e.g. a Cloudflare challenge, 403, or a 429 that persisted after we slowed down and retried) instead of the page. We report this honestly rather than pretending the page is broken — but it means this page could not be audited, and other crawlers like search engines may face similar friction.",
howToFix: howToFix:
'If you own this site, allowlist the "OpenSEO-Audit" user agent in your WAF/bot-protection settings (on Cloudflare: a WAF custom rule that skips bot protection when the user agent contains "OpenSEO-Audit"; on some free tiers you may need to relax bot protection). Then re-run the audit.', 'If you own this site, allowlist the "OpenSEO-Audit" user agent in your WAF/bot-protection settings (on Cloudflare: a WAF custom rule that skips bot protection when the user agent contains "OpenSEO-Audit"; on some free tiers you may need to relax bot protection). Then re-run the audit.',
}, },