Improve deployment safety and chat reliability
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s

This commit is contained in:
2026-08-29 17:28:50 +02:00
parent e9f12bc2ba
commit d974aaedc8
15 changed files with 746 additions and 153 deletions
+78 -6
View File
@@ -1,5 +1,6 @@
"""FluentGerman.ai — FastAPI application entry point."""
import asyncio
import logging
import time
from contextlib import asynccontextmanager
@@ -7,7 +8,8 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select
from sqlalchemy import inspect, select
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from app.auth import hash_password
from app.config import get_settings
@@ -30,11 +32,68 @@ logging.basicConfig(
logger = logging.getLogger("fluentgerman")
# A worker that loses the create_all race has to wait for the winner to finish
# creating the remaining tables before it can judge the schema complete.
SCHEMA_RETRIES = 10
SCHEMA_RETRY_DELAY = 0.5
def _is_table_already_exists(exc: Exception) -> bool:
"""True only for the specific DDL error that means another worker won.
Anything else — bad credentials, missing privileges, an unreachable
server — must not be mistaken for a race.
"""
orig = getattr(exc, "orig", None)
args = getattr(orig, "args", ())
if args and args[0] == 1050: # MySQL ER_TABLE_EXISTS_ERROR
return True
return "already exists" in str(orig or exc).lower()
async def _ensure_schema() -> None:
"""Create the tables and refuse to continue on a half-built schema.
An 'already exists' error aborts create_all part-way, so the losing worker
may still be missing tables the winner has not created yet. Retry rather
than treating that moment as a broken database.
"""
missing: list[str] = []
for attempt in range(SCHEMA_RETRIES):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
except (IntegrityError, OperationalError, ProgrammingError) as exc:
if not _is_table_already_exists(exc):
raise
logger.warning("Schema creation raced with another worker: %s", exc)
async with engine.connect() as conn:
present = await conn.run_sync(lambda c: set(inspect(c).get_table_names()))
missing = sorted(set(Base.metadata.tables) - present)
if not missing:
return
logger.info(
"Waiting for another worker to finish creating %s (attempt %d/%d)",
missing, attempt + 1, SCHEMA_RETRIES,
)
await asyncio.sleep(SCHEMA_RETRY_DELAY)
raise RuntimeError(f"Database schema is incomplete — missing tables: {missing}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Create tables and bootstrap admin user on startup."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
"""Create tables and bootstrap admin user on startup.
uvicorn runs this once per worker and the unit file starts two, so on an
empty database both can reach the same check at the same moment. Both
steps below are written to tolerate losing that race.
"""
await _ensure_schema()
# Bootstrap admin if not exists
settings = get_settings()
@@ -48,8 +107,21 @@ async def lifespan(app: FastAPI):
is_admin=True,
)
db.add(admin)
await db.commit()
logger.info("Admin user created: %s", settings.admin_username)
try:
await db.commit()
logger.info("Admin user created: %s", settings.admin_username)
except IntegrityError:
# Either another worker inserted the same admin, or the
# configured username/email is taken by a non-admin account.
await db.rollback()
result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
if result.scalar_one_or_none() is None:
logger.error(
"Cannot create admin '%s': the username or email already "
"belongs to a non-admin account.", settings.admin_username,
)
raise
logger.info("Admin user already created by another worker")
logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s",
settings.llm_provider, settings.llm_model, settings.voice_mode)
+3 -3
View File
@@ -7,7 +7,7 @@ from fastapi.responses import Response
from app.auth import require_admin, get_current_user
from app.config import get_settings
from app.models import User
from app.schemas import VoiceConfigOut, VoiceInstructionRequest
from app.schemas import SynthesizeRequest, VoiceConfigOut, VoiceInstructionRequest
from app.services.llm_service import summarize_instruction
from app.services.voice_service import synthesize, transcribe
@@ -48,12 +48,12 @@ async def transcribe_audio(
@router.post("/synthesize")
async def synthesize_text(
text: str,
body: SynthesizeRequest,
user: User = Depends(get_current_user),
):
"""Convert text to speech audio (API mode only)."""
try:
audio_bytes = await synthesize(text)
audio_bytes = await synthesize(body.text)
return Response(content=audio_bytes, media_type="audio/mpeg")
except Exception as e:
logger.error(f"Synthesis failed: {str(e)}", exc_info=True)
+4
View File
@@ -88,6 +88,10 @@ class VoiceInstructionRequest(BaseModel):
raw_text: str
class SynthesizeRequest(BaseModel):
text: str
class VoiceConfigOut(BaseModel):
voice_mode: str # "api" | "browser"
voice_api_available: bool = False # True if API STT (Whisper) is configured
+166
View File
@@ -0,0 +1,166 @@
"""FluentGerman.ai — Startup / lifespan tests.
The API tests drive the app through httpx's ASGITransport, which does NOT run
lifespan events — so table creation and the admin bootstrap in main.lifespan
were never exercised. The suite could stay green while the app failed to boot.
"""
import asyncio
import pytest
from sqlalchemy import func, inspect as sa_inspect, select
from sqlalchemy.exc import IntegrityError, OperationalError
from app import main
from app.config import get_settings
from app.database import Base
from app.models import User
from tests.conftest import test_engine as engine, test_session as session_factory
@pytest.fixture
def startup_app(monkeypatch):
"""Point main's module-level engine/session at the test database."""
monkeypatch.setattr(main, "engine", engine)
monkeypatch.setattr(main, "async_session", session_factory)
return main.app
@pytest.mark.asyncio
async def test_lifespan_creates_schema_and_bootstraps_admin(startup_app):
"""Starting from an empty database, startup must create tables and an admin."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
async with main.lifespan(startup_app):
pass
async with session_factory() as db:
result = await db.execute(select(User).where(User.is_admin.is_(True)))
admin = result.scalar_one_or_none()
assert admin is not None, "startup did not create an admin user"
assert admin.username == get_settings().admin_username
assert admin.hashed_password != get_settings().admin_password, "password stored in clear"
@pytest.mark.asyncio
async def test_lifespan_is_idempotent(startup_app):
"""A restart must not create a second admin."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
for _ in range(2):
async with main.lifespan(startup_app):
pass
async with session_factory() as db:
result = await db.execute(
select(func.count()).select_from(User).where(User.is_admin.is_(True))
)
assert result.scalar_one() == 1
@pytest.mark.asyncio
async def test_lifespan_survives_concurrent_workers(startup_app):
"""deploy/fluentgerman.service starts uvicorn with --workers 2, and each
worker runs this lifespan. On an empty database they race on the same
check-then-insert; neither worker may fail."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
async def boot():
async with main.lifespan(startup_app):
pass
await asyncio.gather(boot(), boot())
async with session_factory() as db:
result = await db.execute(
select(func.count()).select_from(User).where(User.is_admin.is_(True))
)
assert result.scalar_one() == 1, "concurrent startup created a duplicate admin"
@pytest.mark.asyncio
async def test_lifespan_reraises_real_ddl_failures(startup_app, monkeypatch):
"""Only a genuine 'table already exists' race may be swallowed — a
permission or connection failure has to stop the worker."""
def explode(*args, **kwargs):
raise OperationalError(
"CREATE TABLE users (...)", {},
Exception("(1142, \"CREATE command denied to user 'fluentgerman'\")"),
)
monkeypatch.setattr(main.Base.metadata, "create_all", explode)
with pytest.raises(OperationalError):
async with main.lifespan(startup_app):
pass
@pytest.mark.asyncio
async def test_lifespan_refuses_to_start_on_incomplete_schema(startup_app, monkeypatch):
"""If some tables are missing, starting anyway would leave the app broken
in a way the admin query alone would not notice."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0)
monkeypatch.setattr(main.Base.metadata, "create_all", lambda *a, **k: None)
with pytest.raises(RuntimeError, match="schema is incomplete"):
async with main.lifespan(startup_app):
pass
@pytest.mark.asyncio
async def test_lifespan_waits_out_a_partial_schema_race(startup_app, monkeypatch):
"""An 'already exists' error aborts create_all part-way, so the losing
worker can briefly see a schema the winner is still finishing. That is a
moment to retry, not a reason to refuse to start."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
real_create_all = main.Base.metadata.create_all
calls = {"n": 0}
def racing_create_all(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
# the winner got there first and we bailed out having created nothing
raise OperationalError(
"CREATE TABLE users (...)", {},
Exception("table 'users' already exists"),
)
return real_create_all(*args, **kwargs)
monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0)
monkeypatch.setattr(main.Base.metadata, "create_all", racing_create_all)
async with main.lifespan(startup_app):
pass
assert calls["n"] >= 2, "should have retried after the race"
async with engine.connect() as conn:
present = await conn.run_sync(lambda c: set(sa_inspect(c).get_table_names()))
assert set(Base.metadata.tables) <= present
@pytest.mark.asyncio
async def test_lifespan_fails_loudly_when_admin_name_is_taken(startup_app):
"""A non-admin already holding the configured username is not a lost race —
starting with no administrator at all would be worse than refusing."""
async with session_factory() as db:
db.add(User(
username=get_settings().admin_username,
email="someone-else@example.com",
hashed_password="x",
is_admin=False,
))
await db.commit()
with pytest.raises(IntegrityError):
async with main.lifespan(startup_app):
pass