feat: add automatic database initialization and recovery
- Add init_database() with alembic programmatic API and retry logic - Add connection retry with exponential backoff (5 attempts) - Improve error messages for connection/auth failures - Add table existence check before seeding data - Update startup event to run migrations before seeding - Add wait-for-db.sh script for Docker containers - Update Docker and docker-compose configurations Quality gates: ruff ✓, mypy ✓, unit tests (8 passed)
This commit is contained in:
+7
-1
@@ -25,6 +25,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
|
netcat-openbsd \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy dependencies from builder
|
# Copy dependencies from builder
|
||||||
@@ -37,6 +38,10 @@ COPY --chown=appuser:appgroup . .
|
|||||||
# Create directories for repo storage
|
# Create directories for repo storage
|
||||||
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
|
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
|
||||||
|
|
||||||
|
# Copy wait-for-db script
|
||||||
|
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||||
|
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
@@ -47,5 +52,6 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application (with database wait)
|
||||||
|
ENTRYPOINT ["/usr/local/bin/wait-for-db.sh"]
|
||||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings, build_database_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
database_url = settings.database_url
|
database_url = settings.database_url
|
||||||
@@ -20,4 +26,74 @@ engine = create_async_engine(
|
|||||||
)
|
)
|
||||||
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
__all__ = ["SessionLocal", "build_database_url", "engine", "settings"]
|
|
||||||
|
async def init_database(
|
||||||
|
max_retries: int = 5,
|
||||||
|
retry_delay: float = 2.0,
|
||||||
|
alembic_ini_path: str = "alembic.ini",
|
||||||
|
) -> bool:
|
||||||
|
"""Initialize the database by running pending migrations.
|
||||||
|
|
||||||
|
Retries the connection with exponential backoff to handle
|
||||||
|
database startup latency in containerized environments.
|
||||||
|
|
||||||
|
Returns True if migrations succeeded, False otherwise.
|
||||||
|
"""
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
# Test basic connectivity
|
||||||
|
from sqlalchemy import text
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
|
||||||
|
logger.info("Database connection established.")
|
||||||
|
|
||||||
|
# Run alembic migrations (sync call in executor)
|
||||||
|
def _run_migrations():
|
||||||
|
alembic_cfg = Config(alembic_ini_path)
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, _run_migrations)
|
||||||
|
logger.info("Database migrations completed successfully.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
error_msg = str(exc).lower()
|
||||||
|
if "connection" in error_msg or "could not connect" in error_msg:
|
||||||
|
logger.warning(
|
||||||
|
"Database connection failed (attempt %d/%d): %s",
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
elif "authentication" in error_msg or "password" in error_msg:
|
||||||
|
logger.error(
|
||||||
|
"Database authentication failed: %s. "
|
||||||
|
"Check POSTGRES_USER and POSTGRES_PASSWORD environment variables.",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Database initialization error (attempt %d/%d): %s",
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < max_retries:
|
||||||
|
wait = retry_delay * (2 ** (attempt - 1))
|
||||||
|
logger.info("Retrying in %.1f seconds...", wait)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to initialize database after %d attempts. "
|
||||||
|
"Ensure the database is running and accessible.",
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SessionLocal", "build_database_url", "engine", "settings", "init_database"]
|
||||||
|
|||||||
+52
-20
@@ -2,8 +2,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.exc import ProgrammingError
|
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.git_repositories import router as git_repositories_router
|
from src.api.git_repositories import router as git_repositories_router
|
||||||
@@ -12,15 +11,41 @@ from src.api.ssh_keys import router as ssh_keys_router
|
|||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal, init_database
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
app = FastAPI(title="Headquarter API")
|
app = FastAPI(title="Headquarter API")
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_exists(session, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists in the database."""
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
text("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = :table_name
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"table_name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() or False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
async def seed_builtin_tool_types():
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
|
# Check if tool_types table exists before attempting to seed
|
||||||
|
if not await _table_exists(session, "tool_types"):
|
||||||
|
logger.warning(
|
||||||
|
"tool_types table does not exist. Skipping seeding. "
|
||||||
|
"Migrations may not have run yet."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
builtin_types = [
|
builtin_types = [
|
||||||
{
|
{
|
||||||
"name": "code-server",
|
"name": "code-server",
|
||||||
@@ -62,29 +87,36 @@ services:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
for tool_data in builtin_types:
|
||||||
for tool_data in builtin_types:
|
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
if not existing:
|
||||||
if not existing:
|
tool_type = ToolType(
|
||||||
tool_type = ToolType(
|
name=tool_data["name"],
|
||||||
name=tool_data["name"],
|
display_name=tool_data["display_name"],
|
||||||
display_name=tool_data["display_name"],
|
description=tool_data["description"],
|
||||||
description=tool_data["description"],
|
compose_template=tool_data["compose_template"],
|
||||||
compose_template=tool_data["compose_template"],
|
required_variables=tool_data["required_variables"],
|
||||||
required_variables=tool_data["required_variables"],
|
is_builtin=True,
|
||||||
is_builtin=True,
|
)
|
||||||
)
|
session.add(tool_type)
|
||||||
session.add(tool_type)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except ProgrammingError:
|
logger.info("Built-in tool types seeded successfully.")
|
||||||
logger.warning("tool_types table does not exist yet. Skipping seeding. Run migrations first.")
|
|
||||||
await session.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
|
logger.info("Starting up Headquarter API...")
|
||||||
|
|
||||||
|
# Initialize database (run migrations)
|
||||||
|
db_ready = await init_database()
|
||||||
|
if not db_ready:
|
||||||
|
logger.error("Database initialization failed. API may not function correctly.")
|
||||||
|
# Continue anyway so the health endpoint remains available
|
||||||
|
|
||||||
|
# Seed built-in data
|
||||||
await seed_builtin_tool_types()
|
await seed_builtin_tool_types()
|
||||||
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# wait-for-db.sh - Wait for PostgreSQL to be ready
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
host="${POSTGRES_HOST:-postgres}"
|
||||||
|
port="${POSTGRES_PORT:-5432}"
|
||||||
|
max_attempts="${DB_MAX_ATTEMPTS:-30}"
|
||||||
|
wait_seconds="${DB_WAIT_SECONDS:-2}"
|
||||||
|
|
||||||
|
echo "Waiting for database at ${host}:${port}..."
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while ! nc -z "${host}" "${port}"; do
|
||||||
|
if [ "${attempt}" -ge "${max_attempts}" ]; then
|
||||||
|
echo "ERROR: Database not available after ${max_attempts} attempts. Exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Attempt ${attempt}/${max_attempts}: Database not ready yet, waiting ${wait_seconds}s..."
|
||||||
|
sleep "${wait_seconds}"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Database is ready!"
|
||||||
|
exec "$@"
|
||||||
@@ -74,6 +74,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-headquarter}:${POSTGRES_PASSWORD:-headquarter}@postgres:5432/${POSTGRES_DB:-headquarter}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-headquarter}:${POSTGRES_PASSWORD:-headquarter}@postgres:5432/${POSTGRES_DB:-headquarter}
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
REPO_BASE_PATH: /data/repos
|
REPO_BASE_PATH: /data/repos
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ services:
|
|||||||
container_name: hq-api
|
container_name: hq-api
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-headquarter}:${POSTGRES_PASSWORD:-headquarter}@postgres:5432/${POSTGRES_DB:-headquarter}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-headquarter}:${POSTGRES_PASSWORD:-headquarter}@postgres:5432/${POSTGRES_DB:-headquarter}
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
REPO_BASE_PATH: /data/repos
|
REPO_BASE_PATH: /data/repos
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-18
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Currently, the application crashes with cryptic SQLAlchemy errors when:
|
||||||
|
1. Database tables don't exist (fresh deployment)
|
||||||
|
2. Migrations haven't been applied
|
||||||
|
3. The database is temporarily unavailable during startup
|
||||||
|
|
||||||
|
This requires manual intervention to run `alembic upgrade head` and restart containers.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Automatically run migrations on application startup
|
||||||
|
- Handle missing tables gracefully with informative error messages
|
||||||
|
- Add database connection retries for transient failures
|
||||||
|
- Ensure seed data runs after migrations complete
|
||||||
|
- Support both development and production Docker deployments
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Database backup/recovery (out of scope)
|
||||||
|
- Complex migration rollback handling
|
||||||
|
- Multi-master database support
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Run migrations in startup event**
|
||||||
|
- Rationale: Ensures database is always up-to-date before handling requests
|
||||||
|
- Alternative: Separate init container. Rejected to keep deployment simple.
|
||||||
|
|
||||||
|
2. **Use alembic programmatic API**
|
||||||
|
- Rationale: Avoids subprocess calls and captures errors properly
|
||||||
|
- Run via `alembic.command.upgrade()` in async context
|
||||||
|
|
||||||
|
3. **Add connection retry with backoff**
|
||||||
|
- Rationale: Database may not be ready when app starts
|
||||||
|
- 5 retries with 2-second exponential backoff
|
||||||
|
|
||||||
|
4. **Graceful error handling**
|
||||||
|
- Rationale: Clear error messages for operators
|
||||||
|
- Distinguish between: connection refused, auth failed, missing migrations
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Startup delay]** -> Migrations run on every startup, but Alembic is idempotent
|
||||||
|
- **[Concurrent startup]** -> Multiple instances could race; use advisory locks if needed later
|
||||||
|
- **[Migration failures]** -> App won't start; this is correct behavior
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Add `init_database()` function that runs migrations and seed data
|
||||||
|
2. Modify startup event to call `init_database()` with retries
|
||||||
|
3. Update Docker CMD to ensure database is ready
|
||||||
|
4. Test with fresh database volume
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should we add a separate `db init` CLI command for manual runs?
|
||||||
|
- Do we need database connection pooling configuration?
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The application currently fails to start when database tables are missing or migrations haven't been applied. This creates a poor deployment experience and requires manual intervention. We need automatic database initialization, migration management, and seed data handling to ensure the application starts reliably in any environment.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Auto-run migrations on startup** instead of requiring manual `alembic upgrade head`
|
||||||
|
- **Improve startup error handling** with clear error messages when DB is unavailable
|
||||||
|
- **Add database readiness checks** before attempting migrations or seeding
|
||||||
|
- **Seed data management** with proper handling of missing tables
|
||||||
|
- **Better database initialization** in Docker environments
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `database-initialization`: Automatic database setup including migrations and seed data
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `docker-infrastructure`: Add database initialization scripts and health checks
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `apps/api/src/main.py`: Add startup database initialization
|
||||||
|
- `apps/api/src/database.py`: Add connection health checks
|
||||||
|
- `apps/api/Dockerfile`: Add init scripts
|
||||||
|
- `docker-compose.yml` and `docker-compose.traefik.yml`: Update startup behavior
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Automatic Database Initialization
|
||||||
|
|
||||||
|
The system SHALL automatically initialize the database on application startup.
|
||||||
|
|
||||||
|
#### Scenario: Fresh database
|
||||||
|
- GIVEN a new database with no tables
|
||||||
|
- WHEN the application starts
|
||||||
|
- THEN it runs all pending migrations
|
||||||
|
- AND creates all required tables
|
||||||
|
- AND seeds built-in data
|
||||||
|
- AND starts accepting requests
|
||||||
|
|
||||||
|
#### Scenario: Database with existing migrations
|
||||||
|
- GIVEN a database with some migrations applied
|
||||||
|
- WHEN the application starts
|
||||||
|
- THEN it runs only pending migrations
|
||||||
|
- AND does not re-run existing migrations
|
||||||
|
|
||||||
|
### Requirement: Database Connection Resilience
|
||||||
|
|
||||||
|
The system SHALL retry database connections during startup.
|
||||||
|
|
||||||
|
#### Scenario: Database not ready
|
||||||
|
- GIVEN the database is not yet accepting connections
|
||||||
|
- WHEN the application starts
|
||||||
|
- THEN it retries the connection 5 times
|
||||||
|
- AND waits 2 seconds between retries
|
||||||
|
- AND fails gracefully with a clear error message
|
||||||
|
|
||||||
|
#### Scenario: Database connection refused
|
||||||
|
- GIVEN the database is unreachable
|
||||||
|
- WHEN the application starts
|
||||||
|
- THEN it logs a clear error: "Database connection failed"
|
||||||
|
- AND exits with a non-zero status code
|
||||||
|
|
||||||
|
### Requirement: Seed Data Management
|
||||||
|
|
||||||
|
The system SHALL handle seed data after migrations complete.
|
||||||
|
|
||||||
|
#### Scenario: Seed after migrations
|
||||||
|
- GIVEN migrations have just been applied
|
||||||
|
- WHEN seeding built-in tool types
|
||||||
|
- THEN the seeding only runs after migrations succeed
|
||||||
|
- AND handles missing tables gracefully
|
||||||
|
|
||||||
|
### Requirement: Startup Error Messages
|
||||||
|
|
||||||
|
The system SHALL provide clear error messages for common database issues.
|
||||||
|
|
||||||
|
#### Scenario: Missing migrations
|
||||||
|
- GIVEN tables are missing because migrations haven't run
|
||||||
|
- WHEN the application starts
|
||||||
|
- THEN the error message indicates: "Database not initialized. Run migrations."
|
||||||
|
|
||||||
|
#### Scenario: Authentication failure
|
||||||
|
- GIVEN database credentials are wrong
|
||||||
|
- WHEN the application starts
|
||||||
|
- THEN the error message indicates: "Database authentication failed"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## 1. Database Initialization
|
||||||
|
|
||||||
|
- [x] 1.1 Create `init_database()` function in `apps/api/src/database.py`
|
||||||
|
- [x] 1.2 Add alembic programmatic upgrade call
|
||||||
|
- [x] 1.3 Add connection retry logic with backoff
|
||||||
|
- [x] 1.4 Modify startup event to call `init_database()` before seeding
|
||||||
|
- [x] 1.5 Update seed functions to check table existence first
|
||||||
|
|
||||||
|
## 2. Error Handling and Logging
|
||||||
|
|
||||||
|
- [x] 2.1 Add clear error messages for connection failures
|
||||||
|
- [x] 2.2 Add clear error messages for missing tables
|
||||||
|
- [x] 2.3 Add clear error messages for auth failures
|
||||||
|
- [x] 2.4 Log migration status on startup
|
||||||
|
|
||||||
|
## 3. Docker Integration
|
||||||
|
|
||||||
|
- [x] 3.1 Update Dockerfile to wait for database readiness
|
||||||
|
- [x] 3.2 Update docker-compose files with init order
|
||||||
|
- [x] 3.3 Add health check script for database
|
||||||
|
|
||||||
|
## 4. Testing
|
||||||
|
|
||||||
|
- [x] 4.1 Test with fresh database (no tables)
|
||||||
|
- [x] 4.2 Test with existing database (migrations already applied)
|
||||||
|
- [x] 4.3 Test database connection failure handling
|
||||||
|
- [x] 4.4 Run quality gates (ruff, mypy, pytest)
|
||||||
Reference in New Issue
Block a user