Skip to content

fix(signaling): answer refused WebSocket upgrades with an HTTP status - #80

Draft
adamshiervani wants to merge 2 commits into
devfrom
fix/ws-upgrade-reject-status
Draft

adamshiervani wants to merge 2 commits into
devfrom
fix/ws-upgrade-reject-status

Conversation

@adamshiervani

@adamshiervani adamshiervani commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Prod reports almost as many 5xx as 2xx. The API itself is healthy: every HTTP route answers 2xx, 3xx, 401 or 404. The 5xx come from WebSocket upgrades that the signaling handlers refuse by calling socket.destroy() without writing a response. The edge sees the origin close the connection before a status line and records a 504.

Verified against prod with an HTTP/1.1 upgrade request:

Request Log line Edge status
Device upgrade with a bad token [Device] Invalid secret token provided. 504
Client upgrade without a session [Client] No authentication token. 504

The volume comes from device firmware. It retries the cloud connection every 5 seconds with no backoff and cannot tell a revoked token from a network error, because it never receives a status code. Each device holding a stale token, for example one deleted from the dashboard or re-registered while an old install kept the old token, produces one 504 every 5 seconds until it is reflashed.

Change

  • Add rejectUpgrade(socket, status) in src/webrtc-signaling.ts. It writes a minimal HTTP response, ends the socket, and destroys it after the response is flushed, the same sequence ws uses in abortHandshake.
  • Use it on every refused upgrade:
    • unknown path: 404
    • device with no token, unknown token, or mismatched device id: 401
    • device reconnecting while a session is in flight: 409
    • client with no session: 401
    • client naming a device it does not own, or that is offline: 404
    • unexpected error in either handler, including a failed database lookup: 500
  • authenticateDeviceRequest no longer swallows database errors. Before, a pool timeout or outage during the token lookup returned null, which would now read as a bad token. It propagates to the handler's catch and answers 500, so 401 on the device path means the token itself can never authenticate.
  • authenticateClientRequest now returns the status to answer with, so the handler can tell a missing session (401) from a device the user cannot reach (404).

After this ships, the edge reports 401 and 404 for these requests instead of 504.

Follow-up in the firmware

The device side still retries every 5 seconds. runWebsocketClient in kvm/cloud.go should back off exponentially on a 401 from the dial, capped at a few minutes. With this change a 401 means the token is unknown or bound to another device id, and a 5xx means try again later. Backing off rather than clearing the token keeps a device recoverable if the cloud ever answers 401 by mistake.

Tests

test/webrtc-signaling.test.ts starts the router on a real HTTP server and sends upgrade requests with Node's HTTP client. The client rejects when the server closes the socket without a response, so the rejection tests fail on the old code and pass on this change. The happy path checks that a valid device token still completes the upgrade and registers the connection. A failed token lookup is simulated by wrapping the Prisma client, since its model delegates are proxies that vi.spyOn cannot patch.

🤖 Generated with Claude Code


Note

Medium Risk
Changes WebSocket upgrade auth and error semantics on a production device connection path; misclassified statuses could affect retry behavior until firmware backoff lands.

Overview
Fixes inflated edge 504 rates when WebRTC upgrades are refused: handlers now send a proper HTTP status before closing the socket instead of socket.destroy() with no response.

Adds rejectUpgrade and uses it for unknown paths (404), bad device tokens (401), in-flight conflicts (409), missing client sessions (401), unreachable or unauthorized devices (404), and handler/DB failures (500). Device auth no longer maps Prisma lookup failures to 401—those bubble to 500 so firmware can treat them as transient. Client auth now returns the status to use (401 vs 404) instead of a bare null device id.

New test/webrtc-signaling.test.ts exercises upgrade rejections and a successful device upgrade against a real HTTP server.

Reviewed by Cursor Bugbot for commit 1867d90. Bugbot is set up for automated code reviews on this repo. Configure here.

Refused upgrades called socket.destroy() without a response, so the edge
reported each one as a 504. Devices with a revoked token retry every 5
seconds, which made 5xx nearly match 2xx on prod.

rejectUpgrade() writes a minimal response, ends the socket and destroys it
once flushed. Bad or missing device tokens get 401, an unknown path or an
unreachable device gets 404, a reconnect during signaling gets 409 and
handler errors get 500.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 480a8cc. Configure here.

Comment thread src/webrtc-signaling.ts
socket.end(
`HTTP/1.1 ${status} ${STATUS_CODES[status]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Upgrade reject can crash process

High Severity

rejectUpgrade calls socket.end on an upgrade socket without an error listener. Node removes its default handler when it emits upgrade, so a failed write such as EPIPE becomes an uncaught exception and can take down the process. That is likely here: stale-token devices retry every 5 seconds and often drop before the status line is flushed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 480a8cc. Configure here.

authenticateDeviceRequest caught database errors and returned null, which
the handler now reports as 401. A pool timeout or outage would then look
like a revoked token to every device. Let the error propagate so the
handler answers 500 and 401 only means the token can never authenticate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant