// oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: every case here is a verbatim reproduction of a plain Error the Cloudflare runtime itself throws, and the test helper throws to abort a case whose premise did not hold import { describe, expect, it } from "@effect/vitest"; import { Cause } from "effect"; import { UNAVAILABLE_RETRY_AFTER_SECONDS } from "@executor-js/host-mcp"; import { classifyDurableObjectError, durableObjectFailureResponse } from "./durable-object-errors"; // Every string here is a real Cloudflare runtime message. They arrive as plain // `Error`s with no code, no type and no marker of any kind — the text IS the // contract, which is exactly why it deserves one place and a test. describe("classifyDurableObjectError", () => { it("reads a self-abort as a session that is gone for good", () => { // `destroy()` ends in `ctx.abort("destroyed")`; the abort reason arrives // verbatim as the error message. expect(classifyDurableObjectError(new Error("destroyed"))).toEqual({ kind: "destroyed", disposition: "session_dead", }); }); it("reads a deploy-time reset as transient", () => { expect( classifyDurableObjectError(new Error("Durable Object reset because its code was updated.")), ).toEqual({ kind: "code_update", disposition: "transient" }); }); it("reads a storage timeout reset as transient", () => { expect( classifyDurableObjectError( new Error( "Durable Object storage operation exceeded timeout which caused object to be reset.", ), ), ).toEqual({ kind: "storage_timeout", disposition: "transient" }); }); it("reads a storage backend fault as transient", () => { expect( classifyDurableObjectError( new Error("Internal error in Durable Object storage caused object to be reset."), ), ).toEqual({ kind: "storage_internal", disposition: "transient" }); }); it("reads a storage fault raised while the object starts up as transient", () => { expect( classifyDurableObjectError( new Error( "Internal error while starting up Durable Object storage caused object to be reset; reference = 0000aaaa1111bbbb", ), ), ).toEqual({ kind: "startup_internal_error", disposition: "transient" }); }); // This variant escaped as an unhandled 500 even with the bare-blip fragment // in place, because the runtime interposes its own description between // "internal error" and the reference id. Pinning the distinction keeps a // future "just widen the blip fragment" from silently re-merging the two. it("keeps the startup fault distinct from the bare platform blip", () => { const startup = classifyDurableObjectError( new Error( "Internal error while starting up Durable Object storage caused object to be reset; reference = ffff9999eeee8888", ), ); const blip = classifyDurableObjectError( new Error("internal error; reference = ffff9999eeee8888"), ); expect(startup?.kind).toBe("startup_internal_error"); expect(blip?.kind, "the described fault must not be read as the bare blip").toBe( "internal_error", ); }); it("reads a blockConcurrencyWhile cancellation as transient", () => { expect( classifyDurableObjectError( new Error( "A call to blockConcurrencyWhile() in a Durable Object waited for too long. The call was canceled and the Durable Object was reset.", ), ), ).toEqual({ kind: "concurrency_reset", disposition: "transient" }); }); it("reads a CPU-limit reset as transient", () => { expect( classifyDurableObjectError( new Error("Durable Object exceeded its CPU time limit and was reset."), ), ).toEqual({ kind: "cpu_limit", disposition: "transient" }); }); // The memory-limit reset is the CPU limit's sibling and is deliberately NOT // classified: the runtime names the application as the cause (un-awaited // writes, an oversized read), so a retry reproduces it. It has to keep being // rethrown and reported rather than disappearing into a 503. it("refuses to classify the sibling memory-limit reset as retryable", () => { expect( classifyDurableObjectError( new Error( "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.", ), ), ).toBeNull(); }); it("reads a platform blip as transient, ignoring the reference id", () => { // The reference id differs on every event; it must not defeat the match. expect( classifyDurableObjectError(new Error("internal error; reference = 0000aaaa1111bbbb")), ).toEqual({ kind: "internal_error", disposition: "transient" }); expect( classifyDurableObjectError(new Error("internal error; reference = ffff9999eeee8888")), ).toEqual({ kind: "internal_error", disposition: "transient" }); }); it("honours the runtime's own retryable flag when the message says nothing", () => { const error = Object.assign(new Error("Network connection lost."), { retryable: true }); expect(classifyDurableObjectError(error)).toEqual({ kind: "retryable", disposition: "transient", }); }); it("looks inside an Effect cause, because that is how the DO seam sees it", () => { const cause = Cause.die(new Error("Durable Object reset because its code was updated.")); expect(classifyDurableObjectError(cause)).toEqual({ kind: "code_update", disposition: "transient", }); }); it("unwraps a wrapped platform error", () => { const wrapped = new Error("session restore failed", { cause: new Error("Durable Object reset because its code was updated."), }); expect(classifyDurableObjectError(wrapped)).toEqual({ kind: "code_update", disposition: "transient", }); }); // The whole point of returning null is that unknown failures keep their // current behaviour: rethrown, reported, paged. Silently swallowing an // application bug as "transient" would be far worse than the noise this // module removes. it("refuses to classify anything it does not recognize", () => { expect(classifyDurableObjectError(new Error("Cannot read properties of undefined"))).toBeNull(); expect(classifyDurableObjectError(new TypeError("x is not a function"))).toBeNull(); expect(classifyDurableObjectError(undefined)).toBeNull(); expect(classifyDurableObjectError(null)).toBeNull(); expect(classifyDurableObjectError({})).toBeNull(); }); // A callback that throws also resets the object, so an application defect // raised inside `blockConcurrencyWhile` can carry the method's name. Only the // runtime's own cancellation message is a platform reset; the rest is a bug // and must keep being rethrown and reported. it("does not read an application defect inside blockConcurrencyWhile as a platform reset", () => { expect( classifyDurableObjectError( new Error("Error in blockConcurrencyWhile(): TypeError: x is not a function"), ), ).toBeNull(); }); // "destroyed" is the abort reason and nothing else. A message that merely // mentions the word describes a different failure and must not be allowed to // condemn a live session id. it("does not condemn a session for a message that merely mentions destruction", () => { expect( classifyDurableObjectError(new Error("the widget was destroyed by the user")), ).toBeNull(); }); }); // What a client actually receives when the platform takes a session Durable // Object away mid-request. // // The self-abort at the end of a session teardown IS reachable black-box, and // e2e/cloud/mcp-destroyed-session-envelope.test.ts owns that case end to end — // it is deliberately not re-tested here. The remaining platform resets (a // deploy replacing the script, a storage timeout, a backend blip, a cancelled // blockConcurrencyWhile) cannot be provoked on the dev stack at all, so the // mapping from those errors to the wire response is pinned here instead. describe("durableObjectFailureResponse", () => { const envelope = async ( error: unknown, ): Promise<{ readonly status: number; readonly retryAfter: string | null; readonly body: { readonly jsonrpc?: string; readonly error?: { readonly code?: number; readonly message?: string; readonly data?: unknown; }; }; }> => { const failure = classifyDurableObjectError(error); if (!failure) throw new Error("expected the error to be classified as a platform failure"); const response = durableObjectFailureResponse(failure); return { status: response.status, retryAfter: response.headers.get("retry-after"), body: await response.json(), }; }; // A deploy resets every live Durable Object. The session id is still valid, // so the client must be told to retry it — and told how long to wait, or the // 503 is not actionable. it("tells the client to retry the same session after a transient platform reset", async () => { const result = await envelope(new Error("Durable Object reset because its code was updated.")); expect(result.status, "HTTP status is the discriminator clients act on").toBe(503); expect(result.body.jsonrpc).toBe("2.0"); expect(result.body.error?.code).toBe(-32001); // `retryAfterSeconds` renders as the standard `Retry-After` header, which // is what a polite client (and any generic retry layer) actually reads. expect(result.retryAfter, "the client is told how long to back off").toBe( String(UNAVAILABLE_RETRY_AFTER_SECONDS), ); }); it("renders the same retry verdict for a storage timeout and a backend blip", async () => { for (const message of [ "Durable Object storage operation exceeded timeout which caused object to be reset.", "internal error; reference = 0000aaaa1111bbbb", "A call to blockConcurrencyWhile() in a Durable Object waited for too long. The call was canceled and the Durable Object was reset.", ]) { const result = await envelope(new Error(message)); expect(result.status, message).toBe(503); expect(result.retryAfter, message).not.toBeNull(); } }); // A storage fault while the object is coming up reaches the handler as the // same untyped Error as every other reset, and used to fall out of the worker // as an unhandled 500. Nothing about the request caused it, so the client is // told to retry the same id rather than to reconnect. it("tells the client to retry the same session after a startup storage fault", async () => { const result = await envelope( new Error( "Internal error while starting up Durable Object storage caused object to be reset; reference = 0000aaaa1111bbbb", ), ); expect(result.status, "HTTP status is the discriminator clients act on").toBe(503); expect(result.body.jsonrpc).toBe("2.0"); expect(result.body.error?.code).toBe(-32001); expect(result.retryAfter, "the client is told how long to back off").toBe( String(UNAVAILABLE_RETRY_AFTER_SECONDS), ); }); // An invocation cut off at the CPU ceiling reaches the handler as the same // untyped Error as every other reset, and used to fall out of the worker as // an unhandled 500. It must land on the retry-the-same-id envelope instead. it("tells the client to retry the same session after a CPU-limit reset", async () => { const result = await envelope( new Error("Durable Object exceeded its CPU time limit and was reset."), ); expect(result.status, "HTTP status is the discriminator clients act on").toBe(503); expect(result.body.jsonrpc).toBe("2.0"); expect(result.body.error?.code).toBe(-32001); expect(result.retryAfter, "the client is told how long to back off").toBe( String(UNAVAILABLE_RETRY_AFTER_SECONDS), ); }); });