Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/manual/billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions docs/manual/building_your_first_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions samples/action-error-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies/
__pycache__/
*.pyc
Empty file.
94 changes: 94 additions & 0 deletions samples/action-error-demo/action_error_demo.py
Original file line number Diff line number Diff line change
@@ -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)
101 changes: 101 additions & 0 deletions samples/action-error-demo/config.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
}
1 change: 1 addition & 0 deletions samples/action-error-demo/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
autohive_integrations_sdk
2 changes: 1 addition & 1 deletion src/autohive_integrations_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
61 changes: 50 additions & 11 deletions src/autohive_integrations_sdk/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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 ----

Expand Down Expand Up @@ -701,10 +729,12 @@ async def execute_action(self,
context: Execution context

Returns:
IntegrationResult with action data and optional billing information
IntegrationResult with action data (ResultType.ACTION) or
error information (ResultType.ERROR) if the handler returned ActionError

Raises:
ValidationError: If inputs or outputs don't match schema, or if handler doesn't return ActionResult
ValidationError: If inputs or outputs don't match schema, or if handler
doesn't return ActionResult or ActionError
"""
if name not in self._action_handlers:
raise ValidationError(f"Action '{name}' not registered")
Expand All @@ -717,7 +747,7 @@ async def execute_action(self,
message = ""
for error in errors:
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, action_config.input_schema, inputs)
raise ValidationError(message, action_config.input_schema, inputs, source="input")

if "fields" in self.config.auth:
auth_config = self.config.auth["fields"]
Expand All @@ -727,16 +757,25 @@ async def execute_action(self,
message = ""
for error in errors:
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, auth_config, context.auth)
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__}"
f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}",
source="output"
)

# Validate output schema against the data inside ActionResult
Expand All @@ -746,7 +785,7 @@ async def execute_action(self,
message = ""
for error in errors:
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, action_config.output_schema, result.data)
raise ValidationError(message, action_config.output_schema, result.data, source="output")

# Return IntegrationResult with ActionResult directly
return IntegrationResult(
Expand Down