chuk-tool-processor 0.6.4__py3-none-any.whl → 0.9.7__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.
Potentially problematic release.
This version of chuk-tool-processor might be problematic. Click here for more details.
- chuk_tool_processor/core/__init__.py +32 -1
- chuk_tool_processor/core/exceptions.py +225 -13
- chuk_tool_processor/core/processor.py +135 -104
- chuk_tool_processor/execution/strategies/__init__.py +6 -0
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +142 -150
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +202 -206
- chuk_tool_processor/execution/tool_executor.py +82 -84
- chuk_tool_processor/execution/wrappers/__init__.py +42 -0
- chuk_tool_processor/execution/wrappers/caching.py +150 -116
- chuk_tool_processor/execution/wrappers/circuit_breaker.py +370 -0
- chuk_tool_processor/execution/wrappers/rate_limiting.py +76 -43
- chuk_tool_processor/execution/wrappers/retry.py +116 -78
- chuk_tool_processor/logging/__init__.py +23 -17
- chuk_tool_processor/logging/context.py +40 -45
- chuk_tool_processor/logging/formatter.py +22 -21
- chuk_tool_processor/logging/helpers.py +28 -42
- chuk_tool_processor/logging/metrics.py +13 -15
- chuk_tool_processor/mcp/__init__.py +8 -12
- chuk_tool_processor/mcp/mcp_tool.py +158 -114
- chuk_tool_processor/mcp/register_mcp_tools.py +22 -22
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +57 -17
- chuk_tool_processor/mcp/setup_mcp_sse.py +57 -17
- chuk_tool_processor/mcp/setup_mcp_stdio.py +11 -11
- chuk_tool_processor/mcp/stream_manager.py +333 -276
- chuk_tool_processor/mcp/transport/__init__.py +22 -29
- chuk_tool_processor/mcp/transport/base_transport.py +180 -44
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +505 -325
- chuk_tool_processor/mcp/transport/models.py +100 -0
- chuk_tool_processor/mcp/transport/sse_transport.py +607 -276
- chuk_tool_processor/mcp/transport/stdio_transport.py +597 -116
- chuk_tool_processor/models/__init__.py +21 -1
- chuk_tool_processor/models/execution_strategy.py +16 -21
- chuk_tool_processor/models/streaming_tool.py +28 -25
- chuk_tool_processor/models/tool_call.py +49 -31
- chuk_tool_processor/models/tool_export_mixin.py +22 -8
- chuk_tool_processor/models/tool_result.py +40 -77
- chuk_tool_processor/models/tool_spec.py +350 -0
- chuk_tool_processor/models/validated_tool.py +36 -18
- chuk_tool_processor/observability/__init__.py +30 -0
- chuk_tool_processor/observability/metrics.py +312 -0
- chuk_tool_processor/observability/setup.py +105 -0
- chuk_tool_processor/observability/tracing.py +345 -0
- chuk_tool_processor/plugins/__init__.py +1 -1
- chuk_tool_processor/plugins/discovery.py +11 -11
- chuk_tool_processor/plugins/parsers/__init__.py +1 -1
- chuk_tool_processor/plugins/parsers/base.py +1 -2
- chuk_tool_processor/plugins/parsers/function_call_tool.py +13 -8
- chuk_tool_processor/plugins/parsers/json_tool.py +4 -3
- chuk_tool_processor/plugins/parsers/openai_tool.py +12 -7
- chuk_tool_processor/plugins/parsers/xml_tool.py +4 -4
- chuk_tool_processor/registry/__init__.py +12 -12
- chuk_tool_processor/registry/auto_register.py +22 -30
- chuk_tool_processor/registry/decorators.py +127 -129
- chuk_tool_processor/registry/interface.py +26 -23
- chuk_tool_processor/registry/metadata.py +27 -22
- chuk_tool_processor/registry/provider.py +17 -18
- chuk_tool_processor/registry/providers/__init__.py +16 -19
- chuk_tool_processor/registry/providers/memory.py +18 -25
- chuk_tool_processor/registry/tool_export.py +42 -51
- chuk_tool_processor/utils/validation.py +15 -16
- chuk_tool_processor-0.9.7.dist-info/METADATA +1813 -0
- chuk_tool_processor-0.9.7.dist-info/RECORD +67 -0
- chuk_tool_processor-0.6.4.dist-info/METADATA +0 -697
- chuk_tool_processor-0.6.4.dist-info/RECORD +0 -60
- {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.6.4.dist-info → chuk_tool_processor-0.9.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# chuk_tool_processor/execution/wrappers/circuit_breaker.py
|
|
2
|
+
"""
|
|
3
|
+
Circuit breaker pattern for tool execution.
|
|
4
|
+
|
|
5
|
+
Prevents cascading failures by tracking failure rates and temporarily
|
|
6
|
+
blocking calls to failing tools. Implements a state machine:
|
|
7
|
+
|
|
8
|
+
CLOSED → OPEN → HALF_OPEN → CLOSED (or back to OPEN)
|
|
9
|
+
|
|
10
|
+
States:
|
|
11
|
+
- CLOSED: Normal operation, requests pass through
|
|
12
|
+
- OPEN: Too many failures, requests blocked immediately
|
|
13
|
+
- HALF_OPEN: Testing if service recovered, limited requests allowed
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import time
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from chuk_tool_processor.core.exceptions import ToolCircuitOpenError
|
|
25
|
+
from chuk_tool_processor.logging import get_logger
|
|
26
|
+
from chuk_tool_processor.models.tool_call import ToolCall
|
|
27
|
+
from chuk_tool_processor.models.tool_result import ToolResult
|
|
28
|
+
|
|
29
|
+
logger = get_logger("chuk_tool_processor.execution.wrappers.circuit_breaker")
|
|
30
|
+
|
|
31
|
+
# Optional observability imports
|
|
32
|
+
try:
|
|
33
|
+
from chuk_tool_processor.observability.metrics import get_metrics
|
|
34
|
+
from chuk_tool_processor.observability.tracing import trace_circuit_breaker
|
|
35
|
+
|
|
36
|
+
_observability_available = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
_observability_available = False
|
|
39
|
+
|
|
40
|
+
# No-op functions when observability not available
|
|
41
|
+
def get_metrics():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def trace_circuit_breaker(*_args, **_kwargs):
|
|
45
|
+
from contextlib import nullcontext
|
|
46
|
+
|
|
47
|
+
return nullcontext()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
# Circuit breaker state
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
class CircuitState(str, Enum):
|
|
54
|
+
"""Circuit breaker states."""
|
|
55
|
+
|
|
56
|
+
CLOSED = "closed" # Normal operation
|
|
57
|
+
OPEN = "open" # Blocking requests due to failures
|
|
58
|
+
HALF_OPEN = "half_open" # Testing recovery with limited requests
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CircuitBreakerConfig:
|
|
62
|
+
"""Configuration for circuit breaker behavior."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
failure_threshold: int = 5,
|
|
67
|
+
success_threshold: int = 2,
|
|
68
|
+
reset_timeout: float = 60.0,
|
|
69
|
+
half_open_max_calls: int = 1,
|
|
70
|
+
timeout_threshold: float | None = None,
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Initialize circuit breaker configuration.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
failure_threshold: Number of failures before opening circuit
|
|
77
|
+
success_threshold: Number of successes in HALF_OPEN to close circuit
|
|
78
|
+
reset_timeout: Seconds to wait before trying HALF_OPEN
|
|
79
|
+
half_open_max_calls: Max concurrent calls in HALF_OPEN state
|
|
80
|
+
timeout_threshold: Optional timeout (s) to consider as failure
|
|
81
|
+
"""
|
|
82
|
+
self.failure_threshold = failure_threshold
|
|
83
|
+
self.success_threshold = success_threshold
|
|
84
|
+
self.reset_timeout = reset_timeout
|
|
85
|
+
self.half_open_max_calls = half_open_max_calls
|
|
86
|
+
self.timeout_threshold = timeout_threshold
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CircuitBreakerState:
|
|
90
|
+
"""Per-tool circuit breaker state tracking."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, config: CircuitBreakerConfig):
|
|
93
|
+
self.config = config
|
|
94
|
+
self.state = CircuitState.CLOSED
|
|
95
|
+
self.failure_count = 0
|
|
96
|
+
self.success_count = 0
|
|
97
|
+
self.last_failure_time: float | None = None
|
|
98
|
+
self.opened_at: float | None = None
|
|
99
|
+
self.half_open_calls = 0
|
|
100
|
+
self._lock = asyncio.Lock()
|
|
101
|
+
|
|
102
|
+
async def record_success(self) -> None:
|
|
103
|
+
"""Record a successful call."""
|
|
104
|
+
async with self._lock:
|
|
105
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
106
|
+
self.success_count += 1
|
|
107
|
+
logger.debug(f"Circuit HALF_OPEN: success {self.success_count}/{self.config.success_threshold}")
|
|
108
|
+
|
|
109
|
+
# Enough successes? Close the circuit
|
|
110
|
+
if self.success_count >= self.config.success_threshold:
|
|
111
|
+
logger.info("Circuit breaker: Transitioning to CLOSED (service recovered)")
|
|
112
|
+
self.state = CircuitState.CLOSED
|
|
113
|
+
self.failure_count = 0
|
|
114
|
+
self.success_count = 0
|
|
115
|
+
self.opened_at = None
|
|
116
|
+
self.half_open_calls = 0
|
|
117
|
+
else:
|
|
118
|
+
# In CLOSED state, just reset failure count
|
|
119
|
+
self.failure_count = 0
|
|
120
|
+
|
|
121
|
+
async def record_failure(self) -> None:
|
|
122
|
+
"""Record a failed call."""
|
|
123
|
+
async with self._lock:
|
|
124
|
+
self.failure_count += 1
|
|
125
|
+
self.last_failure_time = time.monotonic()
|
|
126
|
+
logger.debug(f"Circuit: failure {self.failure_count}/{self.config.failure_threshold}")
|
|
127
|
+
|
|
128
|
+
if self.state == CircuitState.CLOSED:
|
|
129
|
+
# Check if we should open
|
|
130
|
+
if self.failure_count >= self.config.failure_threshold:
|
|
131
|
+
logger.warning(f"Circuit breaker: OPENING after {self.failure_count} failures")
|
|
132
|
+
self.state = CircuitState.OPEN
|
|
133
|
+
self.opened_at = time.monotonic()
|
|
134
|
+
elif self.state == CircuitState.HALF_OPEN:
|
|
135
|
+
# Failed during test → back to OPEN
|
|
136
|
+
logger.warning("Circuit breaker: Back to OPEN (test failed)")
|
|
137
|
+
self.state = CircuitState.OPEN
|
|
138
|
+
self.success_count = 0
|
|
139
|
+
self.opened_at = time.monotonic()
|
|
140
|
+
self.half_open_calls = 0
|
|
141
|
+
|
|
142
|
+
async def can_execute(self) -> bool:
|
|
143
|
+
"""Check if a call should be allowed through."""
|
|
144
|
+
async with self._lock:
|
|
145
|
+
if self.state == CircuitState.CLOSED:
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
149
|
+
# Limit concurrent calls in HALF_OPEN
|
|
150
|
+
if self.half_open_calls < self.config.half_open_max_calls:
|
|
151
|
+
self.half_open_calls += 1
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
# OPEN state: check if we should try HALF_OPEN
|
|
156
|
+
if self.opened_at is not None:
|
|
157
|
+
elapsed = time.monotonic() - self.opened_at
|
|
158
|
+
if elapsed >= self.config.reset_timeout:
|
|
159
|
+
logger.info("Circuit breaker: Transitioning to HALF_OPEN (testing recovery)")
|
|
160
|
+
self.state = CircuitState.HALF_OPEN
|
|
161
|
+
self.half_open_calls = 1
|
|
162
|
+
self.success_count = 0
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
async def release_half_open_slot(self) -> None:
|
|
168
|
+
"""Release a HALF_OPEN slot after call completes."""
|
|
169
|
+
async with self._lock:
|
|
170
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
171
|
+
self.half_open_calls = max(0, self.half_open_calls - 1)
|
|
172
|
+
|
|
173
|
+
def get_state(self) -> dict[str, Any]:
|
|
174
|
+
"""Get current state as dict."""
|
|
175
|
+
return {
|
|
176
|
+
"state": self.state.value,
|
|
177
|
+
"failure_count": self.failure_count,
|
|
178
|
+
"success_count": self.success_count,
|
|
179
|
+
"opened_at": self.opened_at,
|
|
180
|
+
"time_until_half_open": (
|
|
181
|
+
max(0, self.config.reset_timeout - (time.monotonic() - self.opened_at))
|
|
182
|
+
if self.opened_at and self.state == CircuitState.OPEN
|
|
183
|
+
else None
|
|
184
|
+
),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --------------------------------------------------------------------------- #
|
|
189
|
+
# Circuit breaker executor wrapper
|
|
190
|
+
# --------------------------------------------------------------------------- #
|
|
191
|
+
class CircuitBreakerExecutor:
|
|
192
|
+
"""
|
|
193
|
+
Executor wrapper that implements circuit breaker pattern.
|
|
194
|
+
|
|
195
|
+
Tracks failures per tool and opens circuit breakers to prevent
|
|
196
|
+
cascading failures when tools are consistently failing.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
executor: Any,
|
|
202
|
+
*,
|
|
203
|
+
default_config: CircuitBreakerConfig | None = None,
|
|
204
|
+
tool_configs: dict[str, CircuitBreakerConfig] | None = None,
|
|
205
|
+
):
|
|
206
|
+
"""
|
|
207
|
+
Initialize circuit breaker executor.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
executor: Underlying executor to wrap
|
|
211
|
+
default_config: Default circuit breaker configuration
|
|
212
|
+
tool_configs: Per-tool circuit breaker configurations
|
|
213
|
+
"""
|
|
214
|
+
self.executor = executor
|
|
215
|
+
self.default_config = default_config or CircuitBreakerConfig()
|
|
216
|
+
self.tool_configs = tool_configs or {}
|
|
217
|
+
self._states: dict[str, CircuitBreakerState] = {}
|
|
218
|
+
self._states_lock = asyncio.Lock()
|
|
219
|
+
|
|
220
|
+
async def _get_state(self, tool: str) -> CircuitBreakerState:
|
|
221
|
+
"""Get or create circuit breaker state for a tool."""
|
|
222
|
+
if tool not in self._states:
|
|
223
|
+
async with self._states_lock:
|
|
224
|
+
if tool not in self._states:
|
|
225
|
+
config = self.tool_configs.get(tool, self.default_config)
|
|
226
|
+
self._states[tool] = CircuitBreakerState(config)
|
|
227
|
+
return self._states[tool]
|
|
228
|
+
|
|
229
|
+
async def execute(
|
|
230
|
+
self,
|
|
231
|
+
calls: list[ToolCall],
|
|
232
|
+
*,
|
|
233
|
+
timeout: float | None = None,
|
|
234
|
+
use_cache: bool = True,
|
|
235
|
+
) -> list[ToolResult]:
|
|
236
|
+
"""
|
|
237
|
+
Execute tool calls with circuit breaker protection.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
calls: List of tool calls to execute
|
|
241
|
+
timeout: Optional timeout for execution
|
|
242
|
+
use_cache: Whether to use cached results
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
List of tool results
|
|
246
|
+
"""
|
|
247
|
+
if not calls:
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
results: list[ToolResult] = []
|
|
251
|
+
|
|
252
|
+
for call in calls:
|
|
253
|
+
state = await self._get_state(call.tool)
|
|
254
|
+
|
|
255
|
+
# Record circuit breaker state
|
|
256
|
+
metrics = get_metrics()
|
|
257
|
+
if metrics:
|
|
258
|
+
metrics.record_circuit_breaker_state(call.tool, state.state.value)
|
|
259
|
+
|
|
260
|
+
# Check if circuit allows execution with tracing
|
|
261
|
+
with trace_circuit_breaker(call.tool, state.state.value):
|
|
262
|
+
can_execute = await state.can_execute()
|
|
263
|
+
|
|
264
|
+
if not can_execute:
|
|
265
|
+
# Circuit is OPEN - reject immediately
|
|
266
|
+
state_info = state.get_state()
|
|
267
|
+
logger.warning(f"Circuit breaker OPEN for {call.tool} (failures: {state.failure_count})")
|
|
268
|
+
|
|
269
|
+
reset_time = state_info.get("time_until_half_open")
|
|
270
|
+
error = ToolCircuitOpenError(
|
|
271
|
+
tool_name=call.tool,
|
|
272
|
+
failure_count=state.failure_count,
|
|
273
|
+
reset_timeout=reset_time,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
now = datetime.now(UTC)
|
|
277
|
+
results.append(
|
|
278
|
+
ToolResult(
|
|
279
|
+
tool=call.tool,
|
|
280
|
+
result=None,
|
|
281
|
+
error=str(error),
|
|
282
|
+
start_time=now,
|
|
283
|
+
end_time=now,
|
|
284
|
+
machine="circuit_breaker",
|
|
285
|
+
pid=0,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
# Execute the call
|
|
291
|
+
start_time = time.monotonic()
|
|
292
|
+
try:
|
|
293
|
+
# Execute single call
|
|
294
|
+
executor_kwargs = {"timeout": timeout}
|
|
295
|
+
if hasattr(self.executor, "use_cache"):
|
|
296
|
+
executor_kwargs["use_cache"] = use_cache
|
|
297
|
+
|
|
298
|
+
result_list = await self.executor.execute([call], **executor_kwargs)
|
|
299
|
+
result = result_list[0]
|
|
300
|
+
|
|
301
|
+
# Check if successful
|
|
302
|
+
duration = time.monotonic() - start_time
|
|
303
|
+
|
|
304
|
+
# Determine success/failure
|
|
305
|
+
is_timeout = state.config.timeout_threshold is not None and duration > state.config.timeout_threshold
|
|
306
|
+
is_error = result.error is not None
|
|
307
|
+
|
|
308
|
+
if is_error or is_timeout:
|
|
309
|
+
await state.record_failure()
|
|
310
|
+
# Record circuit breaker failure metric
|
|
311
|
+
if metrics:
|
|
312
|
+
metrics.record_circuit_breaker_failure(call.tool)
|
|
313
|
+
else:
|
|
314
|
+
await state.record_success()
|
|
315
|
+
|
|
316
|
+
results.append(result)
|
|
317
|
+
|
|
318
|
+
except Exception as e:
|
|
319
|
+
# Exception during execution
|
|
320
|
+
await state.record_failure()
|
|
321
|
+
|
|
322
|
+
now = datetime.now(UTC)
|
|
323
|
+
results.append(
|
|
324
|
+
ToolResult(
|
|
325
|
+
tool=call.tool,
|
|
326
|
+
result=None,
|
|
327
|
+
error=f"Circuit breaker caught exception: {str(e)}",
|
|
328
|
+
start_time=now,
|
|
329
|
+
end_time=now,
|
|
330
|
+
machine="circuit_breaker",
|
|
331
|
+
pid=0,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
finally:
|
|
336
|
+
# Release HALF_OPEN slot if applicable
|
|
337
|
+
if state.state == CircuitState.HALF_OPEN:
|
|
338
|
+
await state.release_half_open_slot()
|
|
339
|
+
|
|
340
|
+
return results
|
|
341
|
+
|
|
342
|
+
async def get_circuit_states(self) -> dict[str, dict[str, Any]]:
|
|
343
|
+
"""
|
|
344
|
+
Get current state of all circuit breakers.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Dict mapping tool name to state info
|
|
348
|
+
"""
|
|
349
|
+
states = {}
|
|
350
|
+
async with self._states_lock:
|
|
351
|
+
for tool, state in self._states.items():
|
|
352
|
+
states[tool] = state.get_state()
|
|
353
|
+
return states
|
|
354
|
+
|
|
355
|
+
async def reset_circuit(self, tool: str) -> None:
|
|
356
|
+
"""
|
|
357
|
+
Manually reset a circuit breaker.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
tool: Tool name to reset
|
|
361
|
+
"""
|
|
362
|
+
if tool in self._states:
|
|
363
|
+
state = self._states[tool]
|
|
364
|
+
async with state._lock:
|
|
365
|
+
state.state = CircuitState.CLOSED
|
|
366
|
+
state.failure_count = 0
|
|
367
|
+
state.success_count = 0
|
|
368
|
+
state.opened_at = None
|
|
369
|
+
state.half_open_calls = 0
|
|
370
|
+
logger.info(f"Manually reset circuit breaker for {tool}")
|
|
@@ -7,44 +7,64 @@ Two layers of limits are enforced:
|
|
|
7
7
|
* **Global** - ``<N requests> / <period>`` over *all* tools.
|
|
8
8
|
* **Per-tool** - independent ``<N requests> / <period>`` windows.
|
|
9
9
|
|
|
10
|
-
A simple sliding-window algorithm with timestamp queues is used.
|
|
10
|
+
A simple sliding-window algorithm with timestamp queues is used.
|
|
11
11
|
`asyncio.Lock` guards shared state so the wrapper can be used safely from
|
|
12
12
|
multiple coroutines.
|
|
13
13
|
"""
|
|
14
|
+
|
|
14
15
|
from __future__ import annotations
|
|
15
16
|
|
|
16
17
|
import asyncio
|
|
17
18
|
import inspect
|
|
18
19
|
import time
|
|
19
|
-
from typing import Any
|
|
20
|
+
from typing import Any
|
|
20
21
|
|
|
22
|
+
from chuk_tool_processor.logging import get_logger
|
|
21
23
|
from chuk_tool_processor.models.tool_call import ToolCall
|
|
22
24
|
from chuk_tool_processor.models.tool_result import ToolResult
|
|
23
|
-
from chuk_tool_processor.logging import get_logger
|
|
24
25
|
|
|
25
26
|
logger = get_logger("chuk_tool_processor.execution.wrappers.rate_limiting")
|
|
26
27
|
|
|
28
|
+
# Optional observability imports
|
|
29
|
+
try:
|
|
30
|
+
from chuk_tool_processor.observability.metrics import get_metrics
|
|
31
|
+
from chuk_tool_processor.observability.tracing import trace_rate_limit
|
|
32
|
+
|
|
33
|
+
_observability_available = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
_observability_available = False
|
|
36
|
+
|
|
37
|
+
# No-op functions when observability not available
|
|
38
|
+
def get_metrics():
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def trace_rate_limit(*_args, **_kwargs):
|
|
42
|
+
from contextlib import nullcontext
|
|
43
|
+
|
|
44
|
+
return nullcontext()
|
|
45
|
+
|
|
46
|
+
|
|
27
47
|
# --------------------------------------------------------------------------- #
|
|
28
48
|
# Core limiter
|
|
29
49
|
# --------------------------------------------------------------------------- #
|
|
30
50
|
class RateLimiter:
|
|
31
51
|
"""
|
|
32
52
|
Async-native rate limiter for controlling execution frequency.
|
|
33
|
-
|
|
53
|
+
|
|
34
54
|
Implements a sliding window algorithm to enforce rate limits both globally
|
|
35
55
|
and per-tool. All operations are thread-safe using asyncio locks.
|
|
36
56
|
"""
|
|
37
|
-
|
|
57
|
+
|
|
38
58
|
def __init__(
|
|
39
59
|
self,
|
|
40
60
|
*,
|
|
41
|
-
global_limit:
|
|
61
|
+
global_limit: int | None = None,
|
|
42
62
|
global_period: float = 60.0,
|
|
43
|
-
tool_limits:
|
|
63
|
+
tool_limits: dict[str, tuple[int, float]] | None = None,
|
|
44
64
|
) -> None:
|
|
45
65
|
"""
|
|
46
66
|
Initialize the rate limiter.
|
|
47
|
-
|
|
67
|
+
|
|
48
68
|
Args:
|
|
49
69
|
global_limit: Maximum global requests per period (None = no limit)
|
|
50
70
|
global_period: Time period in seconds for the global limit
|
|
@@ -55,13 +75,13 @@ class RateLimiter:
|
|
|
55
75
|
self.tool_limits = tool_limits or {}
|
|
56
76
|
|
|
57
77
|
# Timestamp queues
|
|
58
|
-
self._global_ts:
|
|
59
|
-
self._tool_ts:
|
|
78
|
+
self._global_ts: list[float] = []
|
|
79
|
+
self._tool_ts: dict[str, list[float]] = {}
|
|
60
80
|
|
|
61
81
|
# Locks for thread safety
|
|
62
82
|
self._global_lock = asyncio.Lock()
|
|
63
|
-
self._tool_locks:
|
|
64
|
-
|
|
83
|
+
self._tool_locks: dict[str, asyncio.Lock] = {}
|
|
84
|
+
|
|
65
85
|
logger.debug(
|
|
66
86
|
f"Initialized rate limiter: global={global_limit}/{global_period}s, "
|
|
67
87
|
f"tool-specific={len(self.tool_limits)} tools"
|
|
@@ -77,7 +97,7 @@ class RateLimiter:
|
|
|
77
97
|
async with self._global_lock:
|
|
78
98
|
now = time.monotonic()
|
|
79
99
|
cutoff = now - self.global_period
|
|
80
|
-
|
|
100
|
+
|
|
81
101
|
# Prune expired timestamps
|
|
82
102
|
self._global_ts = [t for t in self._global_ts if t > cutoff]
|
|
83
103
|
|
|
@@ -88,7 +108,7 @@ class RateLimiter:
|
|
|
88
108
|
|
|
89
109
|
# Calculate wait time until a slot becomes available
|
|
90
110
|
wait = (self._global_ts[0] + self.global_period) - now
|
|
91
|
-
|
|
111
|
+
|
|
92
112
|
logger.debug(f"Global rate limit reached, waiting {wait:.2f}s")
|
|
93
113
|
await asyncio.sleep(wait)
|
|
94
114
|
|
|
@@ -105,7 +125,7 @@ class RateLimiter:
|
|
|
105
125
|
async with lock:
|
|
106
126
|
now = time.monotonic()
|
|
107
127
|
cutoff = now - period
|
|
108
|
-
|
|
128
|
+
|
|
109
129
|
# Prune expired timestamps in-place
|
|
110
130
|
buf[:] = [t for t in buf if t > cutoff]
|
|
111
131
|
|
|
@@ -116,7 +136,7 @@ class RateLimiter:
|
|
|
116
136
|
|
|
117
137
|
# Calculate wait time until a slot becomes available
|
|
118
138
|
wait = (buf[0] + period) - now
|
|
119
|
-
|
|
139
|
+
|
|
120
140
|
logger.debug(f"Tool '{tool}' rate limit reached, waiting {wait:.2f}s")
|
|
121
141
|
await asyncio.sleep(wait)
|
|
122
142
|
|
|
@@ -124,32 +144,32 @@ class RateLimiter:
|
|
|
124
144
|
async def wait(self, tool: str) -> None:
|
|
125
145
|
"""
|
|
126
146
|
Block until rate limits allow execution.
|
|
127
|
-
|
|
147
|
+
|
|
128
148
|
This method blocks until both global and tool-specific rate limits
|
|
129
149
|
allow one more execution of the specified tool.
|
|
130
|
-
|
|
150
|
+
|
|
131
151
|
Args:
|
|
132
152
|
tool: Name of the tool being executed
|
|
133
153
|
"""
|
|
134
154
|
await self._acquire_global()
|
|
135
155
|
await self._acquire_tool(tool)
|
|
136
|
-
|
|
137
|
-
async def check_limits(self, tool: str) ->
|
|
156
|
+
|
|
157
|
+
async def check_limits(self, tool: str) -> tuple[bool, bool]:
|
|
138
158
|
"""
|
|
139
159
|
Check if the tool would be rate limited without consuming a slot.
|
|
140
|
-
|
|
160
|
+
|
|
141
161
|
This is a non-blocking method useful for checking limits without
|
|
142
162
|
affecting the rate limiting state.
|
|
143
|
-
|
|
163
|
+
|
|
144
164
|
Args:
|
|
145
165
|
tool: Name of the tool to check
|
|
146
|
-
|
|
166
|
+
|
|
147
167
|
Returns:
|
|
148
168
|
Tuple of (global_limit_reached, tool_limit_reached)
|
|
149
169
|
"""
|
|
150
170
|
global_limited = False
|
|
151
171
|
tool_limited = False
|
|
152
|
-
|
|
172
|
+
|
|
153
173
|
# Check global limit
|
|
154
174
|
if self.global_limit is not None:
|
|
155
175
|
async with self._global_lock:
|
|
@@ -157,7 +177,7 @@ class RateLimiter:
|
|
|
157
177
|
cutoff = now - self.global_period
|
|
158
178
|
active_ts = [t for t in self._global_ts if t > cutoff]
|
|
159
179
|
global_limited = len(active_ts) >= self.global_limit
|
|
160
|
-
|
|
180
|
+
|
|
161
181
|
# Check tool limit
|
|
162
182
|
if tool in self.tool_limits:
|
|
163
183
|
limit, period = self.tool_limits[tool]
|
|
@@ -167,7 +187,7 @@ class RateLimiter:
|
|
|
167
187
|
buf = self._tool_ts.get(tool, [])
|
|
168
188
|
active_ts = [t for t in buf if t > cutoff]
|
|
169
189
|
tool_limited = len(active_ts) >= limit
|
|
170
|
-
|
|
190
|
+
|
|
171
191
|
return global_limited, tool_limited
|
|
172
192
|
|
|
173
193
|
|
|
@@ -177,7 +197,7 @@ class RateLimiter:
|
|
|
177
197
|
class RateLimitedToolExecutor:
|
|
178
198
|
"""
|
|
179
199
|
Executor wrapper that applies rate limiting to tool executions.
|
|
180
|
-
|
|
200
|
+
|
|
181
201
|
This wrapper delegates to another executor but ensures that all
|
|
182
202
|
tool calls respect the configured rate limits.
|
|
183
203
|
"""
|
|
@@ -185,48 +205,60 @@ class RateLimitedToolExecutor:
|
|
|
185
205
|
def __init__(self, executor: Any, limiter: RateLimiter) -> None:
|
|
186
206
|
"""
|
|
187
207
|
Initialize the rate-limited executor.
|
|
188
|
-
|
|
208
|
+
|
|
189
209
|
Args:
|
|
190
210
|
executor: The underlying executor to wrap
|
|
191
211
|
limiter: The RateLimiter that controls execution frequency
|
|
192
212
|
"""
|
|
193
213
|
self.executor = executor
|
|
194
214
|
self.limiter = limiter
|
|
195
|
-
logger.debug(
|
|
215
|
+
logger.debug("Initialized rate-limited executor")
|
|
196
216
|
|
|
197
217
|
async def execute(
|
|
198
218
|
self,
|
|
199
|
-
calls:
|
|
200
|
-
timeout:
|
|
219
|
+
calls: list[ToolCall],
|
|
220
|
+
timeout: float | None = None,
|
|
201
221
|
use_cache: bool = True,
|
|
202
|
-
) ->
|
|
222
|
+
) -> list[ToolResult]:
|
|
203
223
|
"""
|
|
204
224
|
Execute tool calls while respecting rate limits.
|
|
205
|
-
|
|
225
|
+
|
|
206
226
|
This method blocks until rate limits allow execution, then delegates
|
|
207
227
|
to the underlying executor.
|
|
208
|
-
|
|
228
|
+
|
|
209
229
|
Args:
|
|
210
230
|
calls: List of tool calls to execute
|
|
211
231
|
timeout: Optional timeout for execution
|
|
212
232
|
use_cache: Whether to use cached results (forwarded to underlying executor)
|
|
213
|
-
|
|
233
|
+
|
|
214
234
|
Returns:
|
|
215
235
|
List of tool results
|
|
216
236
|
"""
|
|
217
237
|
if not calls:
|
|
218
238
|
return []
|
|
219
|
-
|
|
239
|
+
|
|
220
240
|
# Block for each call *before* dispatching to the wrapped executor
|
|
241
|
+
metrics = get_metrics()
|
|
242
|
+
|
|
221
243
|
for c in calls:
|
|
222
|
-
|
|
223
|
-
|
|
244
|
+
# Check limits first for metrics
|
|
245
|
+
global_limited, tool_limited = await self.limiter.check_limits(c.tool)
|
|
246
|
+
allowed = not (global_limited or tool_limited)
|
|
247
|
+
|
|
248
|
+
# Trace rate limit check
|
|
249
|
+
with trace_rate_limit(c.tool, allowed):
|
|
250
|
+
await self.limiter.wait(c.tool)
|
|
251
|
+
|
|
252
|
+
# Record metrics
|
|
253
|
+
if metrics:
|
|
254
|
+
metrics.record_rate_limit_check(c.tool, allowed)
|
|
255
|
+
|
|
224
256
|
# Check if the executor has a use_cache parameter
|
|
225
257
|
if hasattr(self.executor, "execute"):
|
|
226
258
|
sig = inspect.signature(self.executor.execute)
|
|
227
259
|
if "use_cache" in sig.parameters:
|
|
228
260
|
return await self.executor.execute(calls, timeout=timeout, use_cache=use_cache)
|
|
229
|
-
|
|
261
|
+
|
|
230
262
|
# Fall back to standard execute method
|
|
231
263
|
return await self.executor.execute(calls, timeout=timeout)
|
|
232
264
|
|
|
@@ -237,7 +269,7 @@ class RateLimitedToolExecutor:
|
|
|
237
269
|
def rate_limited(limit: int, period: float = 60.0):
|
|
238
270
|
"""
|
|
239
271
|
Class decorator that marks a Tool with default rate-limit metadata.
|
|
240
|
-
|
|
272
|
+
|
|
241
273
|
This allows higher-level code to detect and configure rate limiting
|
|
242
274
|
for the tool class.
|
|
243
275
|
|
|
@@ -246,17 +278,18 @@ def rate_limited(limit: int, period: float = 60.0):
|
|
|
246
278
|
class WeatherTool:
|
|
247
279
|
async def execute(self, location: str) -> Dict[str, Any]:
|
|
248
280
|
# Implementation
|
|
249
|
-
|
|
281
|
+
|
|
250
282
|
Args:
|
|
251
283
|
limit: Maximum number of calls allowed in the period
|
|
252
284
|
period: Time period in seconds
|
|
253
|
-
|
|
285
|
+
|
|
254
286
|
Returns:
|
|
255
287
|
Decorated class with rate limit metadata
|
|
256
288
|
"""
|
|
289
|
+
|
|
257
290
|
def decorator(cls):
|
|
258
291
|
cls._rate_limit = limit
|
|
259
292
|
cls._rate_period = period
|
|
260
293
|
return cls
|
|
261
294
|
|
|
262
|
-
return decorator
|
|
295
|
+
return decorator
|