# Improvements to Arcade TDK Error Handling
I tried my very best to not make any breaking changes in this PR. So,
you will notice various "Deprecation" notices throughout.
### Instructions for PR reviewers
1. Pull down this PR's branch
2. Pull down the Engine's tool error handling PR's branch
3. Update your installed arcadepy to have the following:
- In `arcadepy/resources/tools/tools.py`, if you want to test out
including stacktraces, then you need to update `ToolsResource.execute`
to accept a `include_error_stacktrace` argument and also include the
"include_error_stacktrace" argument to the POST to the Engine inside of
the function's execute method's body.
- In `arcadepy/types/execute_tool_response.py` add the following enum
```py
class ErrorKind(str, Enum):
"""Error kind that is comprised of
- the who (toolkit, tool, upstream)
- the when (load time, definition parsing time, runtime)
- the what (bad_definition, bad_input, bad_output, retry,
context_required, fatal, etc.)"""
TOOLKIT_LOAD_FAILED = "TOOLKIT_LOAD_FAILED"
TOOL_DEFINITION_BAD_DEFINITION = "TOOL_DEFINITION_BAD_DEFINITION"
TOOL_DEFINITION_BAD_INPUT_SCHEMA = "TOOL_DEFINITION_BAD_INPUT_SCHEMA"
TOOL_DEFINITION_BAD_OUTPUT_SCHEMA = "TOOL_DEFINITION_BAD_OUTPUT_SCHEMA"
TOOL_RUNTIME_BAD_INPUT_VALUE = "TOOL_RUNTIME_BAD_INPUT_VALUE"
TOOL_RUNTIME_BAD_OUTPUT_VALUE = "TOOL_RUNTIME_BAD_OUTPUT_VALUE"
TOOL_RUNTIME_RETRY = "TOOL_RUNTIME_RETRY"
TOOL_RUNTIME_CONTEXT_REQUIRED = "TOOL_RUNTIME_CONTEXT_REQUIRED"
TOOL_RUNTIME_FATAL = "TOOL_RUNTIME_FATAL"
UPSTREAM_RUNTIME_BAD_REQUEST = "UPSTREAM_RUNTIME_BAD_REQUEST"
UPSTREAM_RUNTIME_AUTH_ERROR = "UPSTREAM_RUNTIME_AUTH_ERROR"
UPSTREAM_RUNTIME_NOT_FOUND = "UPSTREAM_RUNTIME_NOT_FOUND"
UPSTREAM_RUNTIME_VALIDATION_ERROR = "UPSTREAM_RUNTIME_VALIDATION_ERROR"
UPSTREAM_RUNTIME_RATE_LIMIT = "UPSTREAM_RUNTIME_RATE_LIMIT"
UPSTREAM_RUNTIME_SERVER_ERROR = "UPSTREAM_RUNTIME_SERVER_ERROR"
UPSTREAM_RUNTIME_UNMAPPED = "UPSTREAM_RUNTIME_UNMAPPED"
UNKNOWN = "UNKNOWN"
```
- In `arcadepy/types/execute_tool_response.py` add the following fields
to OutputError:
```py
kind: ErrorKind
status_code: Optional[int] = None
stacktrace: Optional[str] = None
extra: Optional[dict[str, Any]] = None
```
### Example Client Usage
```py
# Example of handling an upstream rate limit
error = response.output.error
if error and error.kind == ErrorKind.UPSTREAM_RUNTIME_RATE_LIMIT:
sleep_time = error.retry_after_ms / 1000
time.sleep(sleep_time)
# and then execute again
```
```py
# Examples of determining what type of runtime error it is
error = response.output.error
if error:
is_retryable_error = error.kind == ErrorKind.TOOL_RUNTIME_RETRY
is_a_bug_in_the_tool = error.kind == ErrorKind.TOOL_RUNTIME_FATAL
is_additional_context_required = error.kind == ErrorKind.TOOL_RUNTIME_CONTEXT_REQUIRED
```
### Example Tool Usage
```py
# EXAMPLE 1 letting Arcade handle upstream error handling for you
reddit_client.post(params) # Arcade's httpx adapter will handle error handling for you!
# ------------------------------------
# EXAMPLE 2 handling upstream bad request yourself, but letting Arcade handle the rest
try:
reddit_client.post(params)
except httpx.HTTPStatusError as e:
if e.status_code == 400:
raise UpstreamError("My extra custom message) from e
raise
```
```py
# EXAMPLE 1 letting Arcade handle it for you
risky_element = my_risky_list[42] # Arcade will raise a FatalToolError for you
# ------------------------------------
# EXAMPLE 2 handling it yourself for extra flexibility
try:
risky_element = my_risky_list[42]
except IndexError as e:
raise FatalToolError("My extra custom message") from e
```
### Non-runtime Error Message Examples
Example ToolkitLoadError Messages:
```
- [TOOLKIT_LOAD_FAILED] ToolkitLoadError when loading toolkit 'sample_tool': Could not import module mock_module. Reason: Mock import error
- [TOOLKIT_LOAD_FAILED] ToolkitLoadError when loading toolkit 'test_toolkit': Tool 'ValidTool' in toolkit 'test_toolkit' already exists in the catalog.
```
Example ToolDefinitionError Messages
```
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_missing_description': Tool 'tool_missing_description' is missing a description
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_invalid_secret_type': Secret keys must be strings (error in tool ToolWithInvalidSecretType).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_empty_secret': Secrets must have a non-empty key (error in tool ToolWithEmptySecret).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_invalid_metadata_type': Metadata must be strings (error in tool ToolWithInvalidMetadataType).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_metadata_requiring_auth_without_auth': Tool ToolWithMetadataRequiringAuthWithoutAuth declares metadata key 'client_id', which requires that the tool has an auth requirement, but no auth requirement was provided. Please specify an auth requirement.
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_empty_metadata': Metadata must have a non-empty key (error in tool ToolWithEmptyMetadata).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_unsupported_param_type': Unsupported parameter type: <class 'test_catalog.MyFancyTestClass'>
```
Example ToolInputSchemaError Messages
```
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_missing_input_parameter_annotation': Parameter 'input_text' is missing a description
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_no_type_annotation': Parameter param has no type annotation.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_invalid_param_name': Invalid parameter name: '123invalid' is not a valid identifier. Identifiers must start with a letter or underscore, and can only contain letters, digits, or underscores.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_too_many_annotations': Parameter param: Annotated[str, 'name', 'desc', 'extra'] has too many string annotations. Expected 0, 1, or 2, got 3.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_required_union_param': Parameter param is a union type. Only optional types are supported.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_non_callable_default_factory': Default factory for parameter param: Annotated[str, 'Parameter'] = FieldInfo(annotation=NoneType, required=False, default_factory=str) is not callable.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_multiple_tool_contexts': Only one ToolContext parameter is supported, but tool tool_with_multiple_tool_contexts has multiple.
```
Example ToolOutputSchemaError Messages
```
- [TOOL_DEFINITION_BAD_OUTPUT_SCHEMA] ToolOutputSchemaError in definition of tool 'tool_missing_return_type_hint': Tool 'ToolMissingReturnTypeHint' must have a return type
- [TOOL_DEFINITION_BAD_OUTPUT_SCHEMA] ToolOutputSchemaError in definition of tool 'tool_with_unsupported_output_type': Unsupported output type '<class 'test_catalog.MyFancyTestClass'>'. Only built-in Python types, TypedDicts, Pydantic models, and standard collections are supported as tool output types.
```
### Runtime Error Message Examples
Example Tool Runtime Error Messages
```
- [TOOL_RUNTIME_FATAL] FatalToolError during execution of tool 'get_posts_in_subreddit': list index out of range
- [TOOL_RUNTIME_CONTEXT_REQUIRED] ContextRequiredToolError during execution of tool 'get_posts_in_subreddit': Ambiguous username. Please provide a more specific username
- [TOOL_RUNTIME_RETRY] RetryableToolError during execution of tool 'get_posts_in_subreddit': Retry with subreddit=learnpython or subreddit=learnprogramming
```
Example Upstream Runtime Error Messages
```
- [UPSTREAM_RUNTIME_RATE_LIMIT] UpstreamRateLimitError during execution of tool 'get_posts_in_subreddit': 429 Client Error: Too Many Requests
- [UPSTREAM_RUNTIME_BAD_REQUEST] UpstreamError during execution of tool 'get_posts_in_subreddit': 400 Client Error: Bad request. Missing 'id' parameter.
- [UPSTREAM_RUNTIME_BAD_REQUEST] UpstreamError during execution of tool 'search_files': Upstream Google API error: Invalid value '-23'. Values must be within the range: [value: 1\n, value: 1000\n]
```
185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
import logging
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Callable, ClassVar
|
|
|
|
from arcade_core.catalog import ToolCatalog, Toolkit
|
|
from arcade_core.executor import ToolExecutor
|
|
from arcade_core.schema import (
|
|
ToolCallRequest,
|
|
ToolCallResponse,
|
|
ToolDefinition,
|
|
)
|
|
from opentelemetry import trace
|
|
from opentelemetry.metrics import Meter
|
|
|
|
from arcade_serve.core.common import Router, Worker
|
|
from arcade_serve.core.components import (
|
|
CallToolComponent,
|
|
CatalogComponent,
|
|
HealthCheckComponent,
|
|
WorkerComponent,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BaseWorker(Worker):
|
|
"""
|
|
A base worker class that provides a default implementation for registering tools and invoking them.
|
|
Worker implementations for specific web frameworks will inherit from this class.
|
|
"""
|
|
|
|
base_path = "/worker" # By default, prefix all our routes with /worker
|
|
|
|
default_components: ClassVar[tuple[type[WorkerComponent], ...]] = (
|
|
CatalogComponent,
|
|
CallToolComponent,
|
|
HealthCheckComponent,
|
|
)
|
|
|
|
def __init__(
|
|
self, secret: str | None = None, disable_auth: bool = False, otel_meter: Meter | None = None
|
|
) -> None:
|
|
"""
|
|
Initialize the BaseWorker with an empty ToolCatalog.
|
|
If no secret is provided, the worker will use the ARCADE_WORKER_SECRET environment variable.
|
|
"""
|
|
self.catalog = ToolCatalog()
|
|
self.disable_auth = disable_auth
|
|
if disable_auth:
|
|
logger.warning(
|
|
"Warning: Worker is running without authentication. Not recommended for production."
|
|
)
|
|
|
|
self.secret = self._set_secret(secret, disable_auth)
|
|
self.environment = os.environ.get("ARCADE_ENVIRONMENT", "local")
|
|
|
|
self.tool_counter = None
|
|
if otel_meter:
|
|
self.tool_counter = otel_meter.create_counter(
|
|
"tool_call", "requests", "Total number of tools called"
|
|
)
|
|
|
|
def _set_secret(self, secret: str | None, disable_auth: bool) -> str:
|
|
if disable_auth:
|
|
return ""
|
|
|
|
# If secret is provided, use it
|
|
if secret:
|
|
return secret
|
|
|
|
# If secret is not provided, try to get it from environment variables
|
|
env_secret = os.environ.get("ARCADE_WORKER_SECRET")
|
|
if env_secret:
|
|
return env_secret
|
|
|
|
raise ValueError(
|
|
"No secret provided for worker. Set the ARCADE_WORKER_SECRET environment variable."
|
|
)
|
|
|
|
def get_catalog(self) -> list[ToolDefinition]:
|
|
"""
|
|
Get the catalog as a list of ToolDefinitions.
|
|
"""
|
|
return [tool.definition for tool in self.catalog]
|
|
|
|
def register_tool(self, tool: Callable, toolkit_name: str) -> None:
|
|
"""
|
|
Register a tool to the catalog.
|
|
"""
|
|
self.catalog.add_tool(tool, toolkit_name)
|
|
|
|
def register_toolkit(self, toolkit: Toolkit) -> None:
|
|
"""
|
|
Register a toolkit to the catalog.
|
|
"""
|
|
self.catalog.add_toolkit(toolkit)
|
|
|
|
async def call_tool(self, tool_request: ToolCallRequest) -> ToolCallResponse:
|
|
"""
|
|
Call (invoke) a tool using the ToolExecutor.
|
|
"""
|
|
tool_fqname = tool_request.tool.get_fully_qualified_name()
|
|
|
|
try:
|
|
materialized_tool = self.catalog.get_tool(tool_fqname)
|
|
except KeyError:
|
|
raise ValueError(
|
|
f"Tool {tool_fqname} not found in catalog with toolkit version {tool_request.tool.version}."
|
|
)
|
|
|
|
start_time = time.time()
|
|
|
|
if self.tool_counter:
|
|
self.tool_counter.add(
|
|
1,
|
|
{
|
|
"tool_name": tool_fqname.name,
|
|
"toolkit_version": str(tool_fqname.toolkit_version),
|
|
"toolkit_name": tool_fqname.toolkit_name,
|
|
"environment": self.environment,
|
|
},
|
|
)
|
|
execution_id = tool_request.execution_id or ""
|
|
logger.info(
|
|
f"{execution_id} | Calling tool: {tool_fqname} version: {tool_request.tool.version}"
|
|
)
|
|
logger.debug(f"{execution_id} | Tool inputs: {tool_request.inputs}")
|
|
|
|
tracer = trace.get_tracer(__name__)
|
|
with tracer.start_as_current_span("RunTool"):
|
|
output = await ToolExecutor.run(
|
|
func=materialized_tool.tool,
|
|
definition=materialized_tool.definition,
|
|
input_model=materialized_tool.input_model,
|
|
output_model=materialized_tool.output_model,
|
|
context=tool_request.context,
|
|
**tool_request.inputs or {},
|
|
)
|
|
|
|
end_time = time.time() # End time in seconds
|
|
duration_ms = (end_time - start_time) * 1000 # Convert to milliseconds
|
|
|
|
if output.error:
|
|
logger.warning(
|
|
f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} failed"
|
|
)
|
|
logger.warning(f"{execution_id} | Tool error: {output.error.message}")
|
|
logger.warning(
|
|
f"{execution_id} | Tool developer message: {output.error.developer_message}"
|
|
)
|
|
logger.debug(
|
|
f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
|
|
)
|
|
if output.error.stacktrace:
|
|
logger.debug(f"{execution_id} | Tool traceback: {output.error.stacktrace}")
|
|
else:
|
|
logger.info(
|
|
f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} success"
|
|
)
|
|
logger.debug(
|
|
f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
|
|
)
|
|
|
|
return ToolCallResponse(
|
|
execution_id=execution_id,
|
|
duration=duration_ms,
|
|
finished_at=datetime.now().isoformat(),
|
|
success=not output.error,
|
|
output=output,
|
|
)
|
|
|
|
def health_check(self) -> dict[str, Any]:
|
|
"""
|
|
Provide a health check that serves as a heartbeat of worker health.
|
|
"""
|
|
return {"status": "ok", "tool_count": str(len(self.catalog))}
|
|
|
|
def register_routes(self, router: Router) -> None:
|
|
"""
|
|
Register the necessary routes to the application.
|
|
"""
|
|
for component_cls in self.default_components:
|
|
component_cls(self).register(router)
|