mcp-proxy-adapter 3.0.0__py3-none-any.whl → 3.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. examples/basic_example/README.md +123 -9
  2. examples/basic_example/config.json +4 -0
  3. examples/basic_example/docs/EN/README.md +46 -5
  4. examples/basic_example/docs/RU/README.md +46 -5
  5. examples/basic_example/server.py +127 -21
  6. examples/complete_example/commands/system_command.py +1 -0
  7. examples/complete_example/server.py +68 -40
  8. examples/minimal_example/README.md +20 -6
  9. examples/minimal_example/config.json +7 -14
  10. examples/minimal_example/main.py +109 -40
  11. examples/minimal_example/simple_server.py +53 -14
  12. examples/minimal_example/tests/conftest.py +1 -1
  13. examples/minimal_example/tests/test_integration.py +8 -10
  14. examples/simple_server.py +12 -21
  15. examples/test_server.py +22 -14
  16. examples/tool_description_example.py +82 -0
  17. mcp_proxy_adapter/api/__init__.py +0 -0
  18. mcp_proxy_adapter/api/app.py +391 -0
  19. mcp_proxy_adapter/api/handlers.py +229 -0
  20. mcp_proxy_adapter/api/middleware/__init__.py +49 -0
  21. mcp_proxy_adapter/api/middleware/auth.py +146 -0
  22. mcp_proxy_adapter/api/middleware/base.py +79 -0
  23. mcp_proxy_adapter/api/middleware/error_handling.py +198 -0
  24. mcp_proxy_adapter/api/middleware/logging.py +96 -0
  25. mcp_proxy_adapter/api/middleware/performance.py +83 -0
  26. mcp_proxy_adapter/api/middleware/rate_limit.py +152 -0
  27. mcp_proxy_adapter/api/schemas.py +305 -0
  28. mcp_proxy_adapter/api/tool_integration.py +223 -0
  29. mcp_proxy_adapter/api/tools.py +198 -0
  30. mcp_proxy_adapter/commands/__init__.py +19 -0
  31. mcp_proxy_adapter/commands/base.py +301 -0
  32. mcp_proxy_adapter/commands/command_registry.py +231 -0
  33. mcp_proxy_adapter/commands/config_command.py +113 -0
  34. mcp_proxy_adapter/commands/health_command.py +136 -0
  35. mcp_proxy_adapter/commands/help_command.py +193 -0
  36. mcp_proxy_adapter/commands/result.py +215 -0
  37. mcp_proxy_adapter/config.py +9 -0
  38. mcp_proxy_adapter/core/__init__.py +0 -0
  39. mcp_proxy_adapter/core/errors.py +173 -0
  40. mcp_proxy_adapter/core/logging.py +205 -0
  41. mcp_proxy_adapter/core/utils.py +138 -0
  42. mcp_proxy_adapter/custom_openapi.py +47 -10
  43. mcp_proxy_adapter/py.typed +0 -0
  44. mcp_proxy_adapter/schemas/base_schema.json +114 -0
  45. mcp_proxy_adapter/schemas/openapi_schema.json +314 -0
  46. mcp_proxy_adapter/tests/__init__.py +0 -0
  47. mcp_proxy_adapter/tests/api/__init__.py +3 -0
  48. mcp_proxy_adapter/tests/api/test_cmd_endpoint.py +115 -0
  49. mcp_proxy_adapter/tests/api/test_middleware.py +336 -0
  50. mcp_proxy_adapter/tests/commands/__init__.py +3 -0
  51. mcp_proxy_adapter/tests/commands/test_config_command.py +211 -0
  52. mcp_proxy_adapter/tests/commands/test_echo_command.py +127 -0
  53. mcp_proxy_adapter/tests/commands/test_help_command.py +133 -0
  54. mcp_proxy_adapter/tests/conftest.py +131 -0
  55. mcp_proxy_adapter/tests/functional/__init__.py +3 -0
  56. mcp_proxy_adapter/tests/functional/test_api.py +253 -0
  57. mcp_proxy_adapter/tests/integration/__init__.py +3 -0
  58. mcp_proxy_adapter/tests/integration/test_cmd_integration.py +130 -0
  59. mcp_proxy_adapter/tests/integration/test_integration.py +255 -0
  60. mcp_proxy_adapter/tests/performance/__init__.py +3 -0
  61. mcp_proxy_adapter/tests/performance/test_performance.py +189 -0
  62. mcp_proxy_adapter/tests/stubs/__init__.py +10 -0
  63. mcp_proxy_adapter/tests/stubs/echo_command.py +104 -0
  64. mcp_proxy_adapter/tests/test_api_endpoints.py +271 -0
  65. mcp_proxy_adapter/tests/test_api_handlers.py +289 -0
  66. mcp_proxy_adapter/tests/test_base_command.py +123 -0
  67. mcp_proxy_adapter/tests/test_batch_requests.py +117 -0
  68. mcp_proxy_adapter/tests/test_command_registry.py +245 -0
  69. mcp_proxy_adapter/tests/test_config.py +127 -0
  70. mcp_proxy_adapter/tests/test_utils.py +65 -0
  71. mcp_proxy_adapter/tests/unit/__init__.py +3 -0
  72. mcp_proxy_adapter/tests/unit/test_base_command.py +130 -0
  73. mcp_proxy_adapter/tests/unit/test_config.py +217 -0
  74. mcp_proxy_adapter/version.py +1 -1
  75. {mcp_proxy_adapter-3.0.0.dist-info → mcp_proxy_adapter-3.0.2.dist-info}/METADATA +1 -1
  76. mcp_proxy_adapter-3.0.2.dist-info/RECORD +109 -0
  77. examples/basic_example/config.yaml +0 -20
  78. examples/basic_example/main.py +0 -50
  79. examples/complete_example/main.py +0 -67
  80. examples/minimal_example/config.yaml +0 -26
  81. mcp_proxy_adapter/framework.py +0 -109
  82. mcp_proxy_adapter-3.0.0.dist-info/RECORD +0 -58
  83. {mcp_proxy_adapter-3.0.0.dist-info → mcp_proxy_adapter-3.0.2.dist-info}/WHEEL +0 -0
  84. {mcp_proxy_adapter-3.0.0.dist-info → mcp_proxy_adapter-3.0.2.dist-info}/licenses/LICENSE +0 -0
  85. {mcp_proxy_adapter-3.0.0.dist-info → mcp_proxy_adapter-3.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,146 @@
1
+ """
2
+ Middleware for authentication.
3
+ """
4
+
5
+ import json
6
+ from typing import Dict, List, Optional, Callable, Awaitable
7
+
8
+ from fastapi import Request, Response
9
+ from starlette.responses import JSONResponse
10
+
11
+ from mcp_proxy_adapter.core.logging import logger
12
+ from .base import BaseMiddleware
13
+
14
+ class AuthMiddleware(BaseMiddleware):
15
+ """
16
+ Middleware for authenticating requests.
17
+ """
18
+
19
+ def __init__(self, app, api_keys: Dict[str, str] = None, public_paths: List[str] = None, auth_enabled: bool = True):
20
+ """
21
+ Initializes middleware for authentication.
22
+
23
+ Args:
24
+ app: FastAPI application
25
+ api_keys: Dictionary with API keys (key: username)
26
+ public_paths: List of paths accessible without authentication
27
+ auth_enabled: Flag to enable/disable authentication
28
+ """
29
+ super().__init__(app)
30
+ self.api_keys = api_keys or {}
31
+ self.public_paths = public_paths or [
32
+ "/docs",
33
+ "/redoc",
34
+ "/openapi.json",
35
+ "/health"
36
+ ]
37
+ self.auth_enabled = auth_enabled
38
+
39
+ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
40
+ """
41
+ Processes request and checks authentication.
42
+
43
+ Args:
44
+ request: Request.
45
+ call_next: Next handler.
46
+
47
+ Returns:
48
+ Response.
49
+ """
50
+ # Check if authentication is disabled
51
+ if not self.auth_enabled:
52
+ logger.debug("Authentication is disabled, skipping authentication check")
53
+ return await call_next(request)
54
+
55
+ # Check if path is public
56
+ path = request.url.path
57
+ if self._is_public_path(path):
58
+ # If path is public, skip authentication
59
+ return await call_next(request)
60
+
61
+ # Check for API key in header
62
+ api_key = request.headers.get("X-API-Key")
63
+
64
+ if not api_key:
65
+ # Check for API key in query parameters
66
+ api_key = request.query_params.get("api_key")
67
+
68
+ if not api_key and request.method in ["POST", "PUT", "PATCH"]:
69
+ # Check for API key in JSON-RPC request body
70
+ try:
71
+ body = await request.body()
72
+ if body:
73
+ try:
74
+ body_json = json.loads(body.decode("utf-8"))
75
+ # Look for API key in params of JSON-RPC object
76
+ if isinstance(body_json, dict) and "params" in body_json:
77
+ api_key = body_json.get("params", {}).get("api_key")
78
+ except json.JSONDecodeError:
79
+ pass
80
+ except Exception:
81
+ pass
82
+
83
+ # If API key not found, return error
84
+ if not api_key:
85
+ logger.warning(f"Authentication failed: API key not provided | Path: {path}")
86
+ return self._create_error_response("API key not provided", 401)
87
+
88
+ # Check if API key is valid
89
+ username = self._validate_api_key(api_key)
90
+ if not username:
91
+ logger.warning(f"Authentication failed: Invalid API key | Path: {path}")
92
+ return self._create_error_response("Invalid API key", 401)
93
+
94
+ # If API key is valid, save user information in request state
95
+ request.state.username = username
96
+ logger.info(f"Authentication successful: {username} | Path: {path}")
97
+
98
+ # Call the next middleware or main handler
99
+ return await call_next(request)
100
+
101
+ def _is_public_path(self, path: str) -> bool:
102
+ """
103
+ Checks if the path is public.
104
+
105
+ Args:
106
+ path: Path to check.
107
+
108
+ Returns:
109
+ True if path is public, False otherwise.
110
+ """
111
+ return any(path.startswith(public_path) for public_path in self.public_paths)
112
+
113
+ def _validate_api_key(self, api_key: str) -> Optional[str]:
114
+ """
115
+ Validates API key.
116
+
117
+ Args:
118
+ api_key: API key to validate.
119
+
120
+ Returns:
121
+ Username if API key is valid, otherwise None.
122
+ """
123
+ return self.api_keys.get(api_key)
124
+
125
+ def _create_error_response(self, message: str, status_code: int) -> Response:
126
+ """
127
+ Creates error response in JSON-RPC format.
128
+
129
+ Args:
130
+ message: Error message.
131
+ status_code: HTTP status code.
132
+
133
+ Returns:
134
+ JSON response with error.
135
+ """
136
+ return JSONResponse(
137
+ status_code=status_code,
138
+ content={
139
+ "jsonrpc": "2.0",
140
+ "error": {
141
+ "code": -32000,
142
+ "message": message
143
+ },
144
+ "id": None
145
+ }
146
+ )
@@ -0,0 +1,79 @@
1
+ """
2
+ Base middleware module.
3
+ """
4
+
5
+ from typing import Callable, Awaitable
6
+ import logging
7
+
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+ from fastapi import Request, Response
10
+
11
+ from mcp_proxy_adapter.core.logging import logger
12
+
13
+ class BaseMiddleware(BaseHTTPMiddleware):
14
+ """
15
+ Base class for all middleware.
16
+ """
17
+
18
+ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
19
+ """
20
+ Method that will be overridden in child classes.
21
+
22
+ Args:
23
+ request: Request.
24
+ call_next: Next handler.
25
+
26
+ Returns:
27
+ Response.
28
+ """
29
+ try:
30
+ # Process request before calling the main handler
31
+ await self.before_request(request)
32
+
33
+ # Call the next middleware or main handler
34
+ response = await call_next(request)
35
+
36
+ # Process response after calling the main handler
37
+ response = await self.after_response(request, response)
38
+
39
+ return response
40
+ except Exception as e:
41
+ logger.exception(f"Error in middleware: {str(e)}")
42
+ # If an error occurred, call the error handler
43
+ return await self.handle_error(request, e)
44
+
45
+ async def before_request(self, request: Request) -> None:
46
+ """
47
+ Method for processing request before calling the main handler.
48
+
49
+ Args:
50
+ request: Request.
51
+ """
52
+ pass
53
+
54
+ async def after_response(self, request: Request, response: Response) -> Response:
55
+ """
56
+ Method for processing response after calling the main handler.
57
+
58
+ Args:
59
+ request: Request.
60
+ response: Response.
61
+
62
+ Returns:
63
+ Processed response.
64
+ """
65
+ return response
66
+
67
+ async def handle_error(self, request: Request, exception: Exception) -> Response:
68
+ """
69
+ Method for handling errors that occurred in middleware.
70
+
71
+ Args:
72
+ request: Request.
73
+ exception: Exception.
74
+
75
+ Returns:
76
+ Error response.
77
+ """
78
+ # By default, just pass the error further
79
+ raise exception
@@ -0,0 +1,198 @@
1
+ """
2
+ Middleware for error handling.
3
+ """
4
+
5
+ import json
6
+ from typing import Callable, Awaitable, Dict, Any, Optional
7
+
8
+ from fastapi import Request, Response
9
+ from starlette.responses import JSONResponse
10
+
11
+ from mcp_proxy_adapter.core.logging import logger
12
+ from mcp_proxy_adapter.core.errors import MicroserviceError, CommandError, ValidationError
13
+ from .base import BaseMiddleware
14
+
15
+ class ErrorHandlingMiddleware(BaseMiddleware):
16
+ """
17
+ Middleware for handling and formatting errors.
18
+ """
19
+
20
+ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
21
+ """
22
+ Processes request and catches errors.
23
+
24
+ Args:
25
+ request: Request.
26
+ call_next: Next handler.
27
+
28
+ Returns:
29
+ Response.
30
+ """
31
+ try:
32
+ # Call the next middleware or main handler
33
+ return await call_next(request)
34
+
35
+ except CommandError as e:
36
+ # Command error
37
+ request_id = getattr(request.state, "request_id", "unknown")
38
+ logger.error(f"[{request_id}] Command error: {str(e)}")
39
+
40
+ # Проверяем, является ли запрос JSON-RPC
41
+ is_jsonrpc = self._is_json_rpc_request(request)
42
+
43
+ # Get JSON-RPC request ID if available
44
+ request_id_jsonrpc = await self._get_json_rpc_id(request)
45
+
46
+ # If request was JSON-RPC
47
+ if is_jsonrpc:
48
+ return JSONResponse(
49
+ status_code=400,
50
+ content={
51
+ "jsonrpc": "2.0",
52
+ "error": {
53
+ "code": e.code,
54
+ "message": str(e),
55
+ "data": e.details if hasattr(e, "details") else None
56
+ },
57
+ "id": request_id_jsonrpc
58
+ }
59
+ )
60
+
61
+ # Regular API error
62
+ return JSONResponse(
63
+ status_code=400,
64
+ content=e.to_dict()
65
+ )
66
+
67
+ except ValidationError as e:
68
+ # Validation error
69
+ request_id = getattr(request.state, "request_id", "unknown")
70
+ logger.error(f"[{request_id}] Validation error: {str(e)}")
71
+
72
+ # Get JSON-RPC request ID if available
73
+ request_id_jsonrpc = await self._get_json_rpc_id(request)
74
+
75
+ # If request was JSON-RPC
76
+ if self._is_json_rpc_request(request):
77
+ return JSONResponse(
78
+ status_code=400,
79
+ content={
80
+ "jsonrpc": "2.0",
81
+ "error": {
82
+ "code": -32602,
83
+ "message": "Invalid params",
84
+ "data": e.details if hasattr(e, "details") else None
85
+ },
86
+ "id": request_id_jsonrpc
87
+ }
88
+ )
89
+
90
+ # Regular API error
91
+ return JSONResponse(
92
+ status_code=400,
93
+ content=e.to_dict()
94
+ )
95
+
96
+ except MicroserviceError as e:
97
+ # Other microservice error
98
+ request_id = getattr(request.state, "request_id", "unknown")
99
+ logger.error(f"[{request_id}] Microservice error: {str(e)}")
100
+
101
+ # Get JSON-RPC request ID if available
102
+ request_id_jsonrpc = await self._get_json_rpc_id(request)
103
+
104
+ # If request was JSON-RPC
105
+ if self._is_json_rpc_request(request):
106
+ return JSONResponse(
107
+ status_code=e.code,
108
+ content={
109
+ "jsonrpc": "2.0",
110
+ "error": {
111
+ "code": -32000,
112
+ "message": str(e),
113
+ "data": e.details if hasattr(e, "details") else None
114
+ },
115
+ "id": request_id_jsonrpc
116
+ }
117
+ )
118
+
119
+ # Regular API error
120
+ return JSONResponse(
121
+ status_code=e.code,
122
+ content=e.to_dict()
123
+ )
124
+
125
+ except Exception as e:
126
+ # Unexpected error
127
+ request_id = getattr(request.state, "request_id", "unknown")
128
+ logger.exception(f"[{request_id}] Unexpected error: {str(e)}")
129
+
130
+ # Get JSON-RPC request ID if available
131
+ request_id_jsonrpc = await self._get_json_rpc_id(request)
132
+
133
+ # If request was JSON-RPC
134
+ if self._is_json_rpc_request(request):
135
+ return JSONResponse(
136
+ status_code=500,
137
+ content={
138
+ "jsonrpc": "2.0",
139
+ "error": {
140
+ "code": -32603,
141
+ "message": "Internal error"
142
+ },
143
+ "id": request_id_jsonrpc
144
+ }
145
+ )
146
+
147
+ # Regular API error
148
+ return JSONResponse(
149
+ status_code=500,
150
+ content={
151
+ "error": {
152
+ "code": 500,
153
+ "message": "Internal server error"
154
+ }
155
+ }
156
+ )
157
+
158
+ def _is_json_rpc_request(self, request: Request) -> bool:
159
+ """
160
+ Checks if request is a JSON-RPC request.
161
+
162
+ Args:
163
+ request: Request.
164
+
165
+ Returns:
166
+ True if request is JSON-RPC, False otherwise.
167
+ """
168
+ return request.url.path.endswith("/jsonrpc")
169
+
170
+ async def _get_json_rpc_id(self, request: Request) -> Optional[Any]:
171
+ """
172
+ Gets JSON-RPC request ID.
173
+
174
+ Args:
175
+ request: Request.
176
+
177
+ Returns:
178
+ JSON-RPC request ID if available, None otherwise.
179
+ """
180
+ try:
181
+ # Use request state to avoid body parsing if already done
182
+ if hasattr(request.state, "json_rpc_id"):
183
+ return request.state.json_rpc_id
184
+
185
+ # Parse request body
186
+ body = await request.body()
187
+ if body:
188
+ body_text = body.decode("utf-8")
189
+ body_json = json.loads(body_text)
190
+ request_id = body_json.get("id")
191
+
192
+ # Save ID in request state to avoid reparsing
193
+ request.state.json_rpc_id = request_id
194
+ return request_id
195
+ except Exception as e:
196
+ logger.warning(f"Error parsing JSON-RPC ID: {str(e)}")
197
+
198
+ return None
@@ -0,0 +1,96 @@
1
+ """
2
+ Middleware for request logging.
3
+ """
4
+
5
+ import time
6
+ import json
7
+ import uuid
8
+ from typing import Callable, Awaitable
9
+
10
+ from fastapi import Request, Response
11
+
12
+ from mcp_proxy_adapter.core.logging import logger, RequestLogger
13
+ from .base import BaseMiddleware
14
+
15
+ class LoggingMiddleware(BaseMiddleware):
16
+ """
17
+ Middleware for logging requests and responses.
18
+ """
19
+
20
+ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
21
+ """
22
+ Processes request and logs information about it.
23
+
24
+ Args:
25
+ request: Request.
26
+ call_next: Next handler.
27
+
28
+ Returns:
29
+ Response.
30
+ """
31
+ # Generate unique ID for request
32
+ request_id = str(uuid.uuid4())
33
+ request.state.request_id = request_id
34
+
35
+ # Create context logger for this request
36
+ req_logger = RequestLogger("mcp_proxy_adapter.api.middleware", request_id)
37
+
38
+ # Log request start
39
+ start_time = time.time()
40
+
41
+ # Request information
42
+ method = request.method
43
+ url = str(request.url)
44
+ client_host = request.client.host if request.client else "unknown"
45
+
46
+ req_logger.info(f"Request started: {method} {url} | Client: {client_host}")
47
+
48
+ # Log request body if not GET or HEAD
49
+ if method not in ["GET", "HEAD"]:
50
+ try:
51
+ body = await request.body()
52
+ if body:
53
+ # Try to parse JSON
54
+ try:
55
+ body_text = body.decode("utf-8")
56
+ # Hide sensitive data (like passwords)
57
+ body_json = json.loads(body_text)
58
+ if isinstance(body_json, dict) and "params" in body_json:
59
+ # Replace sensitive fields with "***"
60
+ if isinstance(body_json["params"], dict):
61
+ for sensitive_field in ["password", "token", "secret", "api_key"]:
62
+ if sensitive_field in body_json["params"]:
63
+ body_json["params"][sensitive_field] = "***"
64
+
65
+ req_logger.debug(f"Request body: {json.dumps(body_json)}")
66
+ except json.JSONDecodeError:
67
+ # If not JSON, log as is
68
+ req_logger.debug(f"Request body: {body_text}")
69
+ except Exception as e:
70
+ req_logger.warning(f"Error logging request body: {str(e)}")
71
+ except Exception as e:
72
+ req_logger.warning(f"Error reading request body: {str(e)}")
73
+
74
+ # Call the next middleware or main handler
75
+ try:
76
+ response = await call_next(request)
77
+
78
+ # Log request completion
79
+ process_time = time.time() - start_time
80
+ status_code = response.status_code
81
+
82
+ req_logger.info(f"Request completed: {method} {url} | Status: {status_code} | "
83
+ f"Time: {process_time:.3f}s")
84
+
85
+ # Add request ID to response headers
86
+ response.headers["X-Request-ID"] = request_id
87
+ response.headers["X-Process-Time"] = f"{process_time:.3f}s"
88
+
89
+ return response
90
+ except Exception as e:
91
+ # Log error
92
+ process_time = time.time() - start_time
93
+ req_logger.error(f"Request failed: {method} {url} | Error: {str(e)} | "
94
+ f"Time: {process_time:.3f}s")
95
+
96
+ raise
@@ -0,0 +1,83 @@
1
+ """
2
+ Middleware for performance monitoring.
3
+ """
4
+
5
+ import time
6
+ import statistics
7
+ from typing import Dict, List, Callable, Awaitable
8
+
9
+ from fastapi import Request, Response
10
+
11
+ from mcp_proxy_adapter.core.logging import logger
12
+ from .base import BaseMiddleware
13
+
14
+ class PerformanceMiddleware(BaseMiddleware):
15
+ """
16
+ Middleware for measuring performance.
17
+ """
18
+
19
+ def __init__(self, app):
20
+ """
21
+ Initializes performance middleware.
22
+
23
+ Args:
24
+ app: FastAPI application.
25
+ """
26
+ super().__init__(app)
27
+ self.request_times: Dict[str, List[float]] = {}
28
+ self.log_interval = 100 # Log statistics every 100 requests
29
+ self.request_count = 0
30
+
31
+ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
32
+ """
33
+ Processes request and measures performance.
34
+
35
+ Args:
36
+ request: Request.
37
+ call_next: Next handler.
38
+
39
+ Returns:
40
+ Response.
41
+ """
42
+ # Measure processing time
43
+ start_time = time.time()
44
+
45
+ # Call the next middleware or main handler
46
+ response = await call_next(request)
47
+
48
+ # Calculate processing time
49
+ process_time = time.time() - start_time
50
+
51
+ # Save time in statistics
52
+ path = request.url.path
53
+ if path not in self.request_times:
54
+ self.request_times[path] = []
55
+
56
+ self.request_times[path].append(process_time)
57
+
58
+ # Logging statistics
59
+ self.request_count += 1
60
+ if self.request_count % self.log_interval == 0:
61
+ self._log_stats()
62
+
63
+ return response
64
+
65
+ def _log_stats(self) -> None:
66
+ """
67
+ Logs performance statistics.
68
+ """
69
+ logger.info("Performance statistics:")
70
+
71
+ for path, times in self.request_times.items():
72
+ if len(times) > 1:
73
+ avg_time = statistics.mean(times)
74
+ min_time = min(times)
75
+ max_time = max(times)
76
+ # Calculate 95th percentile
77
+ p95_time = sorted(times)[int(len(times) * 0.95)]
78
+
79
+ logger.info(
80
+ f"Path: {path}, Requests: {len(times)}, "
81
+ f"Avg: {avg_time:.3f}s, Min: {min_time:.3f}s, "
82
+ f"Max: {max_time:.3f}s, p95: {p95_time:.3f}s"
83
+ )