mirrors / api.py
hoekashinov's picture
Upload 1011 files
4f625a6 verified
Raw
History Blame Contribute Delete
19.8 kB
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, Request, HTTPException, Response, Depends, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from core.services import redis
from core.utils.openapi_config import configure_openapi
import sentry
from contextlib import asynccontextmanager
from core.agentpress.thread_manager import ThreadManager
from core.services.supabase import DBConnection
from datetime import datetime, timezone
from core.utils.config import config, EnvMode
import asyncio
from core.utils.logger import logger, structlog
import time
from collections import OrderedDict
import os
import psutil
from pydantic import BaseModel
import uuid
from core.utils.rate_limiter import (
auth_rate_limiter,
api_key_rate_limiter,
admin_rate_limiter,
get_client_identifier,
)
from core import api as core_api
from core.sandbox import api as sandbox_api
from core.billing.api import router as billing_router
from core.setup import router as setup_router, webhook_router
from core.admin.admin_api import router as admin_router
from core.admin.billing_admin_api import router as billing_admin_router
from core.admin.feedback_admin_api import router as feedback_admin_router
from core.admin.notification_admin_api import router as notification_admin_router
from core.admin.analytics_admin_api import router as analytics_admin_router
from core.services import transcription as transcription_api
import sys
from core.triggers import api as triggers_api
from core.services import api_keys_api
from core.notifications import api as notifications_api
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
db = DBConnection()
# Generate unique instance ID per process/worker
# This is critical for distributed locking - each worker needs a unique ID
import uuid
instance_id = str(uuid.uuid4())[:8]
# Rate limiter state
ip_tracker = OrderedDict()
MAX_CONCURRENT_IPS = 25
# Background task handle for CloudWatch metrics
_queue_metrics_task = None
_worker_metrics_task = None
_memory_watchdog_task = None
# Graceful shutdown flag for health checks
# When True, health check will return unhealthy to stop receiving traffic
_is_shutting_down = False
@asynccontextmanager
async def lifespan(app: FastAPI):
global _queue_metrics_task, _worker_metrics_task, _memory_watchdog_task, _is_shutting_down
env_mode = config.ENV_MODE.value if config.ENV_MODE else "unknown"
logger.debug(f"Starting up FastAPI application with instance ID: {instance_id} in {env_mode} mode")
try:
await db.initialize()
# Pre-load tool classes and schemas to avoid first-request delay
from core.utils.tool_discovery import warm_up_tools_cache
warm_up_tools_cache()
# Pre-load static Suna config for fast path in API requests
from core.runtime_cache import load_static_suna_config
load_static_suna_config()
core_api.initialize(
db,
instance_id
)
sandbox_api.initialize(db)
# Initialize Redis connection
from core.services import redis
try:
await redis.initialize_async()
logger.debug("Redis connection initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize Redis connection: {e}")
# Continue without Redis - the application will handle Redis failures gracefully
# Start background tasks
# asyncio.create_task(core_api.restore_running_agent_runs())
triggers_api.initialize(db)
credentials_api.initialize(db)
template_api.initialize(db)
composio_api.initialize(db)
# Start CloudWatch queue metrics publisher (production only)
if config.ENV_MODE == EnvMode.PRODUCTION:
from core.services import queue_metrics
_queue_metrics_task = asyncio.create_task(queue_metrics.start_cloudwatch_publisher())
# Start CloudWatch worker metrics publisher
from core.services import worker_metrics
_worker_metrics_task = asyncio.create_task(worker_metrics.start_cloudwatch_publisher())
# Start memory watchdog for observability
_memory_watchdog_task = asyncio.create_task(_memory_watchdog())
yield
# Shutdown sequence: Set flag first so health checks fail
_is_shutting_down = True
logger.info(f"Starting graceful shutdown for instance {instance_id}")
# Give K8s readiness probe time to detect unhealthy state
# This ensures no new traffic is routed to this pod
await asyncio.sleep(2)
logger.debug("Cleaning up agent resources")
await core_api.cleanup()
# Stop CloudWatch queue metrics task
if _queue_metrics_task is not None:
_queue_metrics_task.cancel()
try:
await _queue_metrics_task
except asyncio.CancelledError:
pass
# Stop CloudWatch worker metrics task
if _worker_metrics_task is not None:
_worker_metrics_task.cancel()
try:
await _worker_metrics_task
except asyncio.CancelledError:
pass
# Stop memory watchdog task
if _memory_watchdog_task is not None:
_memory_watchdog_task.cancel()
try:
await _memory_watchdog_task
except asyncio.CancelledError:
pass
try:
logger.debug("Closing Redis connection")
await redis.close()
logger.debug("Redis connection closed successfully")
except Exception as e:
logger.error(f"Error closing Redis connection: {e}")
logger.debug("Disconnecting from database")
await db.disconnect()
except Exception as e:
logger.error(f"Error during application startup: {e}")
raise
app = FastAPI(
lifespan=lifespan,
swagger_ui_parameters={
"persistAuthorization": True, # Keep auth between page refreshes
},
)
# Configure OpenAPI docs with API Key and Bearer token auth
configure_openapi(app)
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
"""Apply rate limiting to sensitive endpoints."""
path = request.url.path
# Skip rate limiting for health checks and OPTIONS requests
if path in ["/v1/health", "/v1/health-docker"] or request.method == "OPTIONS":
return await call_next(request)
# Get client identifier
client_id = get_client_identifier(request)
# Apply appropriate rate limiter based on path
rate_limiter = None
if "/v1/api-keys" in path:
rate_limiter = api_key_rate_limiter
elif "/v1/admin" in path:
rate_limiter = admin_rate_limiter
elif any(sensitive in path for sensitive in ["/v1/setup/initialize", "/v1/billing/webhook"]):
rate_limiter = auth_rate_limiter
if rate_limiter:
is_limited, retry_after = rate_limiter.is_rate_limited(client_id)
if is_limited:
logger.warning(f"Rate limited: {path} from {client_id[:8]}...")
return JSONResponse(
status_code=429,
content={"detail": "Too many requests. Please try again later."},
headers={"Retry-After": str(retry_after)}
)
return await call_next(request)
@app.middleware("http")
async def log_requests_middleware(request: Request, call_next):
structlog.contextvars.clear_contextvars()
request_id = str(uuid.uuid4())
start_time = time.time()
client_ip = request.client.host if request.client else "unknown"
method = request.method
path = request.url.path
query_params = str(request.query_params)
structlog.contextvars.bind_contextvars(
request_id=request_id,
client_ip=client_ip,
method=method,
path=path,
query_params=query_params
)
# Log the incoming request
logger.debug(f"Request started: {method} {path} from {client_ip} | Query: {query_params}")
try:
response = await call_next(request)
process_time = time.time() - start_time
logger.debug(f"Request completed: {method} {path} | Status: {response.status_code} | Time: {process_time:.2f}s")
return response
except Exception as e:
process_time = time.time() - start_time
try:
error_str = str(e)
except Exception:
error_str = f"Error of type {type(e).__name__}"
logger.error(f"Request failed: {method} {path} | Error: {error_str} | Time: {process_time:.2f}s")
raise
# Define allowed origins based on environment
allowed_origins = ["https://www.xera.cc", "https://xera.cc"]
allow_origin_regex = None
# Add staging-specific origins
if config.ENV_MODE == EnvMode.LOCAL:
allowed_origins.append("http://localhost:3000")
allowed_origins.append("http://127.0.0.1:3000")
# Add staging-specific origins
if config.ENV_MODE == EnvMode.STAGING:
allowed_origins.append("https://staging.suna.so")
allowed_origins.append("http://localhost:3000")
# Allow Vercel preview deployments
allow_origin_regex = r"https://.*-kortixai\.vercel\.app"
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_origin_regex=allow_origin_regex,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-API-Key"],
)
# Create a main API router
api_router = APIRouter()
# Include all API routers without individual prefixes
api_router.include_router(core_api.router)
api_router.include_router(sandbox_api.router)
api_router.include_router(billing_router)
api_router.include_router(setup_router)
api_router.include_router(webhook_router) # Webhooks at /api/webhooks/*
api_router.include_router(api_keys_api.router)
api_router.include_router(billing_admin_router)
api_router.include_router(admin_router)
api_router.include_router(feedback_admin_router)
api_router.include_router(notification_admin_router)
api_router.include_router(analytics_admin_router)
from core.mcp_module import api as mcp_api
from core.credentials import api as credentials_api
from core.templates import api as template_api
from core.templates import presentations_api
api_router.include_router(mcp_api.router)
api_router.include_router(credentials_api.router, prefix="/secure-mcp")
api_router.include_router(template_api.router, prefix="/templates")
api_router.include_router(presentations_api.router, prefix="/presentation-templates")
api_router.include_router(transcription_api.router)
from core.knowledge_base import api as knowledge_base_api
api_router.include_router(knowledge_base_api.router)
api_router.include_router(triggers_api.router)
api_router.include_router(notifications_api.router)
from core.notifications import presence_api
api_router.include_router(presence_api.router)
from core.composio_integration import api as composio_api
api_router.include_router(composio_api.router)
from core.google.google_slides_api import router as google_slides_router
api_router.include_router(google_slides_router)
from core.google.google_docs_api import router as google_docs_router
api_router.include_router(google_docs_router)
from core.referrals import router as referrals_router
from core.memory.api import router as memory_router
api_router.include_router(referrals_router)
api_router.include_router(memory_router)
from core.test_harness.api import router as test_harness_router
api_router.include_router(test_harness_router)
from core.files import staged_files_router
api_router.include_router(staged_files_router, prefix="/files")
from core.sandbox.canvas_ai_api import router as canvas_ai_router
api_router.include_router(canvas_ai_router)
@api_router.get("/health", summary="Health Check", operation_id="health_check", tags=["system"])
async def health_check():
logger.debug("Health check endpoint called")
# During shutdown, return unhealthy status
# This causes K8s readinessProbe to fail and removes pod from service endpoints
if _is_shutting_down:
logger.debug(f"Health check returning unhealthy (shutting down) for instance {instance_id}")
raise HTTPException(
status_code=503,
detail={
"status": "shutting_down",
"timestamp": datetime.now(timezone.utc).isoformat(),
"instance_id": instance_id
}
)
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
"instance_id": instance_id,
}
@api_router.get("/metrics/queue", summary="Queue Metrics", operation_id="queue_metrics", tags=["system"])
async def queue_metrics_endpoint():
"""Get Temporal workflow metrics for monitoring and auto-scaling."""
from core.services import queue_metrics
try:
return await queue_metrics.get_queue_metrics()
except Exception as e:
logger.error(f"Failed to get queue metrics: {e}")
raise HTTPException(status_code=500, detail="Failed to get queue metrics")
@api_router.get("/metrics/workers", summary="Worker Metrics", operation_id="worker_metrics", tags=["system"])
async def worker_metrics_endpoint():
"""Get Temporal worker metrics for monitoring."""
from core.services import worker_metrics
try:
return await worker_metrics.get_worker_metrics()
except Exception as e:
logger.error(f"Failed to get worker metrics: {e}")
raise HTTPException(status_code=500, detail="Failed to get worker metrics")
@api_router.get("/metrics", summary="All Metrics", operation_id="all_metrics", tags=["system"])
async def all_metrics_endpoint():
"""Get combined queue and worker metrics for monitoring."""
from core.services import queue_metrics, worker_metrics
try:
queue_data = await queue_metrics.get_queue_metrics()
worker_data = await worker_metrics.get_worker_metrics()
return {
"queue": queue_data,
"workers": worker_data,
"timestamp": datetime.now(timezone.utc).isoformat()
}
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise HTTPException(status_code=500, detail="Failed to get metrics")
@api_router.get("/debug/queue", summary="Debug Workflow Status", operation_id="debug_queue", tags=["system"])
async def debug_queue_status():
"""
Debug endpoint for Temporal workflow status.
Shows running workflows and connection status.
"""
try:
from core.temporal.client import get_temporal_client
client = await get_temporal_client()
# Note: Temporal Cloud provides comprehensive workflow visibility via their UI
# This endpoint provides basic connection status
# For detailed workflow information, use Temporal Cloud dashboard
import os
return {
"temporal_connected": True,
"note": "Use Temporal Cloud dashboard for detailed workflow information",
"temporal_address": os.getenv("TEMPORAL_ADDRESS", "not_set"),
"temporal_namespace": os.getenv("TEMPORAL_NAMESPACE", "not_set"),
"timestamp": datetime.now(timezone.utc).isoformat()
}
except Exception as e:
logger.error(f"Debug workflow status failed: {e}")
return {
"error": str(e),
"temporal_connected": False,
"timestamp": datetime.now(timezone.utc).isoformat()
}
@api_router.get("/health-docker", summary="Docker Health Check", operation_id="health_check_docker", tags=["system"])
async def health_check_docker():
logger.debug("Health docker check endpoint called")
try:
client = await redis.get_client()
await client.ping()
db = DBConnection()
await db.initialize()
db_client = await db.client
await db_client.table("threads").select("thread_id").limit(1).execute()
logger.debug("Health docker check complete")
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
"instance_id": instance_id
}
except Exception as e:
logger.error(f"Failed health docker check: {e}")
raise HTTPException(status_code=500, detail="Health check failed")
app.include_router(api_router, prefix="/v1")
async def _memory_watchdog():
"""Monitor worker memory usage and log warnings when thresholds are exceeded.
Memory thresholds (for 7.5GB limit):
- Critical (>6.5GB / 87%): Immediate action needed, risk of OOM kill
- Warning (>6GB / 80%): High memory usage, consider cleanup
- Info (>5GB / 67%): Elevated memory usage
"""
try:
while True:
try:
process = psutil.Process()
mem_info = process.memory_info()
mem_mb = mem_info.rss / 1024 / 1024 # Convert to MB
mem_percent = (mem_mb / 7680) * 100 # Percentage of 7.5GB limit
# Critical threshold: >6.5GB (87% of 7.5GB limit) - risk of OOM kill
if mem_mb > 6500:
logger.error(
f"🚨 CRITICAL: Worker memory very high: {mem_mb:.0f}MB ({mem_percent:.1f}%) "
f"(instance: {instance_id}) - Risk of OOM kill!"
)
# Try to force garbage collection when memory is critical
try:
import gc
collected = gc.collect()
if collected > 0:
logger.info(f"Emergency GC collected {collected} objects")
except Exception:
pass
# Warning threshold: >6GB (80% of 7.5GB limit)
elif mem_mb > 6000:
logger.warning(
f"⚠️ Worker memory high: {mem_mb:.0f}MB ({mem_percent:.1f}%) "
f"(instance: {instance_id}) - Approaching limit"
)
# Info threshold: >5GB (67% of 7.5GB limit)
elif mem_mb > 5000:
logger.info(
f"Worker memory: {mem_mb:.0f}MB ({mem_percent:.1f}%) "
f"(instance: {instance_id})"
)
except Exception as e:
logger.debug(f"Memory watchdog error: {e}")
await asyncio.sleep(60) # Check every minute
except asyncio.CancelledError:
logger.debug("Memory watchdog cancelled")
except Exception as e:
logger.error(f"Memory watchdog failed: {e}")
if __name__ == "__main__":
import uvicorn
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
# Enable reload mode for local and staging environments
is_dev_env = config.ENV_MODE in [EnvMode.LOCAL, EnvMode.STAGING]
workers = 1 if is_dev_env else 4
reload = is_dev_env
logger.debug(f"Starting server on 0.0.0.0:7860 with {workers} workers (reload={reload})")
uvicorn.run(
"api:app",
host="0.0.0.0",
port=7860,
workers=workers,
loop="asyncio",
reload=False if is_dev_env else False
)