Skip to content

fix: fall back to the base transport when websocket handshakes are rejected outright - #2285

Merged
VeskeR merged 1 commit into
mainfrom
fix-ws-fast-fail-base-transport-fallback
Aug 10, 2026
Merged

fix: fall back to the base transport when websocket handshakes are rejected outright#2285
VeskeR merged 1 commit into
mainfrom
fix-ws-fast-fail-base-transport-fallback

Conversation

@VeskeR

@VeskeR VeskeR commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem

A browser behind a proxy that rejects the WebSocket upgrade — a 403, say — never connects. It rotates through its fallback hosts on web_socket, reports 80003 Unable to connect (and no more fallback hosts to try), and repeats indefinitely. xhr_polling is never attempted.

If the proxy instead silently drops the handshake, the same client connects over xhr_polling in ~10s. Only the fast-failure case is broken.

The failover to the base transport hangs off webSocketGiveUpTimer (webSocketConnectTimeout, 10s), which only helps when a websocket attempt is slow. On fast rejection, tryFallbackHosts() exhausts the host list in milliseconds and calls notifyState('disconnected') — which cancels that timer. Since 2.0 replaced connect-on-base-then-upgrade with connect-on-websocket-then-fall-back, the timers are the only route to the base transport.

Fix

Exhausting every host on web_socket now runs the websocket connectivity check and decides on its result, instead of writing the attempt off:

Connectivity check Behaviour
fails take up the base transport immediately
hangs unchanged — the give-up timer takes it up at webSocketConnectTimeout
succeeds unchanged — disconnected, since websockets work here and the base transport talks to the same unhealthy hosts

That last row is what keeps try_fallback_hosts_on_placement_constraint passing: a client told to move datacenter must not hop back to the primary host on a different transport. It is also the condition the give-up timer already applies, so the two paths now agree.

This only affects attempts that fail on every host over websocket without the transport becoming viable. Clients with no base transport available skip the check entirely.

Testing

Three new tests in test/realtime/transports.test.js, using a rejectingWebSocket() drop-in that fires onerror then close(1006) immediately — what a proxy 403 looks like to the browser WebSocket API:

  • ws_rejected_immediately — falls back to the base transport. Times out at 60s on main.
  • ws_rejected_immediately_but_ws_connectivity_available — does not switch transport when websockets work on the network.
  • ws_rejected_immediately_with_no_base_transport — goes straight to disconnected.

All nine tests in the file pass on Chromium, Firefox and WebKit. The red CI legs are the pre-existing flaky suites (presence, rest/message-operations, channel_backoff_*), each verified to fail on main too.


🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved real-time connection reliability when WebSocket connections fail during setup.
    • The app now more accurately distinguishes between unavailable connectivity and rejected connection hosts.
    • Added smoother fallback to alternative transport methods when WebSocket connections cannot be established.
    • Improved disconnected-state handling when no usable connection method is available.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e4cc48ba-2337-4945-a515-aac8ccb42365

📥 Commits

Reviewing files that changed from the base of the PR and between 26e9bce and 2f34fdb.

📒 Files selected for processing (1)
  • src/common/lib/transport/connectionmanager.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/common/lib/transport/connectionmanager.ts

Walkthrough

ConnectionManager now shares one WebSocket connectivity check per attempt. After WebSocket hosts fail, it starts the base transport or transitions to disconnected based on connectivity. Tests cover both outcomes.

Changes

WebSocket connectivity fallback

Layer / File(s) Summary
Shared WebSocket connectivity checks
src/common/lib/transport/connectionmanager.ts
ConnectionManager caches one connectivity-check promise per WebSocket attempt. Timer logging and fallback handling use the shared result.
Exhausted-host fallback routing
src/common/lib/transport/connectionmanager.ts
After WebSocket hosts are exhausted, the manager checks availability. It starts the base transport when WebSocket connectivity is unavailable and reports failure when connectivity remains available.
Fallback behavior validation
test/realtime/transports.test.js
Tests simulate immediate WebSocket rejection and verify base-transport fallback, disconnected state, and no-base-transport behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConnectionManager
  participant WebSocket
  participant BaseTransport
  ConnectionManager->>WebSocket: Check connectivity once
  WebSocket-->>ConnectionManager: Return availability
  alt Connectivity unavailable
    ConnectionManager->>BaseTransport: Start connection
  else Connectivity available
    ConnectionManager-->>ConnectionManager: Transition to disconnected
  end
Loading

Poem

A rabbit checks the socket bright,
One shared promise guides the night.
If hosts fail, the base hops in,
Or disconnected states begin.
Clean fallback marks the beat. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix: falling back to the base transport when WebSocket handshakes fail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-ws-fast-fail-base-transport-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/features August 10, 2026 14:00 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/features August 10, 2026 14:13 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/bundle-report August 10, 2026 14:13 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/typedoc August 10, 2026 14:13 Inactive
@VeskeR
VeskeR force-pushed the fix-ws-fast-fail-base-transport-fallback branch from 192db23 to ecc6608 Compare August 10, 2026 14:53
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/features August 10, 2026 14:54 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/bundle-report August 10, 2026 14:54 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/typedoc August 10, 2026 14:55 Inactive
@VeskeR
VeskeR force-pushed the fix-ws-fast-fail-base-transport-fallback branch from ecc6608 to 26e9bce Compare August 10, 2026 15:02
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/features August 10, 2026 15:03 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/bundle-report August 10, 2026 15:03 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/2285/typedoc August 10, 2026 15:03 Inactive
@VeskeR
VeskeR marked this pull request as ready for review August 10, 2026 15:14
@VeskeR
VeskeR requested a review from owenpearson August 10, 2026 15:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/common/lib/transport/connectionmanager.ts`:
- Around line 1510-1512: Add a concise, concrete remediation to the ErrorInfo
created in the wsAvailable branch of the connection fallback flow, guiding users
to investigate WebSocket connectivity for the configured hosts. Verify the
wording matches the documented Ably error path and preserves the existing
message, code, and status passed to giveUp().

In `@test/realtime/transports.test.js`:
- Around line 258-266: Update the ws_rejected_immediately_with_no_base_transport
test and its rejectingWebSocket helper to record connectivity-check URLs, then
assert that no connectivity checks were initiated when only the web_socket
transport is configured. Keep the existing disconnected cleanup flow unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c986c20-ede5-46ff-8421-0ff02560a8f9

📥 Commits

Reviewing files that changed from the base of the PR and between 7fa7dc5 and 26e9bce.

📒 Files selected for processing (2)
  • src/common/lib/transport/connectionmanager.ts
  • test/realtime/transports.test.js

Comment on lines +1510 to +1512
if (wsAvailable) {
giveUp(new ErrorInfo('Unable to connect (and no more fallback hosts to try)', 80003, 404));
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a verified remediation to this public error.

giveUp() sends this SDK-originating ErrorInfo through notifyState, so application code can receive it on the public connection-state boundary. Add a concise remediation that helps users investigate WebSocket failures for the configured hosts. Verify the wording against the documented Ably error path before shipping it.

As per coding guidelines: Add a concrete remediation to every publicly reachable SDK-originating throw site when it provides actionable value beyond the message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/common/lib/transport/connectionmanager.ts` around lines 1510 - 1512, Add
a concise, concrete remediation to the ErrorInfo created in the wsAvailable
branch of the connection fallback flow, guiding users to investigate WebSocket
connectivity for the configured hosts. Verify the wording matches the documented
Ably error path and preserves the existing message, code, and status passed to
giveUp().

Source: Coding guidelines

Comment on lines +258 to +266
it('ws_rejected_immediately_with_no_base_transport', function (done) {
const helper = this.test.helper;
Config.WebSocket = rejectingWebSocket();
const realtime = helper.AblyRealtime({ transports: ['web_socket'] });

realtime.connection.once('disconnected', function () {
helper.closeAndFinish(done, realtime);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert that no connectivity check starts without a base transport.

This test passes if checkWsConnectivity() runs, because rejectingWebSocket() rejects that check immediately and the client still becomes disconnected. Record connectivity-check URLs in rejectingWebSocket() and assert that the check count is zero here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/realtime/transports.test.js` around lines 258 - 266, Update the
ws_rejected_immediately_with_no_base_transport test and its rejectingWebSocket
helper to record connectivity-check URLs, then assert that no connectivity
checks were initiated when only the web_socket transport is configured. Keep the
existing disconnected cleanup flow unchanged.

@VeskeR VeskeR mentioned this pull request Aug 10, 2026
'ConnectionManager.abandonWebSocketForBaseTransport()',
'websocket connectivity appears to be unavailable; trying base transport',
);
this.abandonedWebSocket = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: we should remove the duplication between webSocketGiveUpTimer and this

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.

@VeskeR VeskeR mentioned this pull request Aug 10, 2026
…right

The websocket -> base transport failover was only ever triggered by the
webSocketGiveUpTimer, which fires webSocketConnectTimeout after a connection
attempt begins. That only helps when a websocket attempt is slow. A network
that actively rejects the upgrade request fails every candidate host within
milliseconds, so tryTransportWithFallbacks exhausts the host list and notifies
`disconnected` before either the slow timer or the give-up timer can fire,
and notifyState cancels both. Such a client rotates its fallback hosts on
web_socket, gives up with 80003, and repeats forever, never attempting the
base transport. Since 2.0 replaced connect-on-base-then-upgrade with
connect-on-websocket-then-fall-back there is no other route to it, so
these clients simply never connect.

Give the failover a second trigger: exhausting every host on websocket now
runs the websocket connectivity check and decides on its result rather than
writing the attempt off. If the check fails we take up the base transport
immediately; if it hangs the give-up timer still does so as before; and if it
succeeds - websockets do work here, so it is the hosts that are unhealthy -
we go to `disconnected` as we always have, since the base transport talks to
those same hosts.
@VeskeR
VeskeR force-pushed the fix-ws-fast-fail-base-transport-fallback branch from 26e9bce to 2f34fdb Compare August 10, 2026 16:32
@VeskeR
VeskeR merged commit 4bbcc1a into main Aug 10, 2026
15 of 17 checks passed
@VeskeR
VeskeR deleted the fix-ws-fast-fail-base-transport-fallback branch August 10, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants