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>
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.venvand installsrequirements.txton first run (thevenvtarget, keyed offrequirements.txt)cp .env.example .env— optional;.envis-included, so a missing one is not fatalmake run-local— Same, but withBTCDATA_URL=$BTCDATA_LOCAL_URLmake tailwind— Watch Tailwind CSS for changesmake test— Installs test-only dependencies fromrequirements-dev.txtand runspytest 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→ remotedocker-compose.yml;.env.citadel→ remote.env.- btcdata also runs on citadel, so both attach to the external
btc-networkthere andBTCDATA_URLishttp://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, defaulthttp://localhost:4100) - No database — all data comes from btcdata
- Probes:
/api/healthis process-only liveness;/api/readychecks btcdata with a 3-second timeout;/api/versionexposes 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_proxyreconnects upstream behind the still-open browser socket, on the boundedUPSTREAM_RETRY_DELAYS_SECONDSschedule. An ordinary restart is invisible to the page — the last values stay on screen and updates resume.- A connection that lasted
UPSTREAM_STABLE_SECONDSresets 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:
- Browser connects via HTMX WebSocket to
/ws/priceor/ws/strike - btcprice proxies to btcdata's
/api/price/wsor/api/strike/ws - JSON from btcdata is transformed into HTML fragments (in
price_json_to_html/strike_json_to_html) - 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