chuk-tool-processor 0.8__py3-none-any.whl → 0.9__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.

@@ -29,6 +29,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
29
29
 
30
30
  logger = get_logger("chuk_tool_processor.execution.wrappers.caching")
31
31
 
32
+ # Optional observability imports
33
+ try:
34
+ from chuk_tool_processor.observability.metrics import get_metrics
35
+ from chuk_tool_processor.observability.tracing import trace_cache_operation
36
+
37
+ _observability_available = True
38
+ except ImportError:
39
+ _observability_available = False
40
+
41
+ # No-op functions when observability not available
42
+ def get_metrics():
43
+ return None
44
+
45
+ def trace_cache_operation(*_args, **_kwargs):
46
+ from contextlib import nullcontext
47
+
48
+ return nullcontext()
49
+
32
50
 
33
51
  # --------------------------------------------------------------------------- #
34
52
  # Cache primitives
@@ -430,7 +448,15 @@ class CachingToolExecutor:
430
448
 
431
449
  # Use idempotency_key if available, otherwise hash arguments
432
450
  cache_key = call.idempotency_key or self._hash_arguments(call.arguments)
433
- cached_val = await self.cache.get(call.tool, cache_key)
451
+
452
+ # Trace cache lookup operation
453
+ with trace_cache_operation("lookup", call.tool):
454
+ cached_val = await self.cache.get(call.tool, cache_key)
455
+
456
+ # Record metrics
457
+ metrics = get_metrics()
458
+ if metrics:
459
+ metrics.record_cache_operation(call.tool, "lookup", hit=(cached_val is not None))
434
460
 
435
461
  if cached_val is None:
436
462
  # Cache miss
@@ -481,6 +507,8 @@ class CachingToolExecutor:
481
507
  # ------------------------------------------------------------------
482
508
  if use_cache:
483
509
  cache_tasks = []
510
+ metrics = get_metrics()
511
+
484
512
  for (_idx, call), result in zip(uncached, uncached_results, strict=False):
485
513
  if result.error is None and self._is_cacheable(call.tool):
486
514
  ttl = self._ttl_for(call.tool)
@@ -489,14 +517,15 @@ class CachingToolExecutor:
489
517
  # Use idempotency_key if available, otherwise hash arguments
490
518
  cache_key = call.idempotency_key or self._hash_arguments(call.arguments)
491
519
 
492
- # Create task but don't await yet (for concurrent caching)
493
- task = self.cache.set(
494
- call.tool,
495
- cache_key,
496
- result.result,
497
- ttl=ttl,
498
- )
499
- cache_tasks.append(task)
520
+ # Trace and record cache set operation
521
+ # Bind loop variables to avoid B023 error
522
+ async def cache_with_trace(tool=call.tool, key=cache_key, value=result.result, ttl_val=ttl):
523
+ with trace_cache_operation("set", tool, attributes={"ttl": ttl_val}):
524
+ await self.cache.set(tool, key, value, ttl=ttl_val)
525
+ if metrics:
526
+ metrics.record_cache_operation(tool, "set")
527
+
528
+ cache_tasks.append(cache_with_trace())
500
529
 
501
530
  # Flag as non-cached so callers can tell
502
531
  if hasattr(result, "cached"):
@@ -28,6 +28,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
28
28
 
29
29
  logger = get_logger("chuk_tool_processor.execution.wrappers.circuit_breaker")
30
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
+
31
49
 
32
50
  # --------------------------------------------------------------------------- #
33
51
  # Circuit breaker state
@@ -234,8 +252,14 @@ class CircuitBreakerExecutor:
234
252
  for call in calls:
235
253
  state = await self._get_state(call.tool)
236
254
 
237
- # Check if circuit allows execution
238
- can_execute = await state.can_execute()
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()
239
263
 
240
264
  if not can_execute:
241
265
  # Circuit is OPEN - reject immediately
@@ -283,6 +307,9 @@ class CircuitBreakerExecutor:
283
307
 
284
308
  if is_error or is_timeout:
285
309
  await state.record_failure()
310
+ # Record circuit breaker failure metric
311
+ if metrics:
312
+ metrics.record_circuit_breaker_failure(call.tool)
286
313
  else:
287
314
  await state.record_success()
288
315
 
@@ -25,6 +25,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
25
25
 
26
26
  logger = get_logger("chuk_tool_processor.execution.wrappers.rate_limiting")
27
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
+
28
46
 
29
47
  # --------------------------------------------------------------------------- #
30
48
  # Core limiter
@@ -220,8 +238,20 @@ class RateLimitedToolExecutor:
220
238
  return []
221
239
 
222
240
  # Block for each call *before* dispatching to the wrapped executor
241
+ metrics = get_metrics()
242
+
223
243
  for c in calls:
224
- await self.limiter.wait(c.tool)
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)
225
255
 
226
256
  # Check if the executor has a use_cache parameter
227
257
  if hasattr(self.executor, "execute"):
@@ -21,6 +21,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
21
21
 
22
22
  logger = get_logger("chuk_tool_processor.execution.wrappers.retry")
23
23
 
24
+ # Optional observability imports
25
+ try:
26
+ from chuk_tool_processor.observability.metrics import get_metrics
27
+ from chuk_tool_processor.observability.tracing import trace_retry_attempt
28
+
29
+ _observability_available = True
30
+ except ImportError:
31
+ _observability_available = False
32
+
33
+ # No-op functions when observability not available
34
+ def get_metrics():
35
+ return None
36
+
37
+ def trace_retry_attempt(*_args, **_kwargs):
38
+ from contextlib import nullcontext
39
+
40
+ return nullcontext()
41
+
24
42
 
25
43
  # --------------------------------------------------------------------------- #
26
44
  # Retry configuration
@@ -177,63 +195,73 @@ class RetryableToolExecutor:
177
195
  # Execute one attempt
178
196
  # ---------------------------------------------------------------- #
179
197
  start_time = datetime.now(UTC)
180
- try:
181
- kwargs = {"timeout": remaining} if remaining is not None else {}
182
- if hasattr(self.executor, "use_cache"):
183
- kwargs["use_cache"] = use_cache
184
-
185
- result = (await self.executor.execute([call], **kwargs))[0]
186
- pid = result.pid
187
- machine = result.machine
188
198
 
189
- # Success?
190
- if not result.error:
199
+ # Trace retry attempt
200
+ with trace_retry_attempt(call.tool, attempt, cfg.max_retries):
201
+ try:
202
+ kwargs = {"timeout": remaining} if remaining is not None else {}
203
+ if hasattr(self.executor, "use_cache"):
204
+ kwargs["use_cache"] = use_cache
205
+
206
+ result = (await self.executor.execute([call], **kwargs))[0]
207
+ pid = result.pid
208
+ machine = result.machine
209
+
210
+ # Record retry metrics
211
+ metrics = get_metrics()
212
+ success = result.error is None
213
+
214
+ if metrics:
215
+ metrics.record_retry_attempt(call.tool, attempt, success)
216
+
217
+ # Success?
218
+ if success:
219
+ result.attempts = attempt + 1
220
+ return result
221
+
222
+ # Error: decide on retry
223
+ last_error = result.error
224
+ if cfg.should_retry(attempt, error_str=result.error):
225
+ delay = cfg.get_delay(attempt)
226
+ # never overshoot the deadline
227
+ if deadline is not None:
228
+ delay = min(delay, max(deadline - time.monotonic(), 0))
229
+ if delay:
230
+ await asyncio.sleep(delay)
231
+ attempt += 1
232
+ continue
233
+
234
+ # No more retries wanted
235
+ result.error = self._wrap_error(last_error, attempt, cfg)
191
236
  result.attempts = attempt + 1
192
237
  return result
193
238
 
194
- # Error: decide on retry
195
- last_error = result.error
196
- if cfg.should_retry(attempt, error_str=result.error):
197
- delay = cfg.get_delay(attempt)
198
- # never overshoot the deadline
199
- if deadline is not None:
200
- delay = min(delay, max(deadline - time.monotonic(), 0))
201
- if delay:
202
- await asyncio.sleep(delay)
203
- attempt += 1
204
- continue
205
-
206
- # No more retries wanted
207
- result.error = self._wrap_error(last_error, attempt, cfg)
208
- result.attempts = attempt + 1
209
- return result
210
-
211
- # ---------------------------------------------------------------- #
212
- # Exception path
213
- # ---------------------------------------------------------------- #
214
- except Exception as exc: # noqa: BLE001
215
- err_str = str(exc)
216
- last_error = err_str
217
- if cfg.should_retry(attempt, error=exc):
218
- delay = cfg.get_delay(attempt)
219
- if deadline is not None:
220
- delay = min(delay, max(deadline - time.monotonic(), 0))
221
- if delay:
222
- await asyncio.sleep(delay)
223
- attempt += 1
224
- continue
225
-
226
- end_time = datetime.now(UTC)
227
- return ToolResult(
228
- tool=call.tool,
229
- result=None,
230
- error=self._wrap_error(err_str, attempt, cfg),
231
- start_time=start_time,
232
- end_time=end_time,
233
- machine=machine,
234
- pid=pid,
235
- attempts=attempt + 1,
236
- )
239
+ # ---------------------------------------------------------------- #
240
+ # Exception path
241
+ # ---------------------------------------------------------------- #
242
+ except Exception as exc: # noqa: BLE001
243
+ err_str = str(exc)
244
+ last_error = err_str
245
+ if cfg.should_retry(attempt, error=exc, error_str=err_str):
246
+ delay = cfg.get_delay(attempt)
247
+ if deadline is not None:
248
+ delay = min(delay, max(deadline - time.monotonic(), 0))
249
+ if delay:
250
+ await asyncio.sleep(delay)
251
+ attempt += 1
252
+ continue
253
+
254
+ end_time = datetime.now(UTC)
255
+ return ToolResult(
256
+ tool=call.tool,
257
+ result=None,
258
+ error=self._wrap_error(err_str, attempt, cfg),
259
+ start_time=start_time,
260
+ end_time=end_time,
261
+ machine=machine,
262
+ pid=pid,
263
+ attempts=attempt + 1,
264
+ )
237
265
 
238
266
  # --------------------------------------------------------------------- #
239
267
  # Helpers
@@ -110,10 +110,17 @@ async def setup_mcp_http_streamable(
110
110
 
111
111
  # Define OAuth error patterns that should NOT be retried at this level
112
112
  # These will be handled by the transport layer's OAuth refresh mechanism
113
+ # Based on RFC 6750 (Bearer Token Usage) and MCP OAuth spec
113
114
  oauth_error_patterns = [
114
- "invalid_token",
115
+ # RFC 6750 Section 3.1 - Standard Bearer token errors
116
+ "invalid_token", # Token expired, revoked, malformed, or invalid
117
+ "insufficient_scope", # Request requires higher privileges (403 Forbidden)
118
+ # OAuth 2.1 token refresh errors
119
+ "invalid_grant", # Refresh token errors
120
+ # MCP spec - OAuth validation failures (401 Unauthorized)
115
121
  "oauth validation",
116
122
  "unauthorized",
123
+ # Common OAuth error descriptions
117
124
  "expired token",
118
125
  "token expired",
119
126
  "authentication failed",
@@ -89,10 +89,17 @@ async def setup_mcp_sse( # noqa: C901 - long but just a config facade
89
89
 
90
90
  # Define OAuth error patterns that should NOT be retried at this level
91
91
  # These will be handled by the transport layer's OAuth refresh mechanism
92
+ # Based on RFC 6750 (Bearer Token Usage) and MCP OAuth spec
92
93
  oauth_error_patterns = [
93
- "invalid_token",
94
+ # RFC 6750 Section 3.1 - Standard Bearer token errors
95
+ "invalid_token", # Token expired, revoked, malformed, or invalid
96
+ "insufficient_scope", # Request requires higher privileges (403 Forbidden)
97
+ # OAuth 2.1 token refresh errors
98
+ "invalid_grant", # Refresh token errors
99
+ # MCP spec - OAuth validation failures (401 Unauthorized)
94
100
  "oauth validation",
95
101
  "unauthorized",
102
+ # Common OAuth error descriptions
96
103
  "expired token",
97
104
  "token expired",
98
105
  "authentication failed",
@@ -519,16 +519,29 @@ class HTTPStreamableTransport(MCPBaseTransport):
519
519
  self._metrics.update_call_metrics(response_time, success)
520
520
 
521
521
  def _is_oauth_error(self, error_msg: str) -> bool:
522
- """Detect if error is OAuth-related (NEW)."""
522
+ """
523
+ Detect if error is OAuth-related per RFC 6750 and MCP OAuth spec.
524
+
525
+ Checks for:
526
+ - RFC 6750 Section 3.1 Bearer token errors (invalid_token, insufficient_scope)
527
+ - OAuth 2.1 token refresh errors (invalid_grant)
528
+ - MCP spec OAuth validation failures (401/403 responses)
529
+ """
523
530
  if not error_msg:
524
531
  return False
525
532
 
526
533
  error_lower = error_msg.lower()
527
534
  oauth_indicators = [
528
- "invalid_token",
529
- "expired token",
535
+ # RFC 6750 Section 3.1 - Standard Bearer token errors
536
+ "invalid_token", # Token expired, revoked, malformed, or invalid
537
+ "insufficient_scope", # Request requires higher privileges (403 Forbidden)
538
+ # OAuth 2.1 token refresh errors
539
+ "invalid_grant", # Refresh token errors
540
+ # MCP spec - OAuth validation failures (401 Unauthorized)
530
541
  "oauth validation",
531
542
  "unauthorized",
543
+ # Common OAuth error descriptions
544
+ "expired token",
532
545
  "token expired",
533
546
  "authentication failed",
534
547
  "invalid access token",
@@ -596,16 +596,29 @@ class SSETransport(MCPBaseTransport):
596
596
  self._metrics.update_call_metrics(response_time, success)
597
597
 
598
598
  def _is_oauth_error(self, error_msg: str) -> bool:
599
- """Detect if error is OAuth-related (NEW)."""
599
+ """
600
+ Detect if error is OAuth-related per RFC 6750 and MCP OAuth spec.
601
+
602
+ Checks for:
603
+ - RFC 6750 Section 3.1 Bearer token errors (invalid_token, insufficient_scope)
604
+ - OAuth 2.1 token refresh errors (invalid_grant)
605
+ - MCP spec OAuth validation failures (401/403 responses)
606
+ """
600
607
  if not error_msg:
601
608
  return False
602
609
 
603
610
  error_lower = error_msg.lower()
604
611
  oauth_indicators = [
605
- "invalid_token",
606
- "expired token",
612
+ # RFC 6750 Section 3.1 - Standard Bearer token errors
613
+ "invalid_token", # Token expired, revoked, malformed, or invalid
614
+ "insufficient_scope", # Request requires higher privileges (403 Forbidden)
615
+ # OAuth 2.1 token refresh errors
616
+ "invalid_grant", # Refresh token errors
617
+ # MCP spec - OAuth validation failures (401 Unauthorized)
607
618
  "oauth validation",
608
619
  "unauthorized",
620
+ # Common OAuth error descriptions
621
+ "expired token",
609
622
  "token expired",
610
623
  "authentication failed",
611
624
  "invalid access token",
@@ -0,0 +1,30 @@
1
+ """
2
+ OpenTelemetry observability integration for chuk-tool-processor.
3
+
4
+ This module provides drop-in OpenTelemetry tracing and Prometheus metrics
5
+ for tool execution, making it trivial to instrument your tool pipeline.
6
+
7
+ Example:
8
+ from chuk_tool_processor.observability import setup_observability
9
+
10
+ # Enable both tracing and metrics
11
+ setup_observability(
12
+ service_name="my-tool-service",
13
+ enable_tracing=True,
14
+ enable_metrics=True,
15
+ metrics_port=9090
16
+ )
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .metrics import PrometheusMetrics, get_metrics
22
+ from .setup import setup_observability
23
+ from .tracing import get_tracer
24
+
25
+ __all__ = [
26
+ "setup_observability",
27
+ "get_tracer",
28
+ "get_metrics",
29
+ "PrometheusMetrics",
30
+ ]