middleware.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. """请求日志中间件 — 记录最近 MAX_ENTRIES 条请求到内存
  2. 实现为纯 ASGI 中间件(而非 BaseHTTPMiddleware),原因:
  3. - BaseHTTPMiddleware 中先 `await request.body()` 再 `call_next(request)` 会消费请求体,
  4. 导致下游 handler 再次 receive 时拿不到 body,POST 请求死锁/挂起;
  5. - 单 worker 部署下,一个请求挂起会卡死整个 worker,所有请求(含 /api/v1/logs)超时。
  6. 纯 ASGI 中间件通过包装 receive/send 通道缓存请求体与响应状态,既记录日志又不破坏下游请求。
  7. """
  8. import time
  9. from collections import deque
  10. import logging
  11. logger = logging.getLogger(__name__)
  12. MAX_ENTRIES = 500
  13. _request_log: deque = deque(maxlen=MAX_ENTRIES)
  14. def get_request_log() -> list:
  15. """返回请求日志副本(最近在前)"""
  16. return list(_request_log)[::-1]
  17. def clear_request_log() -> int:
  18. """清空日志,返回清空条数"""
  19. n = len(_request_log)
  20. _request_log.clear()
  21. return n
  22. def _decode_body(raw: bytes) -> str:
  23. """将请求体字节解码为可读文本(截断超长内容)"""
  24. if not raw:
  25. return ""
  26. try:
  27. text = raw.decode("utf-8")
  28. if len(text) > 2000:
  29. text = text[:2000] + "...(truncated)"
  30. return text
  31. except Exception:
  32. return f"<binary {len(raw)} bytes>"
  33. class RequestLogMiddleware:
  34. """纯 ASGI 请求日志中间件。
  35. 通过包装 receive 通道缓存请求体(不消费、不阻塞下游),
  36. 通过包装 send 通道捕获响应状态码与响应体,避免破坏原请求。
  37. """
  38. def __init__(self, app):
  39. self.app = app
  40. async def __call__(self, scope, receive, send):
  41. if scope["type"] != "http":
  42. await self.app(scope, receive, send)
  43. return
  44. start_time = time.perf_counter()
  45. method = scope.get("method", "GET")
  46. path = scope.get("path", "")
  47. client = scope.get("client")
  48. client_ip = client[0] if client else "-"
  49. # 缓存请求体(仅对带 body 的方法),不影响下游 receive
  50. body_bytes = b""
  51. body_cached = False
  52. async def receive_wrapper():
  53. nonlocal body_bytes, body_cached
  54. message = await receive()
  55. if message["type"] == "http.request":
  56. body_bytes += message.get("body", b"")
  57. if not message.get("more_body", False):
  58. body_cached = True
  59. return message
  60. # 捕获响应状态码与响应体
  61. status_code = None
  62. response_chunks = []
  63. sent_response_start = False
  64. async def send_wrapper(message):
  65. nonlocal status_code, response_chunks, sent_response_start
  66. if message["type"] == "http.response.start":
  67. status_code = message.get("status", 500)
  68. sent_response_start = True
  69. elif message["type"] == "http.response.body":
  70. body = message.get("body", b"")
  71. if body:
  72. response_chunks.append(body)
  73. total = sum(len(c) for c in response_chunks)
  74. if total > 1000:
  75. response_chunks[:] = [b"".join(response_chunks)[:1000]]
  76. return await send(message)
  77. try:
  78. await self.app(scope, receive_wrapper, send_wrapper)
  79. except Exception as exc:
  80. logger.error("REQUEST EXCEPTION: %s %s %.2fs error=%r",
  81. method, path, time.perf_counter() - start_time, exc)
  82. if not sent_response_start:
  83. from starlette.responses import JSONResponse
  84. resp = JSONResponse(status_code=500, content={"detail": str(exc)})
  85. await resp(scope, receive, send)
  86. status_code = status_code or 500
  87. elapsed = time.perf_counter() - start_time
  88. final_status = status_code if status_code is not None else 500
  89. entry = {
  90. "ts": time.strftime("%H:%M:%S"),
  91. "method": method,
  92. "path": path,
  93. "status": final_status,
  94. "duration_ms": round(elapsed * 1000),
  95. "client_ip": client_ip,
  96. "body": _decode_body(body_bytes) if body_cached else None,
  97. "response": _decode_body(b"".join(response_chunks)[:1000]) if response_chunks else "",
  98. }
  99. _request_log.appendleft(entry)
  100. if elapsed > 5:
  101. logger.warning("SLOW_REQUEST: %s %s %.2fs", method, path, elapsed)
  102. else:
  103. logger.debug("REQUEST: %s %s %.2fs", method, path, elapsed)