diff --git a/docs/manual/billing.md b/docs/manual/billing.md index 0bdd028..57d4cd3 100644 --- a/docs/manual/billing.md +++ b/docs/manual/billing.md @@ -103,11 +103,11 @@ class GenerateContentAction(ActionHandler): ``` ## Best Practices -1. **Always return `ActionResult`** from action handlers +1. **Always return `ActionResult` or `ActionError`** when `supports_billing` is `true` — both support the `cost_usd` field 2. **Be accurate with costs** - report the actual cost incurred by the third-party API call, not an estimate 3. **Use `0.0` for free operations** - if an action doesn't cost anything, explicitly return `cost_usd=0.0` to signal that billing is working correctly 4. **Calculate dynamically when possible** - if the API returns usage data (e.g., tokens consumed), use it to compute the cost rather than using a fixed value -5. **Handle errors gracefully** - even when an action fails, consider whether a cost was incurred (e.g., an API call was made but post-processing failed) +5. **Track costs on errors** - if a third-party API call was made but the action still failed, return `ActionError` with `cost_usd` to ensure the charge is captured ## Migration Notes diff --git a/docs/manual/building_your_first_integration.md b/docs/manual/building_your_first_integration.md index 27846e3..78f7f3b 100644 --- a/docs/manual/building_your_first_integration.md +++ b/docs/manual/building_your_first_integration.md @@ -307,6 +307,29 @@ if email: body["email"] = email ``` +### Returning Errors from Actions + +Action handlers normally return an `ActionResult` whose `data` is validated against the action's `output_schema`. When your action encounters an expected error condition (e.g. a resource not found, an API quota exceeded), you can return an `ActionError` instead. This bypasses output schema validation and returns the error message to the caller: + +```python +from autohive_integrations_sdk import ActionError, HTTPError + +@my_integration.action("my_action_handler") +class MyActionHandler(ActionHandler): + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): + try: + response = await context.fetch(url) + except HTTPError as e: + return ActionError( + message=f"API call failed: {e.message}", + cost_usd=0.01 # API call was made, cost was still incurred + ) + + return ActionResult(data=response) +``` + +Use `ActionError` for expected, application-level failures. For unexpected infrastructure errors, let exceptions propagate normally. + ### Handler Class Naming Use `PascalCase` with an `Action` suffix: diff --git a/samples/action-error-demo/.gitignore b/samples/action-error-demo/.gitignore new file mode 100644 index 0000000..355ca95 --- /dev/null +++ b/samples/action-error-demo/.gitignore @@ -0,0 +1,3 @@ +dependencies/ +__pycache__/ +*.pyc diff --git a/samples/action-error-demo/__init__.py b/samples/action-error-demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/action-error-demo/action_error_demo.py b/samples/action-error-demo/action_error_demo.py new file mode 100644 index 0000000..e56d5d8 --- /dev/null +++ b/samples/action-error-demo/action_error_demo.py @@ -0,0 +1,94 @@ +""" +Action Error Demo — demonstrates the three possible result types from an action handler. + +Actions: +- get_user: Returns ActionResult (success) for a given user_id. +- get_user_billing: Returns ActionResult with a cost_usd billing charge. +- lookup_user: Returns ActionError when the user is not found (expected app-level error). +- lookup_user_with_cost: Returns ActionError with cost_usd when a chargeable lookup fails. +""" +from autohive_integrations_sdk import ( + Integration, ExecutionContext, ActionHandler, ActionResult, ActionError +) +from typing import Dict, Any + +action_error_demo = Integration.load() + +# Simulated user database +USERS = { + "1": {"id": "1", "name": "Alice Smith", "email": "alice@example.com"}, + "2": {"id": "2", "name": "Bob Jones", "email": "bob@example.com"}, +} + + +@action_error_demo.action("get_user") +class GetUserAction(ActionHandler): + """Returns user data for an existing user_id, or raises if not found. + + Use this action to demonstrate a plain ActionResult success path. + Pass user_id "1" or "2" for a success; any other value causes an unhandled + KeyError (exception-based error path — NOT an ActionError). + """ + + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + user_id = inputs["user_id"] + user = USERS[user_id] # Raises KeyError for unknown IDs — unhandled exception path + return ActionResult(data=user) + + +@action_error_demo.action("get_user_billing") +class GetUserBillingAction(ActionHandler): + """Returns user data with a billing charge attached. + + Use this action to demonstrate ActionResult with cost_usd. + Pass user_id "1" or "2" for a success with billing. + """ + + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + user_id = inputs["user_id"] + user = USERS[user_id] # Raises KeyError for unknown IDs — unhandled exception path + return ActionResult(data=user, cost_usd=0.001) + + +@action_error_demo.action("lookup_user") +class LookupUserAction(ActionHandler): + """Looks up a user by user_id, returning ActionError for unknown IDs. + + Use this action to demonstrate ActionError — an expected, application-level + error that the agent should receive as content so it can act on the message, + rather than being treated as an infrastructure failure. + + Pass user_id "1" or "2" for success; any other value returns ActionError. + """ + + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + user_id = inputs["user_id"] + user = USERS.get(user_id) + + if user is None: + return ActionError(message=f"User '{user_id}' not found.") + + return ActionResult(data=user) + + +@action_error_demo.action("lookup_user_with_cost") +class LookupUserWithCostAction(ActionHandler): + """Looks up a user, returning ActionError with cost_usd for chargeable failed lookups. + + Use this action to demonstrate ActionError with a billing charge — for when + the integration incurred a third-party cost even though the lookup failed. + + Pass user_id "1" or "2" for success; any other value returns ActionError + cost. + """ + + async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult: + user_id = inputs["user_id"] + user = USERS.get(user_id) + + if user is None: + return ActionError( + message=f"User '{user_id}' not found.", + cost_usd=0.001 # Lookup was billed even though the user wasn't found + ) + + return ActionResult(data=user, cost_usd=0.001) diff --git a/samples/action-error-demo/config.json b/samples/action-error-demo/config.json new file mode 100644 index 0000000..d44d75f --- /dev/null +++ b/samples/action-error-demo/config.json @@ -0,0 +1,101 @@ +{ + "name": "action-error-demo", + "version": "1.0.0", + "display_name": "Action Error Demo", + "description": "Demonstrates the three result types from action handlers: ActionResult, ActionError, and unhandled exceptions.", + "entry_point": "action_error_demo.py", + "supports_billing": true, + "auth": { + "type": "none" + }, + "actions": { + "get_user": { + "display_name": "Get User", + "description": "Returns user data for user_id '1' or '2'. Any other value throws an unhandled exception (infrastructure error path).", + "input_schema": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "The user ID to fetch. Use '1' or '2' for success." + } + }, + "required": ["user_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + } + }, + "get_user_billing": { + "display_name": "Get User (with Billing)", + "description": "Returns user data with a cost_usd billing charge. Use '1' or '2' for success.", + "input_schema": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "The user ID to fetch. Use '1' or '2' for success." + } + }, + "required": ["user_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + } + }, + "lookup_user": { + "display_name": "Lookup User", + "description": "Returns ActionError for unknown user IDs instead of throwing. Use '1' or '2' for success; any other value returns an ActionError the agent can read.", + "input_schema": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "The user ID to look up. Use '1' or '2' for success; anything else returns ActionError." + } + }, + "required": ["user_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + } + }, + "lookup_user_with_cost": { + "display_name": "Lookup User (with Billing)", + "description": "Returns ActionError with cost_usd for unknown user IDs — for when a chargeable lookup still incurs a cost even on failure.", + "input_schema": { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "The user ID to look up. Use '1' or '2' for success; anything else returns ActionError with cost." + } + }, + "required": ["user_id"] + }, + "output_schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "email": { "type": "string" } + } + } + } + } +} diff --git a/samples/action-error-demo/requirements.txt b/samples/action-error-demo/requirements.txt new file mode 100644 index 0000000..1ef3a79 --- /dev/null +++ b/samples/action-error-demo/requirements.txt @@ -0,0 +1 @@ +autohive_integrations_sdk diff --git a/src/autohive_integrations_sdk/__init__.py b/src/autohive_integrations_sdk/__init__.py index 0d34b59..e69a2e0 100644 --- a/src/autohive_integrations_sdk/__init__.py +++ b/src/autohive_integrations_sdk/__init__.py @@ -4,5 +4,5 @@ # Re-export classes from integration module from autohive_integrations_sdk.integration import ( Integration, ExecutionContext, ActionHandler, PollingTriggerHandler, ConnectedAccountHandler, - ConnectedAccountInfo, ValidationError, ActionResult, IntegrationResult, ResultType + ConnectedAccountInfo, ValidationError, ActionResult, ActionError, IntegrationResult, ResultType ) \ No newline at end of file diff --git a/src/autohive_integrations_sdk/integration.py b/src/autohive_integrations_sdk/integration.py index 2d1b490..ecba7f4 100644 --- a/src/autohive_integrations_sdk/integration.py +++ b/src/autohive_integrations_sdk/integration.py @@ -76,8 +76,10 @@ class AuthType(Enum): class ResultType(Enum): """Type of result being returned""" ACTION = "action" + ACTION_ERROR = "action_error" CONNECTED_ACCOUNT = "connected_account" ERROR = "error" + VALIDATION_ERROR = "validation_error" # ---- Exceptions ---- class ValidationError(Exception): @@ -91,13 +93,15 @@ class ValidationError(Exception): - An action handler returns something other than ``ActionResult`` - A handler name isn't registered """ - def __init__(self, message: str, schema: str = None, inputs: str = None): + def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): self.schema = schema """The schema that failed validation""" self.inputs = inputs """The data that failed validation""" self.message = message """The error message""" + self.source = source + """Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)""" super().__init__(message) class ConfigurationError(Exception): @@ -150,6 +154,28 @@ class ActionResult: data: Any cost_usd: Optional[float] = None +@dataclass +class ActionError: + """Error result returned by action handlers for expected/application-level errors. + + When returned from an action handler, output schema validation is skipped + and the error is returned to the caller as a ResultType.ERROR result. + + Args: + message: Human-readable error message + cost_usd: Optional USD cost incurred before the error occurred + + Example: + ```python + return ActionError( + message="User not found", + cost_usd=0.01 + ) + ``` + """ + message: str + cost_usd: Optional[float] = None + @dataclass class ConnectedAccountInfo: """Account metadata returned by a ``ConnectedAccountHandler``. @@ -178,17 +204,19 @@ class IntegrationResult: Args: version: SDK version (auto-populated) type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) - result: The result object - ActionResult for actions or - ConnectedAccountInfo for connected accounts. + result: The result object - ActionResult for actions, ActionError for + application-level action errors, or ConnectedAccountInfo for + connected accounts. The lambda wrapper serializes these to dicts using asdict(). Note: This type is returned by Integration methods and serialized by the lambda wrapper. - Integration developers should use ActionResult for action handlers. + Integration developers should use ActionResult for action handlers and + ActionError for expected error conditions. """ version: str type: ResultType - result: Union[ActionResult, ConnectedAccountInfo] + result: Union[ActionResult, ActionError, ConnectedAccountInfo] # ---- Configuration Classes ---- @@ -701,59 +729,79 @@ async def execute_action(self, context: Execution context Returns: - IntegrationResult with action data and optional billing information - - Raises: - ValidationError: If inputs or outputs don't match schema, or if handler doesn't return ActionResult + IntegrationResult with action data (ResultType.ACTION), + action error (ResultType.ACTION_ERROR) if the handler returned ActionError, + or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. """ - if name not in self._action_handlers: - raise ValidationError(f"Action '{name}' not registered") - - # Validate inputs against action schema - action_config = self.config.actions[name] - validator = Draft7Validator(action_config.input_schema) - errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) - if errors: - message = "" - for error in errors: - message += f"{list(error.schema_path)}, {error.message},\n " - raise ValidationError(message, action_config.input_schema, inputs) + try: + if name not in self._action_handlers: + raise ValidationError(f"Action '{name}' not registered") - if "fields" in self.config.auth: - auth_config = self.config.auth["fields"] - validator = Draft7Validator(auth_config) - errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) + # Validate inputs against action schema + action_config = self.config.actions[name] + validator = Draft7Validator(action_config.input_schema) + errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) if errors: message = "" for error in errors: message += f"{list(error.schema_path)}, {error.message},\n " - raise ValidationError(message, auth_config, context.auth) - - # Create handler instance and execute - handler = self._action_handlers[name]() - result = await handler.execute(inputs, context) + raise ValidationError(message, action_config.input_schema, inputs, source="input") + + if "fields" in self.config.auth: + auth_config = self.config.auth["fields"] + validator = Draft7Validator(auth_config) + errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) + if errors: + message = "" + for error in errors: + message += f"{list(error.schema_path)}, {error.message},\n " + raise ValidationError(message, auth_config, context.auth, source="input") + + # Create handler instance and execute + handler = self._action_handlers[name]() + result = await handler.execute(inputs, context) + + # Handle ActionError - skip output schema validation + if isinstance(result, ActionError): + return IntegrationResult( + version=__version__, + type=ResultType.ACTION_ERROR, + result=result + ) - # Validate that result is ActionResult - if not isinstance(result, ActionResult): - raise ValidationError( - f"Action handler '{name}' must return ActionResult, got {type(result).__name__}" - ) + # Validate that result is ActionResult + if not isinstance(result, ActionResult): + raise ValidationError( + f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", + source="output" + ) - # Validate output schema against the data inside ActionResult - validator = Draft7Validator(action_config.output_schema) - errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) - if errors: - message = "" - for error in errors: - message += f"{list(error.schema_path)}, {error.message},\n " - raise ValidationError(message, action_config.output_schema, result.data) + # Validate output schema against the data inside ActionResult + validator = Draft7Validator(action_config.output_schema) + errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) + if errors: + message = "" + for error in errors: + message += f"{list(error.schema_path)}, {error.message},\n " + raise ValidationError(message, action_config.output_schema, result.data, source="output") - # Return IntegrationResult with ActionResult directly - return IntegrationResult( - version=__version__, - type=ResultType.ACTION, - result=result - ) + # Return IntegrationResult with ActionResult directly + return IntegrationResult( + version=__version__, + type=ResultType.ACTION, + result=result + ) + except ValidationError as e: + return IntegrationResult( + version=__version__, + type=ResultType.VALIDATION_ERROR, + result={ + 'message': str(e), + 'property': None, + 'value': None, + 'source': getattr(e, 'source', 'legacy') + } + ) async def execute_polling_trigger(self, name: str,