Recover from a btcdata restart without reloading the page #2

Merged
magnus merged 1 commits from ws-reconnect into main 2026-10-11 07:26:42 +00:00
Owner

Problem

A btcdata restart left both panels frozen until the visitor reloaded the page.

htmx's WebSocket extension reconnects a browser socket only after close codes
1006/1011/1012/1013:

if (!maybeCloseWebSocketSource(socketElt) && [1006, 1011, 1012, 1013].indexOf(e.code) >= 0) { ... }

_ws_proxy closed the browser socket with a plain 1000 in its finally block on every
upstream exit, so htmx read the restart as a deliberate server shutdown and never reconnected.
A new browser connection always worked, which is why non-browser checks looked healthy and
hid the defect.

Change

_ws_proxy now reconnects upstream behind the still-open browser socket instead of tearing
the page's socket down:

  • bounded retry schedule (UPSTREAM_RETRY_DELAYS_SECONDS), reset after a connection that
    survived UPSTREAM_STABLE_SECONDS, so a long-lived page treats a later restart as a fresh
    outage rather than a continuing one
  • a concurrent reader task detects a departed browser — otherwise a dead upstream and a
    departed client are indistinguishable while the proxy is not sending
  • only once the schedule runs out does it close with 1012, handing recovery to htmx's own
    backoff; every server-side exit uses that code, so 1000 can no longer reach a live page
  • a payload that cannot be rendered is logged once per connection and skipped, rather than
    ending the stream

The transforms are untouched: price_json_to_html and strike_json_to_html are unchanged,
so the rendering contract consumed by rustbtcdata's frozen fixtures still holds.

Tests

tests/test_ws_proxy.py runs a real loopback WebSocket server as the stand-in btcdata, since
what is being tested is connection lifecycle rather than rendering:

  • same-socket recovery across an upstream restart, for /ws/price and /ws/strike
  • the retryable close code when the upstream never comes back, and when it is lost mid-stream
  • an unrenderable message does not end the stream

make test: 12 passed. All five new cases fail against the pre-fix proxy.

Verified end to end against the real Rust market-data backend and the real database: a held
proxy socket kept streaming across a genuine backend restart, and a fresh isolated Chrome
profile resumed price updates after the restart with browser_sockets: {"opened": 2, "closed": []} — no browser socket closed at all — and zero console, page or CDN errors.

Note on merging

Merging starts build.yml, the Harbor image publish and the Flux deployment. That is
intended: shipping this image is a recorded prerequisite for pointing production btcprice at
the Rust backend, because without it every backend restart freezes production panels.

🤖 Generated with Claude Code

## Problem A btcdata restart left both panels frozen until the visitor reloaded the page. htmx's WebSocket extension reconnects a browser socket only after close codes 1006/1011/1012/1013: ```js if (!maybeCloseWebSocketSource(socketElt) && [1006, 1011, 1012, 1013].indexOf(e.code) >= 0) { ... } ``` `_ws_proxy` closed the browser socket with a plain `1000` in its `finally` block on every upstream exit, so htmx read the restart as a deliberate server shutdown and never reconnected. A *new* browser connection always worked, which is why non-browser checks looked healthy and hid the defect. ## Change `_ws_proxy` now reconnects upstream behind the still-open browser socket instead of tearing the page's socket down: - bounded retry schedule (`UPSTREAM_RETRY_DELAYS_SECONDS`), reset after a connection that survived `UPSTREAM_STABLE_SECONDS`, so a long-lived page treats a later restart as a fresh outage rather than a continuing one - a concurrent reader task detects a departed browser — otherwise a dead upstream and a departed client are indistinguishable while the proxy is not sending - only once the schedule runs out does it close with `1012`, handing recovery to htmx's own backoff; every server-side exit uses that code, so `1000` can no longer reach a live page - a payload that cannot be rendered is logged once per connection and skipped, rather than ending the stream The transforms are untouched: `price_json_to_html` and `strike_json_to_html` are unchanged, so the rendering contract consumed by rustbtcdata's frozen fixtures still holds. ## Tests `tests/test_ws_proxy.py` runs a real loopback WebSocket server as the stand-in btcdata, since what is being tested is connection lifecycle rather than rendering: - same-socket recovery across an upstream restart, for `/ws/price` and `/ws/strike` - the retryable close code when the upstream never comes back, and when it is lost mid-stream - an unrenderable message does not end the stream `make test`: 12 passed. All five new cases fail against the pre-fix proxy. Verified end to end against the real Rust market-data backend and the real database: a held proxy socket kept streaming across a genuine backend restart, and a fresh isolated Chrome profile resumed price updates after the restart with `browser_sockets: {"opened": 2, "closed": []}` — no browser socket closed at all — and zero console, page or CDN errors. ## Note on merging Merging starts `build.yml`, the Harbor image publish and the Flux deployment. That is intended: shipping this image is a recorded prerequisite for pointing production btcprice at the Rust backend, because without it every backend restart freezes production panels. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
magnus added 1 commit 2026-10-11 07:22:58 +00:00
Recover from a btcdata restart without reloading the page
pull-request / validate (pull_request) Successful in 24s
4a2d8fabad
htmx's WebSocket extension reconnects only after close codes 1006/1011/1012/1013.
_ws_proxy closed the browser socket with a normal 1000 on every upstream exit, so
htmx read a btcdata restart as a deliberate shutdown and left both panels frozen
until the visitor reloaded. New browser connections always worked, which hid the
defect from non-browser checks.

The proxy now reconnects upstream behind the still-open browser socket on a bounded
schedule, resets that schedule after a connection that lasted long enough to count
as healthy, and closes with 1012 only once the schedule runs out, so recovery falls
back to htmx's own backoff. A concurrent reader detects a departed browser, which a
dead upstream would otherwise be indistinguishable from, and an unrenderable payload
is logged and skipped rather than ending the stream.

tests/test_ws_proxy.py covers this against a real loopback WebSocket server: same
socket recovery across an upstream restart for both streams, the retryable close
code when the upstream stays down, and skip-on-unrenderable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
magnus merged commit ac01388117 into main 2026-10-11 07:26:42 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: magnus/btcprice#2