master/adapter/http/fastapi/app.py

185 lines
5.5 KiB
Python

import asyncio
from collections.abc import Awaitable, Callable
from pathlib import Path
from docker.errors import NotFound
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from adapter.config.loader import load_config
from adapter.config.model import AppConfig
from adapter.di.container import AppContainer, build_container
from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE
from adapter.http.fastapi.middleware import register_middleware
from adapter.http.fastapi.routers.v1.router import router as v1_router
API_V1_PREFIX = '/api/v1'
APP_CLEANUP_TASK_STATE = 'cleanup_task'
APP_CLEANUP_STOP_STATE = 'cleanup_stop'
def create_app(config: AppConfig | None = None) -> FastAPI:
app_config = load_config() if config is None else config
container = build_container(config=app_config)
app: FastAPI | None = None
try:
app = FastAPI(title=app_config.app.name)
setattr(app.state, APP_CONFIG_STATE, app_config)
setattr(app.state, APP_CONTAINER_STATE, container)
app.add_event_handler('startup', _build_startup_handler(app, container))
app.add_event_handler('shutdown', _build_shutdown_handler(app, container))
register_middleware(app, app_config)
app.include_router(v1_router, prefix=API_V1_PREFIX)
FastAPIInstrumentor.instrument_app(
app,
tracer_provider=container.observability.tracer_provider,
meter_provider=container.observability.meter_provider,
exclude_spans=['send', 'receive'],
)
return app
except Exception:
try:
if app is not None:
_uninstrument_app(app)
finally:
container.shutdown()
raise
def _ensure_sandbox_network(container: AppContainer) -> None:
client = container._docker_client
network_name = container.config.sandbox.network_name
try:
client.networks.get(network_name)
except NotFound:
client.networks.create(network_name)
container.observability.logger.info(
'sandbox_network_created',
attrs={'network': network_name},
)
def _ensure_sandbox_dirs(container: AppContainer) -> None:
cfg = container.config.sandbox
for path_str in (cfg.dependencies_host_path, cfg.lambda_tools_host_path):
Path(path_str).mkdir(parents=True, exist_ok=True)
def _build_startup_handler(
app: FastAPI,
container: AppContainer,
) -> Callable[[], Awaitable[None]]:
async def startup() -> None:
task = _get_cleanup_task(app)
if task is not None and not task.done():
return
await asyncio.to_thread(_ensure_sandbox_network, container)
await asyncio.to_thread(_ensure_sandbox_dirs, container)
await asyncio.to_thread(container.sandbox_reconciler.execute)
stop_event = asyncio.Event()
setattr(app.state, APP_CLEANUP_STOP_STATE, stop_event)
setattr(
app.state,
APP_CLEANUP_TASK_STATE,
asyncio.create_task(
_run_cleanup_loop(container, stop_event),
name='sandbox_cleanup',
),
)
return startup
def _build_shutdown_handler(
app: FastAPI,
container: AppContainer,
) -> Callable[[], Awaitable[None]]:
async def shutdown() -> None:
errors: list[Exception] = []
try:
await _stop_cleanup_loop(app)
except Exception as exc:
errors.append(exc)
try:
_uninstrument_app(app)
except Exception as exc:
errors.append(exc)
try:
container.shutdown()
except Exception as exc:
errors.append(exc)
if errors:
raise ExceptionGroup('app shutdown failed', errors)
return shutdown
async def _run_cleanup_loop(
container: AppContainer,
stop_event: asyncio.Event,
) -> None:
interval = container.config.sandbox.cleanup_interval_seconds
while not stop_event.is_set():
try:
await asyncio.to_thread(
container.usecases.cleanup_expired_sandboxes.execute
)
except Exception as exc:
container.observability.logger.error(
'sandbox_cleanup_failed',
attrs={
'error': type(exc).__name__,
},
)
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
continue
async def _stop_cleanup_loop(app: FastAPI) -> None:
stop_event = _get_cleanup_stop_event(app)
if stop_event is not None:
stop_event.set()
task = _get_cleanup_task(app)
try:
if task is not None:
await task
finally:
setattr(app.state, APP_CLEANUP_TASK_STATE, None)
setattr(app.state, APP_CLEANUP_STOP_STATE, None)
def _get_cleanup_task(app: FastAPI) -> asyncio.Task[None] | None:
task = getattr(app.state, APP_CLEANUP_TASK_STATE, None)
if isinstance(task, asyncio.Task):
return task
return None
def _get_cleanup_stop_event(app: FastAPI) -> asyncio.Event | None:
stop_event = getattr(app.state, APP_CLEANUP_STOP_STATE, None)
if isinstance(stop_event, asyncio.Event):
return stop_event
return None
def _uninstrument_app(app: FastAPI) -> None:
if _is_instrumented(app):
FastAPIInstrumentor.uninstrument_app(app)
def _is_instrumented(app: FastAPI) -> bool:
return bool(getattr(app, '_is_instrumented_by_opentelemetry', False))