A dropped connection used to hang the whole platform permanently, and three defects composed to do it (BUGS.md B-01): The read loop's death was invisible. When the socket closed, _read_loop broke out and finished, but _run_once was blocked on gather() over two notification consumers waiting on queues nobody would ever fill again — it never returned and never raised, so the reconnect-with-backoff logic was unreachable. client.wait_closed() now resolves when the loop ends for any reason, and _run_once races it against the consumers and a keepalive with asyncio.wait(FIRST_COMPLETED). Nothing had a timeout. request() registered a future, wrote to a half-closed socket (drain() often doesn't raise) and awaited a reply that would never come. That hung a POST /bets *while holding the per-user lock*, and could stop the confirmation poller for good. Every request is now bounded at 15s, and a timeout tears the connection down rather than leaving a server that owes us a reply in rotation. There was no keepalive, so on a quiet instance the normal way this connection dies is an idle-timeout drop by the server (~10 minutes for many). A server.ping every 60s makes that observable within a minute. listener.client is also cleared before reconnecting, so callers stop treating a dead connection as live. On top of the finding, the listener now rotates over a list of servers: ELECTRUM_FALLBACK_SERVERS holds comma-separated host:port[:notls] extras, tried after the primary. Everything the platform does goes through this one connection — deposit credits, broadcasts, confirmations, the chain tip the draw waits on — which made a single hardcoded server its biggest point of failure. A failed or dropped session moves to the next server immediately and only sleeps on the backoff once every server has had a turn, so one dead server costs one attempt instead of an outage, while a genuinely offline network still backs off. A malformed entry fails at startup, not during the outage when the fallback is what you need. Also fixes B-19: header handling refuses a height below the current tip and applies height and hex together, since _wait_for_next_block waits for tip_height > tip_at_close (a regression silently added a block to the draw's wait) and that hex is the draw's entropy source, so a mismatched pair would be worse than a stale one. Verified in the live deployment: the log shows the endpoint list, then "Electrum connected to santantonio.sytes.net:50002", and the connection holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
3.4 KiB
Python
77 lines
3.4 KiB
Python
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
|
|
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
|
|
# than being shipped by accident.
|
|
MIN_JWT_SECRET_LENGTH = 32
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
|
|
database_url: str = "sqlite+aiosqlite:///./plm_lottery.db"
|
|
|
|
electrum_host: str = "santantonio.sytes.net"
|
|
electrum_port: int = 50002
|
|
electrum_use_ssl: bool = True
|
|
# Additional servers to fall back to, comma-separated `host:port[:notls]`.
|
|
# The listener rotates over primary + these (app/electrum/listener.py), so one
|
|
# unreachable server costs a single reconnect attempt instead of an outage:
|
|
# every deposit credit, broadcast and confirmation goes through this one
|
|
# connection, which makes a single hardcoded server the platform's biggest
|
|
# single point of failure. Parsed by electrum.client.parse_endpoints.
|
|
electrum_fallback_servers: str = ""
|
|
|
|
xprv_encryption_key: str = ""
|
|
master_key_path: str = "./master.xprv.enc"
|
|
jwt_secret: str = ""
|
|
jwt_algorithm: str = "HS256"
|
|
jwt_expire_minutes: int = 60 * 24
|
|
admin_token: str = ""
|
|
|
|
# Every business/round parameter (bet amount, round duration/cooldown,
|
|
# min amount, fee rate, RBF timeout, fee address) lives in the round_config
|
|
# DB table instead (app/db/models.py RoundConfig, app/rounds/config.py) —
|
|
# editable live via the admin panel/API, no env var, no restart. Only true
|
|
# infra/secrets belong in this Settings class.
|
|
|
|
|
|
settings = Settings()
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""A misconfiguration serious enough that the app must refuse to serve."""
|
|
|
|
|
|
def validate_runtime_secrets(config: Settings | None = None) -> None:
|
|
"""Fail fast on secrets that would otherwise only break at first use: an empty
|
|
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
|
|
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
|
|
the container comes up looking healthy and breaks the moment a real user
|
|
touches it.
|
|
|
|
Called from the app's lifespan (app/main.py) rather than as a Settings
|
|
field_validator on purpose: Settings is constructed at import time by every
|
|
module that reads config, including the test suite, which has no .env and no
|
|
business holding real secrets. At startup the guarantee still holds where it
|
|
matters — the server refuses to serve half-configured — without coupling every
|
|
import to a gitignored file.
|
|
|
|
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
|
|
request when it's empty, so the effect is a locked admin panel, not an open one.
|
|
"""
|
|
config = config or settings
|
|
problems = []
|
|
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
|
|
problems.append(
|
|
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
|
|
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
|
|
)
|
|
if not config.xprv_encryption_key.strip():
|
|
problems.append(
|
|
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
|
|
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
|
)
|
|
if problems:
|
|
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
|