`@orpc/experimental-durable-iterator`: token refresh leaves unhandled rejections, and a closed iterator keeps retrying · Issue #2294 · middleapi/orpc · GitHub
Skip to content

@orpc/experimental-durable-iterator: token refresh leaves unhandled rejections, and a closed iterator keeps retrying #2294

Closed

Description

@arajooj

Environment

@orpc/experimental-durable-iterator 1.15.5 (the client plugin is the same in 1.15.7, 1.16.0 and on the 1.x branch), @orpc/client 1.15.5, partysocket 1.3.0. Seen in Chrome 155 and Firefox 157. The reproduction runs on Bun 1.4.

Reproduction

The same pieces the plugin builds internally: partysocket, the WebSocket RPCLink and ClientRetryPlugin, against an oRPC WebSocket server.

import { createORPCClient } from "@orpc/client";
import { ClientRetryPlugin } from "@orpc/client/plugins";
import { RPCLink } from "@orpc/client/websocket";
import { os } from "@orpc/server";
import { RPCHandler } from "@orpc/server/bun-ws";
import { WebSocket } from "partysocket";

const unhandled: string[] = [];
process.on("unhandledRejection", (e: any) => unhandled.push(`${e?.name}: ${e?.message}`));

let hang = false;
const handler = new RPCHandler({
  subscribe: os.handler(async function* () {
    while (true) {
      await Bun.sleep(300);
      yield "tick";
    }
  }),
  updateToken: os.handler(async () => {
    if (hang) await new Promise(() => {});
  }),
});
const sockets = new Set<any>();
const server = Bun.serve({
  port: 0,
  fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response(null, { status: 400 })),
  websocket: {
    open: (ws) => void sockets.add(ws),
    message: (ws, msg) => void handler.message(ws, msg, { context: {} }),
    close: (ws) => {
      sockets.delete(ws);
      handler.close(ws);
    },
  },
});

function connect() {
  let attempts = 0;
  const websocket = new WebSocket(`ws://localhost:${server.port}`);
  const client: any = createORPCClient(
    new RPCLink({
      websocket,
      clientInterceptors: [async (o) => (attempts++, o.next())],
      plugins: [new ClientRetryPlugin()],
    }),
  );
  return { websocket, client, attempts: () => attempts };
}

// 1a. Refresh with the socket closed, as the plugin does it.
{
  const { websocket, client } = connect();
  await client.updateToken({ token: "a" });
  websocket.close();
  setTimeout(async () => void (await client.updateToken({ token: "b" })), 0);
  await Bun.sleep(200);
  console.log("1a", unhandled.splice(0)); // [ "Error: Cannot send message, WebSocket is not open." ]
}

// 1b. The connection drops while the update is in flight.
{
  const { client } = connect();
  await client.updateToken({ token: "a" });
  hang = true;
  setTimeout(async () => void (await client.updateToken({ token: "b" })), 0);
  await Bun.sleep(100);
  for (const ws of sockets) ws.terminate();
  await Bun.sleep(300);
  hang = false;
  console.log("1b", unhandled.splice(0)); // [ "AbortError: [AsyncIdQueue] Queue[2] was closed or aborted while waiting for pulling." ]
}

// 2. Subscribe with infinite retry (as the plugin does), then close the socket (closeConnection()).
{
  const { websocket, client, attempts } = connect();
  const iterator = await client.subscribe(undefined, { context: { retry: Number.POSITIVE_INFINITY } });
  const consumer = (async () => {
    for await (const _ of iterator);
    return "settled";
  })();
  await Bun.sleep(1000);
  const before = attempts();
  websocket.close();
  await Bun.sleep(9000);
  console.log("2", attempts() - before, "new attempts in 9 s;", await Promise.race([consumer, Bun.sleep(20).then(() => "pending")]));
  // 4 new attempts in 9 s; pending
}

server.stop(true);
process.exit(0);

Describe the bug

1. Unhandled rejections from the token refresh

DurableIteratorLinkPlugin refreshes the token in setTimeout(async () => { ... }) and nothing handles that promise:

if (canProactivelyUpdateToken) {
  await durableClient.updateToken({ token: tokenAndPayload.token });
}
  • If the WebSocket is not open when the refresh fires (a background tab whose connection was closed, or partysocket waiting to reconnect), updateToken rejects with Error: Cannot send message, WebSocket is not open.
  • If the connection drops while the update is in flight, it rejects with AbortError: [AsyncIdQueue] Queue[N] was closed or aborted while waiting for pulling.

Both end up as unhandled rejections in the browser, and error monitoring reports them. Nothing breaks: tokenAndPayload already holds the new token, so the reconnection uses it. The same applies to the retry(...) around next(), which rejects through exit(err) once isFinished is set.

2. A closed iterator keeps retrying every 2 s

When the caller aborts (signal → closeConnection() → websocket.close()), the inner durableClient.subscribe(void 0, { context: { retry: Number.POSITIVE_INFINITY } }) doesn't stop. No signal reaches ClientRetryPlugin, shouldRetry defaults to true, and partysocket's readyState stays CLOSED after close(). So every attempt throws Cannot send message, WebSocket is not open. and gets retried after 2 s, until the page reloads. The consumer's for await never settles either: the pending next() is stuck in the retry loop.

Happy to open a PR against 1.x if the suggested fix below works for you.

Additional context

Suggested fix. What we run as a local patch on 1.15.5, in dist/client/index.mjs. When the update fails, reconnect: the new connection carries the new token. And stop the inner retry once the iterator is closed.

               } catch (err) {
                 if (isFinished) {
                   exit(err);
                 }
                 throw err;
               }
+            }).catch((err) => {
+              // retry() only gives up through exit(), once the iterator is closed
+              if (!isFinished) {
+                throw err;
+              }
             });
+            if (!newTokenAndPayload) {
+              return;
+            }
             const canProactivelyUpdateToken = ...;
             tokenAndPayload = newTokenAndPayload;
             await refreshTokenBeforeExpire();
             if (canProactivelyUpdateToken) {
-              await durableClient.updateToken({ token: tokenAndPayload.token });
+              // the socket is closed or dropped mid-update; a new connection carries the new token
+              await durableClient.updateToken({ token: tokenAndPayload.token }).catch(() => {
+                if (!isFinished) {
+                  websocket.reconnect();
+                }
+              });
             } else {
               websocket.reconnect();
             }
@@
       const iterator_ = await durableClient.subscribe(void 0, {
         context: {
-          retry: Number.POSITIVE_INFINITY
+          retry: Number.POSITIVE_INFINITY,
+          // stop retrying once the iterator is closed (abort or return)
+          shouldRetry: () => !isFinished
         },

With the real plugin, before and after this change (against a fake hub): no unhandled rejection, the reconnection carries the new token, events keep flowing, and a closed iterator makes at most one more attempt and settles. The code is the same on the 1.x branch today (packages/durable-iterator/src/client/plugin.ts). I couldn't find the durable iterator on main (v2), so this is probably a 1.x-only fix.

Logs

1a [ "Error: Cannot send message, WebSocket is not open." ]
1b [ "AbortError: [AsyncIdQueue] Queue[2] was closed or aborted while waiting for pulling." ]
2 4 new attempts in 9 s; pending

Activity

  1. pullfrog commented on Oct 21, 2026

    @pullfrog
    Contributor

    Thanks for the detailed write-up — I traced both reports through the 1.x source and can confirm the diagnosis; no duplicate of this exists.

    Confirmed code paths (packages/durable-iterator/src/client/plugin.ts):

    • The refresh work runs inside a bare setTimeout(async () => { ... }) at plugin.ts:146-193, so any rejection from durableClient.updateToken(...) (plugin.ts:179) or from the retry(...) helper becomes unhandled. Your 1a/1b traces line up exactly.
    • durableClient.subscribe(void 0, { context: { retry: Number.POSITIVE_INFINITY } }) at plugin.ts:205-210 never receives a stop signal. closeConnection() (plugin.ts:197-201) only sets isFinished, clears the refresh timeout, and closes the socket — it does not reach the inner retry loop, so ClientRetryPlugin keeps retrying every 2 s against a closed socket.

    On the proposed fix

    • shouldRetry: () => !isFinished is valid: it's a supported ClientRetryPluginContext option (packages/client/src/plugins/retry.ts:34) and is re-evaluated per attempt (retry.ts:108), so it sees the closure's current isFinished. That's the cleaner fix for issue 2; forwarding options.signal into the subscribe context would also work since the retry plugin bails on signal.aborted (retry.ts:133-135), but shouldRetry expresses the intent directly.
    • websocket.reconnect() on a failed proactive update is consistent with how the channel/tags-mismatch branch already behaves (plugin.ts:186).

    Two things to settle before a PR

    1. packages/durable-iterator/src/client/plugin.test.ts:283-362 (if refresh token is invalid) currently asserts exactly one unhandled rejection after output.return() (plugin.test.ts:358-361). The retry(...).catch(...) that swallows when isFinished would make that assertion fail, so that test encodes the present behavior and would need to change. Worth confirming that dropping that unhandled rejection is the intended contract.
    2. Those tests mock partysocket (plugin.test.ts:18-28), so 1a/1b/2 can't be reproduced in the current unit suite — a fix should come with an integration-style test using real partysocket, or at least a test that drives the mocked socket's send/readyState to reject.

    Scope: confirmed packages/durable-iterator is absent from main (v2), so this is indeed 1.x-only. A targeted PR against 1.x looks appropriate.

    Pullfrog  | Implement a fix ➔ | View workflow run | via Pullfrog | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

  2. added
    bugSomething isn't working
    size:SThis PR changes 10-29 lines, ignoring generated files.
    on Oct 21, 2026
  3. dinwwwh commented on Oct 22, 2026

    @dinwwwh
    Member

    PR is welcome

  4. added a commit that references this issue on Oct 22, 2026
    3499f27
  5. dinwwwh commented on Oct 22, 2026

    @dinwwwh
    Member

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

    bugSomething isn't workingsize:SThis PR changes 10-29 lines, ignoring generated files.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions