feat: add dependency readiness checks

This commit is contained in:
ik
2026-09-04 07:38:34 +07:00
parent d131662d5f
commit 3b26c59219
9 changed files with 179 additions and 11 deletions
+1
View File
@@ -14,6 +14,7 @@ class Settings(BaseSettings):
official_records_url: str = "https://rf4game.de/records/region/RU/"
official_records_region: str = "RU"
official_records_category: str = "records"
official_import_required: bool = False
import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+15 -1
View File
@@ -10,6 +10,7 @@ from uuid import UUID
import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import delete, func, select, text
from sqlalchemy.orm import Session, joinedload
@@ -19,8 +20,9 @@ from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
from .importer import ImportSourceError, import_records, normalize
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, delete_screenshot, signed_screenshot_url, upload_screenshot
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
@@ -38,6 +40,18 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/ready")
def ready(db: Db) -> JSONResponse:
is_ready, components = readiness_report(
db, storage_client(), import_required=settings.official_import_required,
import_interval_seconds=settings.import_interval_seconds,
)
return JSONResponse(
status_code=200 if is_ready else 503,
content={"status": "ready" if is_ready else "not_ready", "components": components},
)
@app.get("/api/v1/fishes", response_model=list[FishOut])
def fishes(db: Db) -> list[Fish]:
return list(db.scalars(select(Fish).order_by(Fish.name_ru)))
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from .models import ImportStatus, OfficialRecordImport
def readiness_report(
session: Session, s3: Any, *, import_required: bool,
import_interval_seconds: int, now: datetime | None = None,
) -> tuple[bool, dict[str, dict[str, object]]]:
current = now or datetime.now(timezone.utc)
components: dict[str, dict[str, object]] = {}
ready = True
try:
session.execute(text("SELECT 1"))
components["postgresql"] = {"status": "ready"}
except Exception:
components["postgresql"] = {"status": "unavailable"}
ready = False
try:
s3.list_buckets()
components["minio"] = {"status": "ready"}
except Exception:
components["minio"] = {"status": "unavailable"}
ready = False
try:
latest = session.scalar(select(OfficialRecordImport).order_by(
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc(),
).limit(1))
if not import_required:
components["official_import"] = {
"status": "optional",
"last_run_status": latest.status.value if latest else None,
}
elif latest is None:
components["official_import"] = {"status": "not_run"}
ready = False
else:
started = latest.started_at if latest.started_at.tzinfo else latest.started_at.replace(tzinfo=timezone.utc)
stale = started < current - timedelta(seconds=import_interval_seconds * 2)
healthy = latest.status == ImportStatus.success and not stale
components["official_import"] = {
"status": "ready" if healthy else ("stale" if stale else latest.status.value),
"last_run_status": latest.status.value,
"last_started_at": started.isoformat(),
}
ready = ready and healthy
except Exception:
components["official_import"] = {"status": "unknown"}
if import_required:
ready = False
return ready, components
+4
View File
@@ -55,6 +55,10 @@ def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
def test_liveness_does_not_probe_dependencies() -> None:
assert client.get("/health").json() == {"status": "ok"}
def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}")
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import ImportStatus, OfficialRecordImport
from app.readiness import readiness_report
class AvailableStorage:
def list_buckets(self) -> dict[str, list[object]]:
return {"Buckets": []}
class UnavailableStorage:
def list_buckets(self) -> None:
raise ConnectionError("fixture unavailable")
def test_optional_import_does_not_block_dependencies() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
ready, components = readiness_report(
session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
)
assert ready is True
assert components == {
"postgresql": {"status": "ready"},
"minio": {"status": "ready"},
"official_import": {"status": "optional", "last_run_status": None},
}
def test_required_import_must_be_recent_and_successful() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(OfficialRecordImport(
started_at=now - timedelta(minutes=30), finished_at=now - timedelta(minutes=29),
status=ImportStatus.success, source_url="fixture://records", rows_seen=1,
rows_created=1, rows_updated=0,
))
session.commit()
ready, components = readiness_report(
session, AvailableStorage(), import_required=True,
import_interval_seconds=3600, now=now,
)
assert ready is True
assert components["official_import"]["status"] == "ready"
def test_unavailable_storage_and_stale_import_fail_readiness() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(OfficialRecordImport(
started_at=now - timedelta(hours=3), finished_at=now - timedelta(hours=3),
status=ImportStatus.success, source_url="fixture://records", rows_seen=1,
rows_created=1, rows_updated=0,
))
session.commit()
ready, components = readiness_report(
session, UnavailableStorage(), import_required=True,
import_interval_seconds=3600, now=now,
)
assert ready is False
assert components["minio"]["status"] == "unavailable"
assert components["official_import"]["status"] == "stale"