Rejected sub-agent WebSocket connection retries forever, no error surfaced · Issue #2118 · cloudflare/agents · GitHub
Skip to content

Rejected sub-agent WebSocket connection retries forever, no error surfaced #2118

Closed

Description

@urjitc

Describe the bug

If a parent agent rejects a sub-agent WebSocket connection (e.g. onBeforeSubAgent returns a 404 for a deleted or unknown name), the client never finds out and retries forever.

The rejection happens before the upgrade, so the client sees a failed HTTP handshake instead of a WebSocket close frame. partysocket turns that into a synthesized close event with code 1000. isTerminalCloseEvent only treats 1008 and 4000-4999 as final (packages/agents/src/client.ts:32-34), so the client retries with maxRetries = Infinity and connectionError is never set. The app has no way to show an error.

The one code path that does send a final close, close(1008, "Sub-agent connection rejected") at packages/agents/src/index.ts:7412, only runs for nested sub-agents (gate: true). A direct parent to child rejection can never reach it, because the Response from onBeforeSubAgent is returned before the upgrade (index.ts:7024-7028).

To Reproduce

Live demo (open and watch the counters): https://agents-2118-repro.chakrabortyurjit.workers.dev

Or locally, no Cloudflare account needed:

  1. git clone https://github.com/urjitc/agents-2118-repro && cd agents-2118-repro
  2. npm install
  3. node repro.mjs (starts a server that rejects upgrades with 404 and points partysocket at it)
  4. Observed output:
tcp connections reaching server: 21
rejected upgrade attempts in 5s: 21
client error messages: [ 'Unexpected server response: 404' ]
close events seen by client: 21
distinct close codes: [ 1000 ]
any close classified terminal: false
client retryCount: 21
verdict: retries forever, nothing terminal ever surfaces

Expected behavior

After a rejected connection (or a few of them), the client should stop and set connectionError so the app can show an error state.

Screenshots

Not needed; the live demo shows the retry loop and counters directly.

Version:

agents@0.19.0 (same behavior on main at aed6d8f), partysocket@1.3.0.

Additional context

Related to #2003 / #2024. Note that #2024 closes a deleted sub-agent's sockets with code 1001, which is also not treated as final, so those clients would land in this same retry loop.

Activity

  1. changed the title [-]Rejected sub-agent WebSocket connect retries forever with no terminal error (failed handshake surfaces as synthetic close 1000)[/-] [+]Rejected sub-agent WebSocket connection retries forever, no error surfaced[/+] on Aug 16, 2026
  2. urjitc commented on Aug 16, 2026

    @urjitc
    Author

    Added a runnable repro: live demo at https://agents-2118-repro.chakrabortyurjit.workers.dev (partysocket against an endpoint that rejects every upgrade with 404, live counters showing close code 1000 on every attempt and no terminal close), source and a local Node version at https://github.com/urjitc/agents-2118-repro.

  3. scott-edwards commented on Aug 17, 2026

    @scott-edwards

    We hit this in production last weekend. Adding impact data and one finding that isn't in the report yet.

    Impact. We moved each of our product's runs into a native sub-agent facet, and the parent's onBeforeSubAgent returned 404 for a name that had no facet yet. That is the direct parent-to-child path you describe, so the rejection never became a close frame. Result: 62 of 68 active runs had a chat surface that retried the same 404 indefinitely and never reported anything. One Durable Object retried on a ~5 second cycle for 29 hours. Every affected person saw a loading state that never resolved and never errored, so it read as a hung app rather than a failure.

    The client cannot work around this. We tried both documented options before concluding the fix has to be in the SDK:

    • shouldReconnectOnClose returning false stops the retry loop, but connectionError is still not set, because the handler requires !shouldReconnect && terminalClose and the synthesized close is non-terminal. The failure just becomes silent instead of noisy — strictly worse for the user.
    • maxRetries stops _connect() without clearing _shouldReconnect, so the same handler never fires. Also silent.

    So today there is no combination of public options that lets an app distinguish "still connecting" from "this route will never connect." That distinction is the whole difference between a spinner and an error state.

    Suggestion. Treating a close that arrives with no prior successful open as terminal after a small number of attempts would cover this, #2024's 1001, and any future non-terminal rejection, without changing behaviour for genuine mid-session drops.

    Happy to test a fix against our workload.

  4. HaoChiBao commented on Aug 18, 2026

    @HaoChiBao

    I'll pick this up.

    The handshake 404 never becomes a close the client treats as final, so it just keeps retrying. Scott's right that the public knobs don't help either: shouldReconnectOnClose / maxRetries still never set connectionError, so the app can't tell "still connecting" from "this will never connect."

    I'll stop retrying when a connection never actually opened, surface that as an error, and check it against Urjit's repro. Once there's a branch I'll drop it here so you can try it on the production path.

  5. urjitc commented on Aug 18, 2026

    @urjitc
    Author

    @HaoChiBao are you just scraping issues and having agents respond/make prs 😑

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions