fix: improve error handling and stability - no logout on transient errors, add ErrorBoundary, global error logging
This commit is contained in:
+21
-3
@@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import importlib
|
||||
@@ -29,6 +29,7 @@ from app.routes import (
|
||||
ai_copilot,
|
||||
audit,
|
||||
auth,
|
||||
errors,
|
||||
contact_folders,
|
||||
contacts,
|
||||
dashboard,
|
||||
@@ -308,6 +309,22 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
# ── Global exception handler — catch ALL unhandled exceptions ──
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
record_error(
|
||||
event="unhandled_exception",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=500,
|
||||
error=str(exc),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error", "code": "internal_error"},
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(metrics.router)
|
||||
app.include_router(auth.router)
|
||||
@@ -338,6 +355,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(custom_fields.router)
|
||||
app.include_router(saved_filters.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(errors.router)
|
||||
|
||||
# ── Register plugin routes for all built-in plugins ──
|
||||
# Routes are registered here (before app start); activation status
|
||||
@@ -375,8 +393,8 @@ def create_app() -> FastAPI:
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
router = getattr(router_module, route_def.router_attr)
|
||||
app.include_router(router)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register plugin routes for {mod_name}: {exc}")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Error logging endpoint — accepts frontend errors and logs them.
|
||||
|
||||
No auth required so errors can be logged even during logout.
|
||||
Rate-limited to 10 requests per minute per IP (simple in-memory implementation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/errors", tags=["errors"])
|
||||
|
||||
# -- Simple in-memory rate limiter (10 req/min per IP) --
|
||||
RATE_LIMIT = 10 # max requests
|
||||
RATE_WINDOW = 60 # seconds
|
||||
_ip_requests: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def _is_rate_limited(client_ip: str) -> bool:
|
||||
"""Return True if the IP has exceeded the rate limit."""
|
||||
now = time.monotonic()
|
||||
dq = _ip_requests[client_ip]
|
||||
|
||||
# Remove timestamps outside the window
|
||||
while dq and now - dq[0] > RATE_WINDOW:
|
||||
dq.popleft()
|
||||
|
||||
if len(dq) >= RATE_LIMIT:
|
||||
return True
|
||||
|
||||
dq.append(now)
|
||||
return False
|
||||
|
||||
|
||||
# -- Request schema --
|
||||
|
||||
class ErrorReport(BaseModel):
|
||||
timestamp: str | None = None
|
||||
message: str = Field(..., max_length=2000)
|
||||
stack: str | None = Field(None, max_length=10000)
|
||||
context: dict[str, Any] | None = None
|
||||
url: str | None = Field(None, max_length=500)
|
||||
userAgent: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def report_error(error: ErrorReport, request: Request) -> Response:
|
||||
"""Log a frontend error. No auth required. Rate-limited per IP."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if _is_rate_limited(client_ip):
|
||||
return Response(status_code=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
# Log with structured info
|
||||
logger.error(
|
||||
"Frontend error reported: %s",
|
||||
error.message,
|
||||
extra={
|
||||
"error_timestamp": error.timestamp,
|
||||
"error_message": error.message,
|
||||
"error_stack": error.stack,
|
||||
"error_context": error.context,
|
||||
"error_url": error.url,
|
||||
"error_user_agent": error.userAgent,
|
||||
"client_ip": client_ip,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user