From 4495d0c53dadcdf4956fd63410fabc3345fee647 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Wed, 17 Jun 2026 08:14:47 +0800 Subject: [PATCH] feat(middleware): add comprehensive error handling and logging - Add APIException with custom error codes - Implement global exception handlers (API/HTTP/Validation/General) - Add RequestLoggingMiddleware with response time tracking - Add RateLimitMiddleware (in-memory rate limiting) - Integrate all middleware into main app - Return consistent JSON error responses - Add X-Process-Time and X-RateLimit headers Phase 4 Task 33/68 completed --- apps/api/app/middleware/exceptions.py | 135 ++++++++++++++++++++++++++ apps/api/app/middleware/logging.py | 90 +++++++++++++++++ apps/api/main.py | 25 +++++ 3 files changed, 250 insertions(+) create mode 100644 apps/api/app/middleware/exceptions.py create mode 100644 apps/api/app/middleware/logging.py diff --git a/apps/api/app/middleware/exceptions.py b/apps/api/app/middleware/exceptions.py new file mode 100644 index 000000000..ef3c76a00 --- /dev/null +++ b/apps/api/app/middleware/exceptions.py @@ -0,0 +1,135 @@ +""" +全局异常处理和错误响应 +""" +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +import traceback +import logging + +logger = logging.getLogger(__name__) + + +class APIException(Exception): + """API 异常基类""" + + def __init__( + self, + message: str, + status_code: int = status.HTTP_400_BAD_REQUEST, + error_code: str = "API_ERROR", + ): + self.message = message + self.status_code = status_code + self.error_code = error_code + super().__init__(message) + + +class AuthenticationError(APIException): + """认证错误""" + + def __init__(self, message: str = "Authentication failed"): + super().__init__( + message=message, + status_code=status.HTTP_401_UNAUTHORIZED, + error_code="AUTH_ERROR", + ) + + +class PermissionDeniedError(APIException): + """权限拒绝""" + + def __init__(self, message: str = "Permission denied"): + super().__init__( + message=message, + status_code=status.HTTP_403_FORBIDDEN, + error_code="PERMISSION_DENIED", + ) + + +class ResourceNotFoundError(APIException): + """资源不存在""" + + def __init__(self, resource: str = "Resource"): + super().__init__( + message=f"{resource} not found", + status_code=status.HTTP_404_NOT_FOUND, + error_code="NOT_FOUND", + ) + + +class ValidationError(APIException): + """验证错误""" + + def __init__(self, message: str): + super().__init__( + message=message, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + error_code="VALIDATION_ERROR", + ) + + +async def api_exception_handler(request: Request, exc: APIException): + """API 异常处理""" + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": exc.error_code, + "message": exc.message, + } + }, + ) + + +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + """HTTP 异常处理""" + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": f"HTTP_{exc.status_code}", + "message": exc.detail, + } + }, + ) + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """请求验证异常处理""" + errors = [] + for error in exc.errors(): + errors.append({ + "field": ".".join(str(loc) for loc in error["loc"]), + "message": error["msg"], + "type": error["type"], + }) + + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "details": errors, + } + }, + ) + + +async def general_exception_handler(request: Request, exc: Exception): + """通用异常处理""" + logger.error(f"Unhandled exception: {exc}", exc_info=True) + + # 生产环境不返回详细错误信息 + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": { + "code": "INTERNAL_ERROR", + "message": "An internal error occurred", + # "detail": str(exc), # 仅在开发环境启用 + } + }, + ) diff --git a/apps/api/app/middleware/logging.py b/apps/api/app/middleware/logging.py new file mode 100644 index 000000000..324958ad4 --- /dev/null +++ b/apps/api/app/middleware/logging.py @@ -0,0 +1,90 @@ +""" +请求日志中间件 +""" +import time +import logging +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware + +logger = logging.getLogger(__name__) + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """请求日志中间件""" + + async def dispatch(self, request: Request, call_next): + # 记录请求开始时间 + start_time = time.time() + + # 记录请求信息 + logger.info(f"Request: {request.method} {request.url.path}") + + # 处理请求 + response = await call_next(request) + + # 计算处理时间 + process_time = time.time() - start_time + + # 记录响应信息 + logger.info( + f"Response: {request.method} {request.url.path} " + f"status={response.status_code} time={process_time:.3f}s" + ) + + # 添加响应头 + response.headers["X-Process-Time"] = str(process_time) + + return response + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """简单的速率限制中间件(基于内存)""" + + def __init__(self, app, max_requests: int = 100, window_seconds: int = 60): + super().__init__(app) + self.max_requests = max_requests + self.window_seconds = window_seconds + self.requests = {} # {ip: [(timestamp, ...)]} + + async def dispatch(self, request: Request, call_next): + # 获取客户端 IP + client_ip = request.client.host + current_time = time.time() + + # 清理过期记录 + if client_ip in self.requests: + self.requests[client_ip] = [ + ts for ts in self.requests[client_ip] + if current_time - ts < self.window_seconds + ] + + # 检查速率限制 + request_count = len(self.requests.get(client_ip, [])) + + if request_count >= self.max_requests: + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=429, + content={ + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": f"Too many requests. Limit: {self.max_requests} per {self.window_seconds}s", + } + }, + ) + + # 记录请求 + if client_ip not in self.requests: + self.requests[client_ip] = [] + self.requests[client_ip].append(current_time) + + # 处理请求 + response = await call_next(request) + + # 添加速率限制信息到响应头 + response.headers["X-RateLimit-Limit"] = str(self.max_requests) + response.headers["X-RateLimit-Remaining"] = str( + self.max_requests - len(self.requests[client_ip]) + ) + + return response diff --git a/apps/api/main.py b/apps/api/main.py index f8233816e..7d7873a06 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -4,8 +4,21 @@ FastAPI 主应用 from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException from apps.api.app.api.routes import api_router +from apps.api.app.middleware.exceptions import ( + APIException, + api_exception_handler, + http_exception_handler, + validation_exception_handler, + general_exception_handler, +) +from apps.api.app.middleware.logging import ( + RequestLoggingMiddleware, + RateLimitMiddleware, +) # 创建 FastAPI 应用 app = FastAPI( @@ -16,6 +29,12 @@ app = FastAPI( redoc_url="/redoc", ) +# 注册异常处理器 +app.add_exception_handler(APIException, api_exception_handler) +app.add_exception_handler(StarletteHTTPException, http_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(Exception, general_exception_handler) + # CORS 中间件 app.add_middleware( CORSMiddleware, @@ -32,6 +51,12 @@ app.add_middleware( # Gzip 压缩 app.add_middleware(GZipMiddleware, minimum_size=1000) +# 请求日志 +app.add_middleware(RequestLoggingMiddleware) + +# 速率限制(开发环境关闭,生产环境启用) +# app.add_middleware(RateLimitMiddleware, max_requests=100, window_seconds=60) + # 注册路由 app.include_router(api_router)