-
Notifications
You must be signed in to change notification settings - Fork 602
fix: deliver the actual error to onError(connection, error) when a handler throws #1717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mattzcarey
wants to merge
3
commits into
main
Choose a base branch
from
fix/388-onerror-undefined
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| "agents": patch | ||
| "@cloudflare/ai-chat": patch | ||
| --- | ||
|
|
||
| fix: deliver the actual error to `onError(connection, error)` when a message handler throws | ||
|
|
||
| When `.sql` (or anything else) threw while an agent was handling a websocket message, the framework reported it with a single-argument `this.onError(error)` call. A user override written with the documented two-parameter signature — `onError(connection, error)` — therefore received the error as the _connection_ and `undefined` as the _error_, and the original failure was replaced by `throw undefined` upstream (#388). | ||
|
|
||
| `_tryCatch` (and ai-chat's `_tryCatchChat`) now read the connection from the agent context and deliver the actual caught error through the `onError(connection, error)` overload, then rethrow the original error rather than `onError`'s return value. The base `onError` discriminates its overloads on call arity instead of error truthiness, so a connection error with no detail is no longer misrouted into the server branch (which threw the Connection object itself); errors are always passed through exactly as received, never synthesized. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { Agent, type Connection, type WSMessage } from "../../index.ts"; | ||
|
|
||
| export type OnErrorCapture = { | ||
| firstArgIsConnection: boolean; | ||
| firstArgErrorMessage: string | null; | ||
| connectionId: string | null; | ||
| errorDefined: boolean; | ||
| errorName: string | null; | ||
| errorMessage: string | null; | ||
| }; | ||
|
|
||
| export type OnErrorTestState = { count: number }; | ||
|
|
||
| // Overrides onError exactly the way users do per the documented websocket | ||
| // signature (#388): two parameters, reading the error from the second one. | ||
| // The captures record what actually arrived in each slot. | ||
| export class TestOnErrorAgent extends Agent<Cloudflare.Env, OnErrorTestState> { | ||
| initialState: OnErrorTestState = { count: 0 }; | ||
| captures: OnErrorCapture[] = []; | ||
|
|
||
| async onMessage(_connection: Connection, message: WSMessage) { | ||
| if (message === "throw-sql") { | ||
| this.sql`SELECT * FROM table_that_does_not_exist`; | ||
| } | ||
| } | ||
|
|
||
| onStateChanged(state: OnErrorTestState, _source: Connection | "server") { | ||
| if (state.count === 999) { | ||
| throw new Error("state hook boom"); | ||
| } | ||
| } | ||
|
|
||
| override onError(connection: Connection, error?: unknown): void { | ||
| const isConnection = | ||
| typeof connection === "object" && | ||
| connection !== null && | ||
| typeof (connection as Connection).send === "function"; | ||
| this.captures.push({ | ||
| firstArgIsConnection: isConnection, | ||
| firstArgErrorMessage: | ||
| connection instanceof Error ? connection.message : null, | ||
| connectionId: isConnection ? (connection as Connection).id : null, | ||
| errorDefined: error !== undefined, | ||
| errorName: error instanceof Error ? error.name : null, | ||
| errorMessage: error instanceof Error ? error.message : null | ||
| }); | ||
| // Like the issue reporter's handler: observe the error, don't rethrow. | ||
| } | ||
|
|
||
| async getCaptures(): Promise<OnErrorCapture[]> { | ||
| return this.captures; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { env, exports } from "cloudflare:workers"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { getAgentByName } from ".."; | ||
| import { MessageType } from "../types"; | ||
| import type { OnErrorCapture } from "./agents/on-error.ts"; | ||
|
|
||
| // Regression tests for #388: when .sql (or anything else) throws while the | ||
| // agent is handling a websocket message, the actual error must be delivered | ||
| // to onError(connection, error) — the error in the error slot, the connection | ||
| // in the connection slot. Before the fix, _tryCatch called this.onError(e) | ||
| // with a single argument, so a user override written with the documented | ||
| // two-parameter signature received the error AS the connection and an | ||
| // undefined error — "my agent is silently failing and i have no clue why". | ||
|
|
||
| async function connectWS(path: string) { | ||
| const res = await exports.default.fetch(`http://example.com${path}`, { | ||
| headers: { Upgrade: "websocket" } | ||
| }); | ||
| expect(res.status).toBe(101); | ||
| const ws = res.webSocket as WebSocket; | ||
| expect(ws).toBeDefined(); | ||
| ws.accept(); | ||
| return { ws }; | ||
| } | ||
|
|
||
| describe("onError receives the actual error thrown by .sql (#388)", () => { | ||
| it("delivers (connection, error) to a two-parameter onError override", async () => { | ||
| const room = crypto.randomUUID(); | ||
| const { ws } = await connectWS(`/agents/test-on-error-agent/${room}`); | ||
| ws.send("throw-sql"); | ||
|
|
||
| const agent = await getAgentByName(env.TestOnErrorAgent, room); | ||
| let captures: OnErrorCapture[] = []; | ||
| const start = Date.now(); | ||
| while (captures.length === 0 && Date.now() - start < 2000) { | ||
| captures = await agent.getCaptures(); | ||
| if (captures.length === 0) { | ||
| await new Promise((r) => setTimeout(r, 10)); | ||
| } | ||
| } | ||
| ws.close(); | ||
|
|
||
| expect(captures.length).toBe(1); | ||
| const capture = captures[0]; | ||
| // The error slot must carry the actual SqlError… | ||
| expect(capture.errorDefined).toBe(true); | ||
| expect(capture.errorName).toBe("SqlError"); | ||
| expect(capture.errorMessage).toContain("no such table"); | ||
| // …and the connection slot must carry the Connection, not the error. | ||
| expect(capture.firstArgIsConnection).toBe(true); | ||
| expect(capture.firstArgErrorMessage).toBeNull(); | ||
| }); | ||
|
|
||
| it("delivers (connection, error) when a state persistence hook throws on a connection-driven update", async () => { | ||
| const room = crypto.randomUUID(); | ||
| const { ws } = await connectWS(`/agents/test-on-error-agent/${room}`); | ||
| ws.send( | ||
| JSON.stringify({ | ||
| type: MessageType.CF_AGENT_STATE, | ||
| state: { count: 999 } | ||
| }) | ||
| ); | ||
|
|
||
| const agent = await getAgentByName(env.TestOnErrorAgent, room); | ||
| let captures: OnErrorCapture[] = []; | ||
| const start = Date.now(); | ||
| while (captures.length === 0 && Date.now() - start < 2000) { | ||
| captures = await agent.getCaptures(); | ||
| if (captures.length === 0) { | ||
| await new Promise((r) => setTimeout(r, 10)); | ||
| } | ||
| } | ||
| ws.close(); | ||
|
|
||
| expect(captures.length).toBe(1); | ||
| const capture = captures[0]; | ||
| expect(capture.errorDefined).toBe(true); | ||
| expect(capture.errorMessage).toBe("state hook boom"); | ||
| expect(capture.firstArgIsConnection).toBe(true); | ||
| expect(capture.firstArgErrorMessage).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("base onError overload discrimination", () => { | ||
| it("rethrows the original error for a connection error", async () => { | ||
| const agent = await getAgentByName(env.TestStateAgent, "on-error-ws"); | ||
| const result = await agent.probeOnError("ws-error"); | ||
| expect(result.thrown).toBe("Error"); | ||
| expect(result.message).toBe("ws boom"); | ||
| }); | ||
|
|
||
| it("rethrows the original error for a server error", async () => { | ||
| const agent = await getAgentByName(env.TestStateAgent, "on-error-server"); | ||
| const result = await agent.probeOnError("server-error"); | ||
| expect(result.thrown).toBe("Error"); | ||
| expect(result.message).toBe("server boom"); | ||
| }); | ||
|
|
||
| it("passes an undefined connection error through untouched", async () => { | ||
| // No injection: a two-argument call with no error detail must not throw | ||
| // the Connection object (the old truthiness misroute) and must not | ||
| // synthesize a stand-in Error — it rethrows exactly what was passed. | ||
| const agent = await getAgentByName(env.TestStateAgent, "on-error-ws-undef"); | ||
| const result = await agent.probeOnError("ws-undefined-error"); | ||
| expect(result.thrown).toBe("undefined"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 Pre-existing issue:
throw this.onError(e)in _tryCatch is a no-op throwAt
packages/agents/src/index.ts:3125,_tryCatchdoesthrow this.onError(e). Since the baseonErroralways throws internally (line 3225), the outerthrowis dead code — the exception fromonErrorpropagates before it returns. However, if a subclass overridesonErrorto not throw (which the return typevoid | Promise<void>allows), thenthrow this.onError(e)would throwundefined(sync case) or throw aPromiseobject (async case). This is a pre-existing design quirk unrelated to this PR, but worth noting since theonErrorcontract is being refined here.(Refers to lines 3121-3127)
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed it's a pre-existing quirk and intentionally out of scope here:
_tryCatch'sthrow this.onError(e)is dead code while the baseonErrorthrows, and a non-throwing override would make it throwundefined/a Promise. This PR keeps the base contract (always throws) unchanged. Tightening_tryCatch(e.g.await this.onError(e); throw e;) would change observable behavior for subclasses that deliberately swallow errors in an override, so it deserves its own change if maintainers want it.