Skip to content
Merged
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: 4 additions & 0 deletions .changeset/activity-errors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: patch
packages: [ http, sdk-types ]
changelog: |-
Typed activity methods now raise `TurnkeyActivityError` or `TurnkeyActivityConsensusNeededError` when an activity does not complete, instead of a pydantic validation error. The errors carry the activity id, status, type, and failure message. `send_signed_request` now unpacks versioned result keys such as `createSubOrganizationResultV7` correctly.
96 changes: 76 additions & 20 deletions codegen/http/generate_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,51 @@ def _poll_for_completion(self, activity: Any) -> Any:

return activity

def _raise_for_activity_status(self, activity: Any) -> None:
\"\"\"Raise a typed error if an activity did not complete.

Args:
activity: Activity object after polling.

Raises:
TurnkeyActivityConsensusNeededError: If the activity needs more approvals.
TurnkeyActivityError: If the activity failed, was rejected, or polling
stopped before it reached a terminal status.
\"\"\"
status = getattr(activity.status, "value", activity.status)
if status == "ACTIVITY_STATUS_COMPLETED":
return

activity_type = getattr(activity.type, "value", activity.type)
failure = getattr(activity, "failure", None)
failure_message = getattr(failure, "message", None) if failure else None

if status == "ACTIVITY_STATUS_CONSENSUS_NEEDED":
raise TurnkeyActivityConsensusNeededError(
f"Consensus needed for activity {activity.id}",
activity_id=activity.id,
activity_status=status,
activity_type=activity_type,
failure=failure_message,
)

if status == "ACTIVITY_STATUS_FAILED":
message = f"Activity {activity.id} failed"
elif status == "ACTIVITY_STATUS_REJECTED":
message = f"Activity {activity.id} was rejected"
elif status in TERMINAL_ACTIVITY_STATUSES:
message = f"Activity {activity.id} ended with status {status}"
else:
message = f"Activity {activity.id} did not reach a terminal status after polling"

raise TurnkeyActivityError(
message,
activity_id=activity.id,
activity_status=status,
activity_type=activity_type,
failure=failure_message,
)

def _activity(self, url: str, body: Dict[str, Any], result_key: str, response_type: type) -> Any:
\"\"\"Execute an activity and poll for completion.

Expand All @@ -243,31 +288,33 @@ def _activity(self, url: str, body: Dict[str, Any], result_key: str, response_ty

Returns:
Parsed response as Pydantic model with flattened result fields

Raises:
TurnkeyActivityConsensusNeededError: If the activity needs more approvals.
TurnkeyActivityError: If the activity did not complete, or completed
without the expected result.
TurnkeyNetworkError: If a request fails.
\"\"\"
# Make initial request, we parse as activity response without result fields
initial_response = self._request(url, body, GetActivityResponse)

# Poll for completion
activity = self._poll_for_completion(initial_response.activity)
self._raise_for_activity_status(activity)

# Get the versioned result (e.g., 'createApiKeysResultV2') and flatten its fields into the response
result_data = getattr(activity.result, result_key, None) if activity.result else None
if result_data is None:
raise TurnkeyActivityError(
f"Activity {activity.id} completed without a {result_key} result",
activity_id=activity.id,
activity_status=getattr(activity.status, "value", activity.status),
activity_type=getattr(activity.type, "value", activity.type),
code=TurnkeyErrorCodes.ACTIVITY_RESULT_MISSING,
)

# If activity completed successfully, flatten result fields into response
if activity.status == "ACTIVITY_STATUS_COMPLETED" and hasattr(activity, 'result') and activity.result:
result = activity.result
# Get the versioned result key (e.g., 'createApiKeysResultV2')
if hasattr(result, result_key):
result_data = getattr(result, result_key)
if result_data and hasattr(result_data, 'model_dump'):
# Flatten result fields into response
result_dict = result_data.model_dump(by_alias=True, exclude_none=True)
# Construct final response with activity and result fields
response = response_type(
activity=activity,
**result_dict
)
return response

# Return response with just the activity (no result fields)
return response_type(activity=activity)
result_dict = result_data.model_dump(by_alias=True, exclude_none=True)
return response_type(activity=activity, **result_dict)

def _activity_decision(self, url: str, body: Dict[str, Any], response_type: type) -> Any:
\"\"\"Execute an activity decision.
Expand Down Expand Up @@ -295,6 +342,9 @@ def send_signed_request(self, signed_request: SignedRequest, response_type: type
stamping methods (stamp_create_api_keys, stamp_get_policies, etc.).

For activities, this will poll until the activity reaches a terminal status.
If response_type is given and the activity does not complete, this raises
TurnkeyActivityError. Without response_type, it returns the raw payload
for every status so the caller can inspect the activity.

Args:
signed_request: A SignedRequest object returned by a stamping method.
Expand All @@ -305,6 +355,8 @@ def send_signed_request(self, signed_request: SignedRequest, response_type: type
The parsed response via response_type, or raw JSON dict if no type provided.

Raises:
TurnkeyActivityConsensusNeededError: If the activity needs more approvals.
TurnkeyActivityError: If response_type is given and the activity did not complete.
TurnkeyNetworkError: If the request fails.
\"\"\"
headers = {
Expand Down Expand Up @@ -353,8 +405,12 @@ def send_signed_request(self, signed_request: SignedRequest, response_type: type
activity_dict = activity.model_dump(by_alias=True, exclude_none=True) if hasattr(activity, 'model_dump') else {}
payload["activity"] = activity_dict

# Flatten the result that is set, whatever its version suffix
# (e.g., createPolicyResult, ethSendTransactionResultV2, createSubOrganizationResultV7)
# Typed callers get an error if the activity did not complete.
# Without a response_type, return the raw payload for every status.
if response_type is not None:
self._raise_for_activity_status(activity)

# Flatten the result that is set (e.g., createPolicyResult, createSubOrganizationResultV7)
result = activity.result
if activity.status == "ACTIVITY_STATUS_COMPLETED" and result is not None:
for field_name in type(result).model_fields:
Expand Down
17 changes: 17 additions & 0 deletions packages/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ response = client.get_whoami()
print(response)
```

## Handling activity outcomes

Activity methods such as `create_wallet` wait for the activity to finish. If it does not complete, they raise an error. `TurnkeyActivityConsensusNeededError` means the activity needs more approvals. `TurnkeyActivityError` means it failed, was rejected, or was still pending when polling stopped. Both errors carry `activity_id`, `activity_status`, `activity_type`, and `failure`.

```python
from turnkey_sdk_types import TurnkeyActivityConsensusNeededError, TurnkeyActivityError

try:
wallet = client.create_wallet(body)
except TurnkeyActivityConsensusNeededError as err:
save_for_later(err.activity_id) # fetch it again with get_activity after approval
except TurnkeyActivityError as err:
print(err.activity_status, err.failure)
```

Catch `TurnkeyActivityConsensusNeededError` first, because it is a subclass of `TurnkeyActivityError`.

## Code Generation

This package uses code generation to create HTTP client methods from the OpenAPI specification located in `schema/public_api.swagger.json`.
Expand Down
103 changes: 80 additions & 23 deletions packages/http/src/turnkey_http/generated/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,53 @@ def _poll_for_completion(self, activity: Any) -> Any:

return activity

def _raise_for_activity_status(self, activity: Any) -> None:
"""Raise a typed error if an activity did not complete.

Args:
activity: Activity object after polling.

Raises:
TurnkeyActivityConsensusNeededError: If the activity needs more approvals.
TurnkeyActivityError: If the activity failed, was rejected, or polling
stopped before it reached a terminal status.
"""
status = getattr(activity.status, "value", activity.status)
if status == "ACTIVITY_STATUS_COMPLETED":
return

activity_type = getattr(activity.type, "value", activity.type)
failure = getattr(activity, "failure", None)
failure_message = getattr(failure, "message", None) if failure else None

if status == "ACTIVITY_STATUS_CONSENSUS_NEEDED":
raise TurnkeyActivityConsensusNeededError(
f"Consensus needed for activity {activity.id}",
activity_id=activity.id,
activity_status=status,
activity_type=activity_type,
failure=failure_message,
)

if status == "ACTIVITY_STATUS_FAILED":
message = f"Activity {activity.id} failed"
elif status == "ACTIVITY_STATUS_REJECTED":
message = f"Activity {activity.id} was rejected"
elif status in TERMINAL_ACTIVITY_STATUSES:
message = f"Activity {activity.id} ended with status {status}"
else:
message = (
f"Activity {activity.id} did not reach a terminal status after polling"
)

raise TurnkeyActivityError(
message,
activity_id=activity.id,
activity_status=status,
activity_type=activity_type,
failure=failure_message,
)

def _activity(
self, url: str, body: Dict[str, Any], result_key: str, response_type: type
) -> Any:
Expand All @@ -159,34 +206,35 @@ def _activity(

Returns:
Parsed response as Pydantic model with flattened result fields

Raises:
TurnkeyActivityConsensusNeededError: If the activity needs more approvals.
TurnkeyActivityError: If the activity did not complete, or completed
without the expected result.
TurnkeyNetworkError: If a request fails.
"""
# Make initial request, we parse as activity response without result fields
initial_response = self._request(url, body, GetActivityResponse)

# Poll for completion
activity = self._poll_for_completion(initial_response.activity)
self._raise_for_activity_status(activity)

# Get the versioned result (e.g., 'createApiKeysResultV2') and flatten its fields into the response
result_data = (
getattr(activity.result, result_key, None) if activity.result else None
)
if result_data is None:
raise TurnkeyActivityError(
f"Activity {activity.id} completed without a {result_key} result",
activity_id=activity.id,
activity_status=getattr(activity.status, "value", activity.status),
activity_type=getattr(activity.type, "value", activity.type),
code=TurnkeyErrorCodes.ACTIVITY_RESULT_MISSING,
)

# If activity completed successfully, flatten result fields into response
if (
activity.status == "ACTIVITY_STATUS_COMPLETED"
and hasattr(activity, "result")
and activity.result
):
result = activity.result
# Get the versioned result key (e.g., 'createApiKeysResultV2')
if hasattr(result, result_key):
result_data = getattr(result, result_key)
if result_data and hasattr(result_data, "model_dump"):
# Flatten result fields into response
result_dict = result_data.model_dump(
by_alias=True, exclude_none=True
)
# Construct final response with activity and result fields
response = response_type(activity=activity, **result_dict)
return response

# Return response with just the activity (no result fields)
return response_type(activity=activity)
result_dict = result_data.model_dump(by_alias=True, exclude_none=True)
return response_type(activity=activity, **result_dict)

def _activity_decision(
self, url: str, body: Dict[str, Any], response_type: type
Expand Down Expand Up @@ -220,6 +268,9 @@ def send_signed_request(
stamping methods (stamp_create_api_keys, stamp_get_policies, etc.).

For activities, this will poll until the activity reaches a terminal status.
If response_type is given and the activity does not complete, this raises
TurnkeyActivityError. Without response_type, it returns the raw payload
for every status so the caller can inspect the activity.

Args:
signed_request: A SignedRequest object returned by a stamping method.
Expand All @@ -230,6 +281,8 @@ def send_signed_request(
The parsed response via response_type, or raw JSON dict if no type provided.

Raises:
TurnkeyActivityConsensusNeededError: If the activity needs more approvals.
TurnkeyActivityError: If response_type is given and the activity did not complete.
TurnkeyNetworkError: If the request fails.
"""
headers = {
Expand Down Expand Up @@ -281,8 +334,12 @@ def send_signed_request(
)
payload["activity"] = activity_dict

# Flatten the result that is set, whatever its version suffix
# (e.g., createPolicyResult, ethSendTransactionResultV2, createSubOrganizationResultV7)
# Typed callers get an error if the activity did not complete.
# Without a response_type, return the raw payload for every status.
if response_type is not None:
self._raise_for_activity_status(activity)

# Flatten the result that is set (e.g., createPolicyResult, createSubOrganizationResultV7)
result = activity.result
if activity.status == "ACTIVITY_STATUS_COMPLETED" and result is not None:
for field_name in type(result).model_fields:
Expand Down
Loading
Loading