Skip to content

fix: deliver the actual error to onError(connection, error) when a handler throws#1717

Open
mattzcarey wants to merge 3 commits into
mainfrom
fix/388-onerror-undefined
Open

fix: deliver the actual error to onError(connection, error) when a handler throws#1717
mattzcarey wants to merge 3 commits into
mainfrom
fix/388-onerror-undefined

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Note

This is a Fable experiment (model-authored PR). Please close if it adds no value.

Fixes #388

Problem

When .sql (or anything else) throws while an agent is handling a websocket message, the error is caught by _tryCatch, which reported it with a single-argument call:

catch (e) {
  throw this.onError(e);
}

TypeScript overloads don't exist at runtime. A user override written with the documented two-parameter signature — onError(connection: Connection, error?: unknown), which is exactly what the reporter of #388 had — therefore receives the thrown SqlError as the connection parameter and undefined as the error parameter. The real error hides in the wrong slot.

It gets worse: throw this.onError(e) throws onError's return value. A user override that returns normally turns the failure into throw undefined, so the original error also vanishes from upstream logs. That's "my agent is silently failing and i have no clue why."

(An earlier revision of this PR misdiagnosed this as the runtime delivering an ErrorEvent with no detail, and synthesized a placeholder Error for that case. That masked the symptom instead of delivering the real error — thanks @threepointone for catching it. The synthesized error is gone.)

Fix

  • Agent._tryCatch (and AIChatAgent._tryCatchChat) now read the connection from the agent context and deliver the actual caught error through the two-argument overload — this.onError(connection, e) — when the failure happened while handling a connection; server-side failures (alarms, schedules, email) still use this.onError(e). The original error is then rethrown, not onError's return value.
  • The base onError implementation discriminates its overloads on call arity instead of error truthiness, so a genuine two-argument call with no error detail (partyserver's onError(connection, e.error) from an ErrorEvent) is no longer misrouted into the server branch — which used to log and throw the Connection object itself. Errors are passed through exactly as received; nothing is synthesized.

The public overload signatures are unchanged.

Tests

packages/agents/src/tests/on-error.test.ts (new), with a dedicated TestOnErrorAgent:

  • End-to-end repro of In AIChatAgent onError's error argument should never be undefined. #388: a real websocket sends a message whose handler runs this.sql`SELECT * FROM table_that_does_not_exist`; a user-style two-parameter onError(connection, error) override records what it receives. Asserts the error slot carries the actual SqlError ("no such table") and the connection slot carries the Connection. Red on main: error is undefined and the SqlError arrives as connection.
  • Base-impl probes on TestStateAgent: (connection, error) and (error) rethrow the original error; (connection, undefined) passes undefined through — it neither throws the Connection object nor synthesizes a stand-in error.

pnpm run check green (format, lint, 102-project typecheck); full agents suite 1760 passed; full @cloudflare/ai-chat suite 659 passed.

Notes for maintainers

  • The state persistence hook error path (onStateChanged/onStateUpdate throwing) is also routed through onError(connection, error) when the update came from a connection — same bug, same fix, covered by a test.
  • Docs taught the broken patterns and are fixed in this PR: docs/agent-class.md's example discriminated on error truthiness and threw the first argument (i.e. the Connection object on connection errors), and docs/http-websockets.md declared onError twice in one class, which is invalid at runtime. Both now show a single implementation handling both call shapes.
  • Remaining one-argument internal call sites (await this.onError(e) in queue/schedule/alarm/agent-tool-recovery paths) are genuinely server-side — no connection in context — and are unchanged.
  • _tryCatch rethrowing the original error means handler failures now surface as the real exception in runtime logs even when a user override swallows them silently before — previously those paths threw undefined.
  • Changeset included (agents + @cloudflare/ai-chat, both patch).

@changeset-bot

changeset-bot Bot commented Jun 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4788db5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/ai-chat Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

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 throw

At packages/agents/src/index.ts:3125, _tryCatch does throw this.onError(e). Since the base onError always throws internally (line 3225), the outer throw is dead code — the exception from onError propagates before it returns. However, if a subclass overrides onError to not throw (which the return type void | Promise<void> allows), then throw this.onError(e) would throw undefined (sync case) or throw a Promise object (async case). This is a pre-existing design quirk unrelated to this PR, but worth noting since the onError contract is being refined here.

(Refers to lines 3121-3127)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

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's throw this.onError(e) is dead code while the base onError throws, and a non-throwing override would make it throw undefined/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.

@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1717

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1717

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1717

create-think

npm i https://pkg.pr.new/create-think@1717

hono-agents

npm i https://pkg.pr.new/hono-agents@1717

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1717

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1717

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1717

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1717

commit: 4788db5

…ssage handler throws

When .sql threw while handling a websocket message, _tryCatch reported it
via a one-argument this.onError(error) call, so user overrides written with
the documented two-parameter signature received the error as the connection
and undefined as the error (#388). _tryCatch/_tryCatchChat now route the
actual caught error through onError(connection, error) using the connection
from the agent context, and rethrow the original error instead of onError's
return value. The base onError discriminates overloads on arity and passes
errors through exactly as received — never synthesized.
@mattzcarey mattzcarey force-pushed the fix/388-onerror-undefined branch from cbcc969 to 8a8f370 Compare June 9, 2026 22:05
@mattzcarey mattzcarey changed the title fix(agents): discriminate onError overloads on arity, not error truthiness fix: deliver the actual error to onError(connection, error) when a handler throws Jun 9, 2026
Matt Carey added 2 commits June 9, 2026 23:13
…ect onError docs

The state persistence hook (onStateChanged/onStateUpdate) error path also
delivered errors via a one-argument onError call even when the update came
from a connection. Route it through onError(connection, error) like
_tryCatch. Fix the docs examples, which taught the bug: agent-class.md
discriminated on error truthiness and threw the first argument (the
Connection object on connection errors), and http-websockets.md declared
onError twice in one class, which is invalid at runtime. Both now show a
single implementation handling both call shapes.
…aths

Same ALS store, but the public typed helper instead of reaching into the
internal agentContext directly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In AIChatAgent onError's error argument should never be undefined.

1 participant