Refuse to start half-configured, and keep failures machine-readable

Startup validation (B-15). 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 came up looking healthy and broke the
moment a real user touched it. validate_runtime_secrets() reports every problem
at once and is called from the lifespan (wired in the next commit).

Deviation from the plan in BUGS.md, which proposed a Pydantic field_validator:
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
— a validator there would fail a fresh clone at collection. At startup the
guarantee that matters is unchanged (the server won't serve traffic
half-configured) without coupling imports to a gitignored file. An empty
ADMIN_TOKEN is deliberately non-fatal: require_admin already denies everything,
so the effect is a locked panel, not an open one.

Unhandled errors answer the documented shape (B-24). The catch-all returned a
bare-string `detail` while app/api/errors.py documents
{"code", "message", "params"}, leaving clients to special-case exactly the
responses they understand least. It now returns internal_error in that shape,
with the exception text staying in logs/app.log and out of the response body.

GET /guida no longer crashes (B-16). It reads docs/guida-utente.md, which the
Dockerfile doesn't ship, so in every real deployment that navbar link was a 500 —
confirmed in the deployed log, which holds two of them from earlier today ending
in "RuntimeError: File at path docs/guida-utente.md does not exist." It now
checks the file and answers a structured 404 (guide_unavailable) with an error
logged. Shipping docs/ in the image was written and then reverted on request: the
guide is being reworked first, so /guida answers 404 in Docker for now, which is
an accepted state rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:34:33 +02:00
co-authored by Claude Opus 5
parent 85dce221c5
commit 25f4a1c6b6
3 changed files with 113 additions and 5 deletions
+20 -5
View File
@@ -1,8 +1,9 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -20,7 +21,8 @@ from app.api.routes.rounds import router as rounds_router
from app.api.routes.users import router as users_router
from app.api.routes.withdrawals import router as withdrawals_router
from app.auth.routes import router as auth_router
from app.config import settings
from app.api.errors import ApiError, http_error
from app.config import settings, validate_runtime_secrets
from app.db.base import AsyncSessionLocal
from app.electrum.client import ElectrumClient
from app.electrum.listener import ElectrumListener
@@ -74,7 +76,10 @@ app.include_router(rounds_router)
@app.exception_handler(Exception)
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "internal server error"})
return JSONResponse(
status_code=500,
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
)
@app.get("/health")
@@ -99,8 +104,18 @@ async def admin_panel() -> FileResponse:
async def user_guide() -> FileResponse:
"""Serves docs/guida-utente.md as plain text so it opens inline in the
browser (no markdown rendering — keeps this simple), linked from the
navbar's help button in app/static/index.html."""
return FileResponse("docs/guida-utente.md", media_type="text/plain; charset=utf-8")
navbar's help button in app/static/index.html.
The Dockerfile must COPY docs/ for this to exist inside the container (it
didn't, which made this a guaranteed 500 in every real deployment — B-16).
Answers a clean 404 if the file is missing rather than an unhandled error."""
if not _USER_GUIDE_PATH.is_file():
logger.error("user guide missing at %s (is docs/ shipped in the image?)", _USER_GUIDE_PATH)
raise http_error(status.HTTP_404_NOT_FOUND, "guide_unavailable", "user guide not available")
return FileResponse(_USER_GUIDE_PATH, media_type="text/plain; charset=utf-8")
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
_USER_GUIDE_PATH = Path("docs/guida-utente.md")