From 9c837e475097920833f7dc6408c71f396b1ad125 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 3 Aug 2026 14:21:34 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugin):=20two-lane=20retry=20for=20ne?= =?UTF-8?q?ver-suppress=20failures=20=E2=80=94=20lease-fast=20for=20blips,?= =?UTF-8?q?=20cadence=20+=20extended=20page=20expiry=20for=20persistent=20?= =?UTF-8?q?ones;=20v0.24.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package-lock.json | 2 +- packages/plugin/package.json | 2 +- packages/plugin/src/config.js | 16 +++- packages/plugin/src/resources/RenderQueue.js | 73 +++++++++++++++---- .../plugin/test/suppressionStatus.test.js | 54 +++++++++++--- 5 files changed, 118 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6edfb4a8..421f4b73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9007,7 +9007,7 @@ }, "packages/plugin": { "name": "@harperfast/prerender", - "version": "0.23.0", + "version": "0.24.0", "license": "Apache-2.0", "dependencies": { "fast-xml-parser": "^5.0.9", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 28c55620..527cde29 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/prerender", - "version": "0.23.0", + "version": "0.24.0", "type": "module", "description": "Configurable Harper plugin for prerendering pages for bots and crawlers", "license": "Apache-2.0", diff --git a/packages/plugin/src/config.js b/packages/plugin/src/config.js index a6b1e189..8bb7caba 100644 --- a/packages/plugin/src/config.js +++ b/packages/plugin/src/config.js @@ -273,7 +273,7 @@ const defaultConfig = () => ({ // verdict, and striking on it would mass-delete healthy targets during an outage. // - 408/429/5xx never suppress either (see RenderQueue): the origin failed to serve // the page, it didn't disavow it — the target and its cached page both survive and - // the render just retries at its normal cadence. + // the render retries under `failureRetry` below. suppression: { recheckInterval: 7 * DAY, maxStrikes: 4, @@ -283,6 +283,20 @@ const defaultConfig = () => ({ }, }, + // Retry shape for the HTTP failures that never suppress (401/403 auth-shaped, + // 408/429/5xx transient). The first `fastRetries` consecutive failures leave the + // job's claim lease in place, so the retry comes on lease expiry + // (`queue.jobLeaseTime`, minutes) — an origin blip recovers fast, and the cached + // page's stale-while-revalidate window covers bots throughout. From the next strike + // on, the retry drops to the target's normal cadence — a persistently failing page + // must not hot-loop 100+ renders a day — and the kept page's `expiresAt` is pushed + // out to that retry, so bots keep getting the last good render instead of falling + // through to a failing origin. Strikes are the target's one shared counter; any + // successful render clears it. + failureRetry: { + fastRetries: 2, + }, + // A redirect that proves nothing permanent (302/303/307, a client-side redirect's // 200, or any redirect onto a route class we don't serve) keeps the source target on // the theory the page is coming back. A source that answers that way EVERY interval diff --git a/packages/plugin/src/resources/RenderQueue.js b/packages/plugin/src/resources/RenderQueue.js index 917594e6..d8869826 100644 --- a/packages/plugin/src/resources/RenderQueue.js +++ b/packages/plugin/src/resources/RenderQueue.js @@ -306,25 +306,22 @@ export class RenderQueue extends Resource { // // EXCEPT 401/403: an auth-shaped error is almost never a statement about the page — // it's a broken renderer credential, an origin bot-mitigation rule change, or an - // origin auth outage. Striking on it would suppress (and after maxStrikes DELETE) - // swathes of healthy targets exactly when such a failure hits everything at once. - // Keep the target, keep its cached page, reschedule at normal cadence, log loudly. + // origin auth outage. Striking toward deletion would suppress (and after maxStrikes + // DELETE) swathes of healthy targets exactly when such a failure hits everything at + // once. Keep the target, keep its cached page, retry via retryAfterFailure. if (result.statusCode === 401 || result.statusCode === 403) { logger.error( `Prerender got ${result.statusCode} for ${cacheKey} — auth-shaped, NOT suppressing. ` + `If these are widespread, check the renderer's origin-bypass credential and the CDN/origin access rules.` ); - await this.rescheduleAtTargetCadence(cacheKey); + await this.retryAfterFailure(cacheKey); } else if (result.statusCode === 408 || result.statusCode === 429 || result.statusCode >= 500) { // Transient-shaped: the origin failed to serve the page, it didn't disavow it. - // Suppressing would delete the last good cached page and count a strike toward - // deletion over what may be one bad minute at the origin — keep both, retry at - // the target's normal cadence, and let a persistent failure surface as noise - // here rather than as silently vanished targets. - logger.warn( - `Prerender got transient ${result.statusCode} for ${cacheKey} — keeping target and cached page, retrying at normal cadence` - ); - await this.rescheduleAtTargetCadence(cacheKey); + // Suppressing would delete the last good cached page and park the URL for the + // recheck interval over what may be one bad minute at the origin — keep both + // and retry via retryAfterFailure (fast first, then the target's cadence). + logger.warn(`Prerender got transient ${result.statusCode} for ${cacheKey} — keeping target and cached page`); + await this.retryAfterFailure(cacheKey); } else { logger.warn(`Suppressing prerendered url: ${cacheKey}${result.reason ? ` (${result.reason})` : ''}`); await Target.suppress(CacheKey.extractUrl(cacheKey), { @@ -378,9 +375,9 @@ export class RenderQueue extends Resource { if (authShaped || transientShaped) { logger[authShaped ? 'error' : 'warn']( `Prerendered url ${result.id} redirected to ${result.redirectedTo}, which returned ${result.statusCode} — ` + - `${authShaped ? 'auth-shaped' : 'transient'}, keeping the target and retrying at its normal cadence` + `${authShaped ? 'auth-shaped' : 'transient'}, keeping the target` ); - await this.rescheduleAtTargetCadence(result.id); + await this.retryAfterFailure(result.id); return; } @@ -503,6 +500,54 @@ export class RenderQueue extends Resource { await this.rescheduleAtTargetCadence(cacheKey, renderTarget); } + /** + * Retry shape for auth-shaped (401/403) and transient (408/429/5xx) failures — the ones + * that never suppress. Two lanes, split by the target's strike count + * (`render.failureRetry.fastRetries`): + * + * FAST — the schedule is left holding its claim lease, so the retry comes on lease + * expiry (`queue.jobLeaseTime`, minutes). An origin blip recovers fast, and the cached + * page's swrTtl window keeps serving bots across a lease-sized wait. + * + * SLOW — after `fastRetries` consecutive failures this is not a blip: drop to the + * target's normal cadence so a persistently failing page can't hot-loop renders all + * day, and push the kept page's `expiresAt` out to that retry so bots keep getting the + * last good render instead of falling through to a failing origin (a cadence-sized + * wait is far beyond swrTtl). + * + * Strikes are the target's one shared counter (suppression and redirect strikes use it + * too); any successful render clears it. A targetless key (render-now one-off) has its + * schedule dropped, as everywhere else. + */ + static async retryAfterFailure(cacheKey) { + const sourceUrl = CacheKey.extractUrl(cacheKey); + const renderTarget = await Target.get({ id: sourceUrl, select: ['strikes', 'renderInterval', 'sitemapUrl'] }); + if (!renderTarget) { + await RenderSchedule.delete(cacheKey); + return; + } + const strikes = (Number.isFinite(renderTarget.strikes) ? Number(renderTarget.strikes) : 0) + 1; + await Target.patch(sourceUrl, { strikes }); + + if (strikes <= config.render.failureRetry.fastRetries) { + logger.warn(`Retrying ${cacheKey} on its claim lease (failure strike ${strikes})`); + return; // schedule untouched — the lease written at claim drives the retry + } + + const interval = resolveRenderInterval(sourceUrl, renderTarget.renderInterval); + const nextRenderTime = currentMinuteMs() + interval; + logger.warn(`Retrying ${cacheKey} at its normal cadence (failure strike ${strikes}) — extending the cached page`); + await RenderSchedule.put(cacheKey, { nextRenderTime, fromSitemap: !!renderTarget.sitemapUrl }); + // Guarded by a point read: patch on a missing row would materialize a content-less + // page record that the serving path could then try to serve. ARRAY select on purpose — + // it builds a record (truthy for any existing row); a string select returns the bare + // scalar, which is exactly the projection trap that has bitten twice before. + const page = await databases.page_cache.PrerenderedPage.get({ id: cacheKey, select: ['cacheKey'] }); + if (page) { + await databases.page_cache.PrerenderedPage.patch(cacheKey, { expiresAt: nextRenderTime }); + } + } + /** * Keep a redirecting source in its rotation. Mirrors the post-render scheduling in * processJobResult: a target-backed key comes due one interval from completion (so cadence diff --git a/packages/plugin/test/suppressionStatus.test.js b/packages/plugin/test/suppressionStatus.test.js index 8b64b6c6..02b4eb53 100644 --- a/packages/plugin/test/suppressionStatus.test.js +++ b/packages/plugin/test/suppressionStatus.test.js @@ -207,20 +207,19 @@ test('a 404 verdict does NOT delete at gone.maxStrikes when the strikes came und // ---- auth-shaped (401/403) ---- -test('403 keeps the target, keeps the cached pages, reschedules at normal cadence', async () => { +test('403 keeps the target and cached pages; first failure retries on the claim lease', async () => { seedSource({ renderInterval: 3_600_000 }); await postResult(nonIndexable(403)); const target = stores.target.get(A); assert.ok(target, 'target must survive an auth-shaped result'); assert.notEqual(target.state, 'suppressed'); - assert.ok(!target.strikes, 'no strike for an auth-shaped result'); + assert.equal(target.strikes, 1, 'the failure is counted'); - const schedule = stores.renderSchedule.get(key(A)); - assert.ok(schedule.nextRenderTime > Date.now(), 'rescheduled in the future'); - assert.ok( - schedule.nextRenderTime < Date.now() + 2 * 3_600_000, - 'rescheduled at the target cadence, not a suppression recheck' + assert.equal( + stores.renderSchedule.get(key(A)).nextRenderTime, + 1, + 'schedule untouched — the claim lease drives the fast retry' ); assert.equal(stores.prerenderedPage.get(key(A)).content, 'old html', 'last good page keeps serving'); assert.ok( @@ -245,21 +244,52 @@ test('403 on a targetless one-off drops the schedule instead of retrying forever // ---- transient (408/429/5xx) ---- for (const statusCode of [408, 429, 500, 503]) { - test(`${statusCode} keeps the target and cached pages and retries at normal cadence`, async () => { + test(`${statusCode} keeps the target and cached pages; first failure retries on the claim lease`, async () => { seedSource(); await postResult(nonIndexable(statusCode)); const target = stores.target.get(A); assert.ok(target, 'target must survive a transient failure'); assert.notEqual(target.state, 'suppressed'); - assert.ok(!target.strikes, 'no strike for a transient failure'); + assert.equal(target.strikes, 1, 'the failure is counted'); assert.equal(stores.prerenderedPage.get(key(A)).content, 'old html', 'last good page keeps serving'); - const schedule = stores.renderSchedule.get(key(A)); - assert.ok(schedule.nextRenderTime > Date.now(), 'rescheduled in the future'); + assert.equal(stores.renderSchedule.get(key(A)).nextRenderTime, 1, 'schedule untouched — lease-driven retry'); }); } +test('past fastRetries, a failure drops to the target cadence and extends the cached page', async () => { + const fast = config.render.failureRetry.fastRetries; + seedSource({ renderInterval: 3_600_000 }); + stores.target.get(A).strikes = fast; // this failure is strike fast+1 — first slow-lane one + + await postResult(nonIndexable(503)); + + const target = stores.target.get(A); + assert.equal(target.strikes, fast + 1); + assert.notEqual(target.state, 'suppressed'); + + const schedule = stores.renderSchedule.get(key(A)); + assert.ok(schedule.nextRenderTime > Date.now(), 'slow lane: rescheduled at the target cadence'); + assert.ok(schedule.nextRenderTime < Date.now() + 2 * 3_600_000, 'cadence, not a suppression recheck'); + + const page = stores.prerenderedPage.get(key(A)); + assert.equal(page.content, 'old html', 'page survives'); + assert.equal(page.expiresAt, schedule.nextRenderTime, 'expiresAt extended to the retry, so bots never fall through'); +}); + +test('the slow lane does not materialize a page row where none exists', async () => { + const fast = config.render.failureRetry.fastRetries; + seedSource(); + stores.target.get(A).strikes = fast; + stores.prerenderedPage.delete(key(A)); // desktop page missing (mobile still cached) + + await postResult(nonIndexable(500)); + + assert.equal(stores.prerenderedPage.has(key(A)), false, 'no content-less page record created'); + assert.ok(stores.renderSchedule.get(key(A)).nextRenderTime > Date.now(), 'still rescheduled at cadence'); +}); + // ---- content verdicts stay on the default knobs ---- test('noindex (status 200) still suppresses under the default knobs with its own reason', async () => { @@ -299,7 +329,7 @@ test('a client-side redirect landing on a 403 page keeps the source and seeds no const source = stores.target.get(A); assert.ok(source, 'auth-shaped landing must NOT retire the source — a credential outage would mass-delete'); assert.notEqual(source.state, 'suppressed'); - assert.ok(!source.strikes, 'no strike either'); + assert.equal(source.strikes, 1, 'the failure is counted toward the retry lanes'); assert.equal(stores.prerenderedPage.get(key(A)).content, 'old html', 'last good page keeps serving'); assert.equal(stores.target.has(B), false, 'auth-shaped destination must not become a suppressed row'); }); From 4b767aa0ba5b2d12ed1649eca865b2e17044e6d7 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 3 Aug 2026 14:26:05 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(plugin):=20coerce=20strikes=20before=20?= =?UTF-8?q?the=20finite=20check=20(BigInt=20surfacing,=20review)=20?= =?UTF-8?q?=E2=80=94=20shared=20countedStrikes=20helper=20across=20suppres?= =?UTF-8?q?s/redirect/failure=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/plugin/src/resources/RenderQueue.js | 6 +++--- packages/plugin/src/resources/Target.js | 13 ++++++++++++- packages/plugin/test/suppressionStatus.test.js | 11 +++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/plugin/src/resources/RenderQueue.js b/packages/plugin/src/resources/RenderQueue.js index d8869826..6ea85336 100644 --- a/packages/plugin/src/resources/RenderQueue.js +++ b/packages/plugin/src/resources/RenderQueue.js @@ -6,7 +6,7 @@ import { CacheKey } from '../util/cacheKey.js'; import { canonicalizeUrl } from '../util/url.js'; import { classifyPath, queryAllowlistFor, resolveRenderInterval, PRERENDER } from '../util/routeClass.js'; import { recordUnroutedPath } from '../util/unrouted.js'; -import { Target } from './Target.js'; +import { Target, countedStrikes } from './Target.js'; import { getDesiredPause, setDesiredPause } from '../util/queueControl.js'; const protocol = server.hostname === 'localhost' ? 'http' : 'https'; @@ -486,7 +486,7 @@ export class RenderQueue extends Resource { await RenderSchedule.delete(cacheKey); return; } - const strikes = (Number.isFinite(renderTarget.strikes) ? Number(renderTarget.strikes) : 0) + 1; + const strikes = countedStrikes(renderTarget.strikes) + 1; const maxStrikes = config.render.redirects.maxStrikes; if (Number.isFinite(maxStrikes) && maxStrikes > 0 && strikes >= maxStrikes) { logger.warn( @@ -526,7 +526,7 @@ export class RenderQueue extends Resource { await RenderSchedule.delete(cacheKey); return; } - const strikes = (Number.isFinite(renderTarget.strikes) ? Number(renderTarget.strikes) : 0) + 1; + const strikes = countedStrikes(renderTarget.strikes) + 1; await Target.patch(sourceUrl, { strikes }); if (strikes <= config.render.failureRetry.fastRetries) { diff --git a/packages/plugin/src/resources/Target.js b/packages/plugin/src/resources/Target.js index 69f1e86b..a7e94c31 100644 --- a/packages/plugin/src/resources/Target.js +++ b/packages/plugin/src/resources/Target.js @@ -15,6 +15,17 @@ const { // jittered schedules — which is exactly what writing the suppression row must not do. const TargetTable = databases.render_service.Target; +/** + * A target's stored strike count, read defensively: coerce BEFORE the finite check, because + * Harper numeric columns can surface as BigInt (which `Number.isFinite` rejects outright — + * the same trap `resolveRenderInterval` guards for renderInterval), and `Number(null)` is 0, + * so an absent count correctly reads as zero rather than NaN. + */ +export const countedStrikes = (value) => { + const count = Number(value); + return Number.isFinite(count) && count > 0 ? count : 0; +}; + /** * The URL registry — ONE row per URL (see schema.graphql). Device variants are not stored: * the devices a URL renders for are `config.deviceTypes.default` at write time, and `put` @@ -146,7 +157,7 @@ export class Target extends TargetTable { id: url, select: ['strikes', 'renderInterval', 'sitemapUrl', 'schedulerNode'], }); - const strikes = (Number.isFinite(existing?.strikes) ? Number(existing.strikes) : 0) + 1; + const strikes = countedStrikes(existing?.strikes) + 1; const maxStrikes = knobs.maxStrikes; if (existing && Number.isFinite(maxStrikes) && maxStrikes > 0 && strikes >= maxStrikes) { diff --git a/packages/plugin/test/suppressionStatus.test.js b/packages/plugin/test/suppressionStatus.test.js index 02b4eb53..fdf9c851 100644 --- a/packages/plugin/test/suppressionStatus.test.js +++ b/packages/plugin/test/suppressionStatus.test.js @@ -365,3 +365,14 @@ test('a client-side redirect landing on a 404 page retires the source and seeds assert.equal(stores.target.has(A), false, 'source retired — it leads to a page that is gone'); assert.equal(stores.target.get(B)?.suppressedReason, 'http-gone', 'destination suppressed under the gone class'); }); + +test('a BigInt strikes value (Harper numeric surfacing) still counts toward the slow lane', async () => { + const fast = config.render.failureRetry.fastRetries; + seedSource({ renderInterval: 3_600_000 }); + stores.target.get(A).strikes = BigInt(fast); // Number.isFinite(BigInt) is false — must coerce first + + await postResult(nonIndexable(503)); + + assert.equal(stores.target.get(A).strikes, fast + 1, 'BigInt count read correctly, not reset to 1'); + assert.ok(stores.renderSchedule.get(key(A)).nextRenderTime > Date.now(), 'transitioned to the slow lane'); +}); From cf2f87843c9dfb5c08860593eaa50bda63ce48fc Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 3 Aug 2026 14:33:19 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(plugin):=20drop=20the=20slow-lane=20exp?= =?UTF-8?q?iresAt=20extension=20=E2=80=94=20swrTtl=20is=20the=20staleness?= =?UTF-8?q?=20bound;=20past=20it,=20origin=20fallthrough=20is=20the=20hone?= =?UTF-8?q?st=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- packages/plugin/src/config.js | 12 ++++++----- packages/plugin/src/resources/RenderQueue.js | 18 ++++++---------- .../plugin/test/suppressionStatus.test.js | 21 +++++++------------ 3 files changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/plugin/src/config.js b/packages/plugin/src/config.js index 8bb7caba..040d6b00 100644 --- a/packages/plugin/src/config.js +++ b/packages/plugin/src/config.js @@ -288,11 +288,13 @@ const defaultConfig = () => ({ // job's claim lease in place, so the retry comes on lease expiry // (`queue.jobLeaseTime`, minutes) — an origin blip recovers fast, and the cached // page's stale-while-revalidate window covers bots throughout. From the next strike - // on, the retry drops to the target's normal cadence — a persistently failing page - // must not hot-loop 100+ renders a day — and the kept page's `expiresAt` is pushed - // out to that retry, so bots keep getting the last good render instead of falling - // through to a failing origin. Strikes are the target's one shared counter; any - // successful render clears it. + // on, the retry drops to the target's normal cadence: a persistently failing page + // must not hot-loop 100+ renders a day. Past `page.swrTtl` the kept page stops + // serving and bots fall through to the origin on purpose — its answer (a live page + // for auth-shaped failures, an honest 5xx for transient ones) is the truth, and + // serving arbitrarily old snapshots while users get errors would break bot/user + // parity. Strikes are the target's one shared counter; any successful render + // clears it. failureRetry: { fastRetries: 2, }, diff --git a/packages/plugin/src/resources/RenderQueue.js b/packages/plugin/src/resources/RenderQueue.js index 6ea85336..25756458 100644 --- a/packages/plugin/src/resources/RenderQueue.js +++ b/packages/plugin/src/resources/RenderQueue.js @@ -511,9 +511,11 @@ export class RenderQueue extends Resource { * * SLOW — after `fastRetries` consecutive failures this is not a blip: drop to the * target's normal cadence so a persistently failing page can't hot-loop renders all - * day, and push the kept page's `expiresAt` out to that retry so bots keep getting the - * last good render instead of falling through to a failing origin (a cadence-sized - * wait is far beyond swrTtl). + * day. The kept page's expiry is deliberately NOT extended: `swrTtl` is the product + * bound on how stale we serve as if fresh, and past it bots fall through to the + * origin — whose answer (a live page for auth-shaped failures, an honest 5xx for + * transient ones) is the truth. Serving arbitrarily old snapshots while users get + * errors would break bot/user parity. * * Strikes are the target's one shared counter (suppression and redirect strikes use it * too); any successful render clears it. A targetless key (render-now one-off) has its @@ -536,16 +538,8 @@ export class RenderQueue extends Resource { const interval = resolveRenderInterval(sourceUrl, renderTarget.renderInterval); const nextRenderTime = currentMinuteMs() + interval; - logger.warn(`Retrying ${cacheKey} at its normal cadence (failure strike ${strikes}) — extending the cached page`); + logger.warn(`Retrying ${cacheKey} at its normal cadence (failure strike ${strikes})`); await RenderSchedule.put(cacheKey, { nextRenderTime, fromSitemap: !!renderTarget.sitemapUrl }); - // Guarded by a point read: patch on a missing row would materialize a content-less - // page record that the serving path could then try to serve. ARRAY select on purpose — - // it builds a record (truthy for any existing row); a string select returns the bare - // scalar, which is exactly the projection trap that has bitten twice before. - const page = await databases.page_cache.PrerenderedPage.get({ id: cacheKey, select: ['cacheKey'] }); - if (page) { - await databases.page_cache.PrerenderedPage.patch(cacheKey, { expiresAt: nextRenderTime }); - } } /** diff --git a/packages/plugin/test/suppressionStatus.test.js b/packages/plugin/test/suppressionStatus.test.js index fdf9c851..8f915cbd 100644 --- a/packages/plugin/test/suppressionStatus.test.js +++ b/packages/plugin/test/suppressionStatus.test.js @@ -258,10 +258,11 @@ for (const statusCode of [408, 429, 500, 503]) { }); } -test('past fastRetries, a failure drops to the target cadence and extends the cached page', async () => { +test('past fastRetries, a failure drops to the target cadence — page kept but its expiry untouched', async () => { const fast = config.render.failureRetry.fastRetries; seedSource({ renderInterval: 3_600_000 }); stores.target.get(A).strikes = fast; // this failure is strike fast+1 — first slow-lane one + stores.prerenderedPage.get(key(A)).expiresAt = 777; // sentinel: must not be rewritten await postResult(nonIndexable(503)); @@ -275,19 +276,11 @@ test('past fastRetries, a failure drops to the target cadence and extends the ca const page = stores.prerenderedPage.get(key(A)); assert.equal(page.content, 'old html', 'page survives'); - assert.equal(page.expiresAt, schedule.nextRenderTime, 'expiresAt extended to the retry, so bots never fall through'); -}); - -test('the slow lane does not materialize a page row where none exists', async () => { - const fast = config.render.failureRetry.fastRetries; - seedSource(); - stores.target.get(A).strikes = fast; - stores.prerenderedPage.delete(key(A)); // desktop page missing (mobile still cached) - - await postResult(nonIndexable(500)); - - assert.equal(stores.prerenderedPage.has(key(A)), false, 'no content-less page record created'); - assert.ok(stores.renderSchedule.get(key(A)).nextRenderTime > Date.now(), 'still rescheduled at cadence'); + assert.equal( + page.expiresAt, + 777, + 'expiry deliberately NOT extended — swrTtl bounds staleness; past it, origin is the truth' + ); }); // ---- content verdicts stay on the default knobs ----