From e99b181874379804c79bfffbd6fff9d99f2d3971 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 19 May 2026 14:01:09 +0200 Subject: [PATCH] fix(queue): treat 200 + non-REVALIDATED response as success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DO queue handler previously threw FatalError when the HEAD self-fetch returned 200 with an x-nextjs-cache value other than REVALIDATED. This commonly happens under load when in-isolate stale-while-revalidate has already regenerated the page before the queued HEAD reaches the ISR handler — the page is fresh in cache but Next.js returns HIT/STALE rather than REVALIDATED. Fall through to the success path (sync table updated, failed state cleared) and log a debug message instead. --- ...ue-treat-already-revalidated-as-success.md | 7 ++++ .../src/api/durable-objects/queue.spec.ts | 32 +++++++++++-------- .../src/api/durable-objects/queue.ts | 20 ++++++------ 3 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 .changeset/queue-treat-already-revalidated-as-success.md diff --git a/.changeset/queue-treat-already-revalidated-as-success.md b/.changeset/queue-treat-already-revalidated-as-success.md new file mode 100644 index 000000000..a691e05a2 --- /dev/null +++ b/.changeset/queue-treat-already-revalidated-as-success.md @@ -0,0 +1,7 @@ +--- +"@opennextjs/cloudflare": patch +--- + +fix: treat 200 + non-REVALIDATED response as success in DO queue handler + +`DOQueueHandler.executeRevalidation` previously threw `FatalError` when the HEAD self-fetch returned 200 with `x-nextjs-cache` other than `REVALIDATED`. This commonly happens under load when in-isolate stale-while-revalidate has already regenerated the page before the queued HEAD reaches the ISR handler — the page is fresh in cache but Next.js returns `HIT`/`STALE` rather than `REVALIDATED`. The handler now logs a debug message and falls through to the success path (sync table updated, failed state cleared). Addresses the `FatalError: ... cannot be done. This error should never happen.` reports. diff --git a/packages/cloudflare/src/api/durable-objects/queue.spec.ts b/packages/cloudflare/src/api/durable-objects/queue.spec.ts index 8869b1642..8144ec67a 100644 --- a/packages/cloudflare/src/api/durable-objects/queue.spec.ts +++ b/packages/cloudflare/src/api/durable-objects/queue.spec.ts @@ -97,6 +97,25 @@ describe("DurableObjectQueue", () => { expect(queue.ongoingRevalidations.has("id")).toBe(true); }); + it("should treat 200 + non-REVALIDATED response as success (page already regenerated by another path)", async () => { + process.env.__NEXT_PREVIEW_MODE_ID = "test"; + const queue = createDurableObjectQueue({ + fetchDuration: 10, + statusCode: 200, + headers: new Headers([["x-nextjs-cache", "HIT"]]), + }); + await queue.revalidate(createMessage("id")); + + await queue.ongoingRevalidations.get("id"); + + expect(queue.routeInFailedState.size).toBe(0); + expect(queue.sql.exec).toHaveBeenCalledWith( + "INSERT OR REPLACE INTO sync (id, lastSuccess, buildId) VALUES (?, unixepoch(), ?)", + "test.local/test", + process.env.__OPEN_NEXT_BUILD_ID + ); + }); + it("should block concurrency", async () => { const queue = createDurableObjectQueue({ fetchDuration: 10 }); await queue.revalidate(createMessage("id")); @@ -121,19 +140,6 @@ describe("DurableObjectQueue", () => { }); describe("failed revalidation", () => { - it("should not put it in failed state for an incorrect 200", async () => { - const queue = createDurableObjectQueue({ - fetchDuration: 10, - statusCode: 200, - headers: new Headers([["x-nextjs-cache", "MISS"]]), - }); - await queue.revalidate(createMessage("id")); - - await queue.ongoingRevalidations.get("id"); - - expect(queue.routeInFailedState.size).toBe(0); - }); - it("should not put it in failed state for a failed revalidation with 404", async () => { const queue = createDurableObjectQueue({ fetchDuration: 10, diff --git a/packages/cloudflare/src/api/durable-objects/queue.ts b/packages/cloudflare/src/api/durable-objects/queue.ts index d5d92b3ac..55d1aeea3 100644 --- a/packages/cloudflare/src/api/durable-objects/queue.ts +++ b/packages/cloudflare/src/api/durable-objects/queue.ts @@ -1,11 +1,6 @@ import { debug, error, warn } from "@opennextjs/aws/adapters/logger.js"; import type { QueueMessage } from "@opennextjs/aws/types/overrides.js"; -import { - FatalError, - IgnorableError, - isOpenNextError, - RecoverableError, -} from "@opennextjs/aws/utils/error.js"; +import { IgnorableError, isOpenNextError, RecoverableError } from "@opennextjs/aws/utils/error.js"; import { DurableObject } from "cloudflare:workers"; const DEFAULT_MAX_REVALIDATION = 5; @@ -132,11 +127,14 @@ export class DOQueueHandler extends DurableObject { signal: AbortSignal.timeout(this.revalidationTimeout), }); // Now we need to handle errors from the fetch - if (response.status === 200 && response.headers.get("x-nextjs-cache") !== "REVALIDATED") { - this.routeInFailedState.delete(msg.MessageDeduplicationId); - throw new FatalError( - `The revalidation for ${host}${url} cannot be done. This error should never happen.` - ); + if (response.status === 200) { + const cacheStatus = response.headers.get("x-nextjs-cache"); + if (cacheStatus !== "REVALIDATED") { + // Page already regenerated by another path (e.g. in-isolate SWR). Treat as success. + debug( + `Revalidation for ${host}${url} observed x-nextjs-cache=${cacheStatus}; treating as success.` + ); + } } else if (response.status === 404) { // The page is not found, we should not revalidate it // We remove the route from the failed state because it might be expected (i.e. a route that was deleted)