Skip to content

Realtime create() reuses the initial custom URL during automatic reconnects #2971

Closed

Description

@huanglianggit

Confirm this is a Node library issue and not an underlying OpenAI API issue

  • This is an issue with the Node library.

Describe the bug

With reconnect: true, OpenAIRealtimeWS.create() and OpenAIRealtimeWebSocket.create() retain the URL returned by the first buildRealtimeURL call for all later physical connections. The original callback is never called again. The corresponding constructors rebuild the URL on each reconnect.

This matters for a custom gateway whose connection URL needs to change between physical attempts. I reproduced a controlled route rotation: the constructor obtains a fresh URL and reconnects, while .create() retries the retired URL and exhausts its reconnect budget. Function-based API credentials are still refreshed, but the URL remains stale; these clients cannot simply switch to the synchronous constructor.

The finding is limited to the combination of .create(), automatic recovery, and a URL builder that needs to run again. I am not reporting that ordinary fixed-URL recovery is broken or that there is a security vulnerability. No live OpenAI/Azure service or real credentials were involved.

Expected behavior

Keep the initial URL snapshot for the initial connection, but invoke the original URL builder once for each later physical reconnect attempt, consistently with the constructor behavior and the existing recovery uses one URL per lifetime attempt regression.

To Reproduce

  1. Check out main at c3e69cdabaa5a03be6f3c5ae89449aa2c950dd75 (7.39.0) and install the pinned dependencies.

  2. Add the following test to tests/realtime-wire-lifecycle.test.ts. It reuses that file's existing loopback TLS server, CA setup, settings, and rawEvent helper; it does not change SDK sources or relax TLS validation.

  3. Run:

    pnpm test tests/realtime-wire-lifecycle.test.ts --testNamePattern='factory rebuilds a custom URL' --maxWorkers=1
test.each([StableWS, StableNative])('factory rebuilds a custom URL (%s)', async (Realtime) => {
  let builds = 0;
  const incoming = once(peers, 'connection');
  const realtime = await Realtime.create(
    new OpenAI({ baseURL, apiKey: 'ek_synthetic_only' }),
    {
      ...settings,
      reconnect: true,
      maxReconnectAttempts: 1,
      reconnectInitialDelay: 0,
      reconnectMaxDelay: 0,
      buildRealtimeURL: () => {
        const url = new URL('realtime', baseURL);
        url.protocol = 'wss:';
        url.searchParams.set('target', String(++builds));
        return url;
      },
    },
  );
  realtime.on('error', () => {});
  try {
    const opened = rawEvent(realtime.socket, 'open');
    const [first] = await incoming;
    await opened;
    const replacement = once(peers, 'connection');
    const reconnected = realtime.emitted('reconnected');
    first.close(1012);
    const [, request] = await replacement;
    await reconnected;
    expect({ builds, path: request.url }).toEqual({
      builds: 2,
      path: '/v1/realtime?target=2',
    });
  } finally {
    realtime.close();
  }
});

Observed in an equivalent local four-case regression using the same public source entrypoints and TLS fixture:

  • Both constructor controls pass: builder calls = 2, paths = target=1, then target=2.
  • Both .create() cases fail: builder calls = 1, paths = target=1, then target=1.
  • The replacement still emits reconnected if the server accepts the stale route; this alone therefore does not detect the problem.

A separate probe against the locally built CommonJS and ESM outputs used a loopback server that accepts the first route but returns 503 if it is reused after disconnect:

Entry path Formats / transports checked Result
Constructor with static key CJS + ESM, ws + native All 4 controls rebuild the URL and reconnect
.create() with static key CJS + ESM, ws + native All 4 reuse the old URL and close after the bounded retry
.create() with key provider CJS + ESM, ws + native All 4 refresh credentials twice in total, but reuse the old URL and close

The matrix was reproduced in two independent executions. It uses synthetic keys and locally generated test certificates. Azure factories, beta helpers, and other runtimes were not assessed for this finding.

Root cause

The factories replace the caller's callback with buildRealtimeURL: () => url:

The recovery hook then calls buildRealtimeURL(connectionClient, props) with those substituted props. This initial one-shot snapshot behavior predates automatic recovery, but now pins later attempts too. The constructor path still retains the caller's original callback.

Related background: #2305 / #2308 introduced custom connection URL support, and #2968 added opt-in recovery. I found no equivalent open issue or PR after searching the current lists and all-state buildRealtimeURL, reconnect factory, and reconnect URL results.

Validation and scope

  • Existing focused wire/adapter tests: 157 passed.
  • Broader related Realtime / Responses / Translation / Live tests: 1605 passed, 1 existing opt-in live test skipped. These overlap the focused tests and are not additive coverage counts.
  • New source regression: 2 constructor controls passed / 2 factory cases failed.
  • Built-output probe: 4 successful controls / 8 reproducible factory failures, across CJS and ESM.
  • Frozen installation, root TypeScript check, lint/format, build, and TS 4.9 packaged-source navigation check passed. This was a local build from the exact main SHA, not a separate npm-registry installation test.
  • No production fix or PR is included; I am submitting the reproducible issue and root-cause analysis under the current contribution policy.

OS

Windows x64 (loopback-only reproduction).

Node version

Node v24.19.0, pnpm 11.5.1, Vitest 5.0.3.

Library version

7.39.0, locally built from main commit c3e69cdabaa5a03be6f3c5ae89449aa2c950dd75.

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