Files
btcprice/AGENTS.md
T
magnusandClaude Opus 5 4a2d8fabad
pull-request / validate (pull_request) Successful in 24s
Recover from a btcdata restart without reloading the page
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>
2026-10-11 09:21:19 +02:00

5.7 KiB

AGENTS.md

Bitcoin price tracker UI — Python FastAPI web application. Serves the HTML/Tailwind UI and proxies live price data from btcdata over WebSocket.

Build & Development Commands

  • make run — Start dev server on port 4000; creates .venv and installs requirements.txt on first run (the venv target, keyed off requirements.txt)
  • cp .env.example .env — optional; .env is -included, so a missing one is not fatal
  • make run-local — Same, but with BTCDATA_URL=$BTCDATA_LOCAL_URL
  • make tailwind — Watch Tailwind CSS for changes
  • make test — Installs test-only dependencies from requirements-dev.txt and runs pytest tests/
  • make venv — Bootstrap the virtualenv without starting the server

Docker

Docker runs alongside local dev on different ports:

Service Local dev Docker
btcprice localhost:4000 localhost:4041 (container port 4040)
btcdata localhost:4100 localhost:4102

docker compose up -d here starts btcprice only. Both projects attach to the external btc-network (docker network create btc-network once), and btcdata must be started from ../btcdata. Inside the network btcprice reaches it at http://btcdata:${DOCKER_BTCDATA_PORT:-4102}.

The image does not set BTCDATA_URL; deployments must provide it. BUILD_SHA is embedded as APP_VERSION and exposed at /api/version. The port in CMD is hardcoded to 4040.

CI/CD and production

.gitea/workflows/build.yml tests pushes to main and v* tags on the anvil Gitea Actions runner, then publishes immutable linux/amd64 images to harbor.pmagnus.cloud/btcprice/btcprice. It embeds the short Git SHA as APP_VERSION.

Flux image automation updates the manifests in the separate magnus/deploy repository and Flux reconciles k3s. CI has no Kubernetes credentials. Production runs two replicas—one on hostinger and one on citadel—and is served at https://btcprice.k8s.pmagnus.cloud through ingress-nginx and hostinger Traefik TLS. Deployment changes belong in ../deploy/apps/btcprice; do not deploy with Compose or manual kubectl apply.

Legacy deployment

./deploy-citadel.sh — build locally for the remote's arch, docker save | ssh docker load, docker compose up -d --no-build in /home/pmp/apps/btcprice on citadel (192.168.50.195). Mirrors ../elprice/deploy-citadel.sh; no registry.

  • docker-compose.citadel.yml → remote docker-compose.yml; .env.citadel → remote .env.
  • btcdata also runs on citadel, so both attach to the external btc-network there and BTCDATA_URL is http://btcdata:4102 (service name, not a LAN address). The deploy script creates the network on the remote if it is missing.
  • Temporary migration fallback at http://192.168.50.195:4041; it is not the production endpoint.

Architecture

  • Framework: FastAPI
  • Server: Uvicorn
  • Templates: Inline HTML strings + HTMX (WebSocket extension)
  • Styling: Tailwind CSS v4
  • Data backend: btcdata (separate service at BTCDATA_URL, default http://localhost:4100)
  • No database — all data comes from btcdata
  • Probes: /api/health is process-only liveness; /api/ready checks btcdata with a 3-second timeout; /api/version exposes the build identifier

btcdata connection

The browser connects only to btcprice: ws-connect="/ws/price" and ws-connect="/ws/strike". Each endpoint runs _ws_proxy, which opens an upstream WebSocket to btcdata's /api/price/ws and /api/strike/ws, converts each JSON message to an HTML fragment (price_json_to_html, strike_json_to_html) and forwards it. BTCDATA_URL is the single point of contact; _btcdata_ws_url rewrites its scheme to ws/wss.

Recovering from a btcdata restart

htmx's WebSocket extension reconnects only after an abnormal close (1006/1011/1012/1013); a normal 1000 close means "the server is done" and the panel stays frozen until the visitor reloads. So a btcdata restart must never reach the browser as a normal close:

  • _ws_proxy reconnects upstream behind the still-open browser socket, on the bounded UPSTREAM_RETRY_DELAYS_SECONDS schedule. An ordinary restart is invisible to the page — the last values stay on screen and updates resume.
  • A connection that lasted UPSTREAM_STABLE_SECONDS resets that schedule, so a long-lived page treats a later restart as a fresh outage rather than a continuing one.
  • When the schedule runs out, the proxy closes with BROWSER_RETRY_CLOSE_CODE (1012) and htmx's own backoff takes over. Every server-side exit uses that code; only a browser that has already left can see a plain close.
  • A payload that cannot be rendered is logged and skipped, not treated as a dead stream.

tests/test_ws_proxy.py covers this against a real loopback WebSocket server, including same-socket recovery across an upstream restart and the retryable close code. The regression to watch for is a 1000 close after upstream loss.

Source Layout

btcprice acts as a WebSocket proxy between the browser and btcdata:

  1. Browser connects via HTMX WebSocket to /ws/price or /ws/strike
  2. btcprice proxies to btcdata's /api/price/ws or /api/strike/ws
  3. JSON from btcdata is transformed into HTML fragments (in price_json_to_html / strike_json_to_html)
  4. HTMX swaps the HTML into the DOM by matching element IDs (price-display, strike-display)

Key Conventions

  • Numbers use Danish locale formatting (dots for thousands, commas for decimals) via da_fmt()
  • Price changes are color-coded: green (up), red (down), gray (unchanged)
  • All HTML is built as string concatenation in Python — no template engine