pull-request / validate (pull_request) Successful in 24s
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>
109 lines
5.7 KiB
Markdown
109 lines
5.7 KiB
Markdown
# 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 `-include`d, 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
|