File size: 7,726 Bytes
de745bf b93f0d1 de745bf 1054555 de745bf b93f0d1 de745bf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from contextlib import asynccontextmanager
import asyncio
import time
from .config import settings
from .api.routes import router
from .api.jobs import router as jobs_router
from .auth import license_middleware
from .utils.logger import setup_logging
from .db import repo
# Setup structured logging
setup_logging()
# Rate limiter
limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
print(f"BRIDGE Modul - X v{settings.app_version} starting up...")
# Verify Supabase connectivity at boot (best-effort, never fatal)
if settings.supabase_configured:
try:
status = await repo.db_status()
if status.get("reachable"):
print(f"[DB] Supabase reachable β tables_ready={status.get('tables_ready')}")
if not status.get("tables_ready"):
print("[DB] WARNING: run supabase/schema.sql in the Supabase SQL editor "
"to create the tables.")
else:
print(f"[DB] WARNING: Supabase not reachable β {status.get('detail')}")
except Exception as exc: # pragma: no cover - defensive
print(f"[DB] WARNING: DB check failed β {exc}")
else:
print("[DB] Supabase not configured β persistence disabled (see .env.example)")
# Port-25 egress preflight: mailbox verification cannot work without it, so
# report it at boot instead of letting operators discover it from a wall of
# "smtp_unreachable" results. Best-effort and never fatal.
try:
from .validator.egress import preflight_or_warn
await asyncio.to_thread(preflight_or_warn)
except Exception as exc: # pragma: no cover - defensive
print(f"[EGRESS] preflight skipped β {exc}")
# Greylist retry-queue drain loop (in-process β the Space runs no Celery
# beat, so without this the queue would fill forever and greylisted
# mailboxes would stay permanently risky). Fail-open, never fatal.
drain_task = None
try:
from .tasks.drain_loop import DrainLoop
from .validator.engine import engine as _engine
async def _prober(email: str, mx_host: str):
return await _engine.mailbox.verify(email, mx_host)
loop = DrainLoop(prober=_prober, interval_seconds=300)
drain_task = asyncio.create_task(loop.start())
print("[RETRY] drain loop started (every 300s)")
except Exception as exc: # pragma: no cover - defensive
print(f"[RETRY] drain loop skipped β {exc}")
# Warm the advisory model at startup: the first request otherwise pays the
# torch + checkpoint load (measured 42.6s cold start) even though inference
# now runs off the event loop. Fail-open and never fatal.
try:
from .ml.runtime import get_model_async
model = await get_model_async()
if model is not None and getattr(model, "loaded", False):
print("[ML] advisory model preloaded")
else:
print("[ML] advisory model unavailable - deterministic mode")
except Exception as exc: # pragma: no cover - defensive
print(f"[ML] preload skipped β {exc}")
# Warm the LOCAL risk scorer at startup (Part B architecture): weights are
# baked into the image (HF_HOME=/app/models), so this is a fast disk-cache
# load (~seconds) with ZERO network. Without it, the first paying request
# would pay the model load. Fail-open: a failed load trips the circuit
# breaker inside the scorer and the heuristic takes over (spec Β§2.3).
try:
from .ml.risk_scorer import ai_scorer as _scorer
if _scorer._inference_configured():
await asyncio.to_thread(_scorer._ensure_loaded)
st = _scorer.status()
print(
f"[AI] local risk scorer preloaded: model={st['model']} "
f"loaded={st['model_loaded']} "
f"circuit_open={st['circuit']['open']}"
)
except Exception as exc: # pragma: no cover - defensive
print(f"[AI] risk scorer preload skipped β {exc}")
yield
if drain_task is not None:
drain_task.cancel()
print("BRIDGE Modul - X shutting down...")
# Create FastAPI app
app = FastAPI(
title=settings.app_name,
description="""
BRIDGE Modul - X β 7-Layer Email Validation Engine.
Features:
- Layer 1 β RFC 5321/5322 Syntax Validation
- Layer 2 β DNS Domain Existence Check
- Layer 3 β MX Record Verification
- Layer 4 β Disposable/Role-Based/Free Provider Detection
- Layer 5 β Catch-All Domain Detection
- Layer 6 β SMTP Mailbox Verification (with proxy support)
- Layer 7 β Deliverability Scoring & Risk Assessment
Provider support: Gmail, Outlook, Yahoo, iCloud, ProtonMail, Zoho, 100K+ domains
""",
version=settings.app_version,
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan
)
# ---- Middleware -----------------------------------------------------
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Process-Time"]
)
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.monotonic()
response = await call_next(request)
process_time = (time.monotonic() - start_time) * 1000
response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
return response
# Central Supabase JWT + license gate for paid API routes.
# /api/v1/health and /api/health stay open (HF + website productApi probe).
# No-op while LICENSE_ENFORCEMENT != "true" (demo mode). See app/auth.py.
@app.middleware("http")
async def license_gate(request: Request, call_next):
return await license_middleware(request, call_next)
# Global error handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc) if settings.debug else "Contact support"
}
)
# ---- Include Routes -------------------------------------------------
app.include_router(router, prefix="/api/v1")
# Server-driven bulk jobs (POST /api/v1/jobs): the engine finalizes the run
# with DB heartbeats so a closed browser tab can no longer orphan it.
app.include_router(jobs_router, prefix="/api/v1")
# ---- Health alias for the website productApi probe -------------------
# productApi tries GET <backend>/api/health then /health. The versioned
# route lives at /api/v1/health; this alias keeps the standard probe path
# working (stays open via the license_gate allow-list in app/auth.py).
@app.get("/api/health", tags=["System"])
async def api_health_alias():
import time as _time
return {
"status": "healthy",
"app": settings.app_name,
"version": settings.app_version,
"timestamp": _time.time(),
}
# ---- Root -----------------------------------------------------------
@app.get("/")
async def root():
return {
"app": settings.app_name,
"version": settings.app_version,
"docs": "/docs",
"health": "/api/v1/health"
}
|