diff --git a/.changeset/activity-errors.yml b/.changeset/activity-errors.yml new file mode 100644 index 0000000..d7bce32 --- /dev/null +++ b/.changeset/activity-errors.yml @@ -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. diff --git a/codegen/http/generate_http.py b/codegen/http/generate_http.py index b92b4e9..df6d48a 100644 --- a/codegen/http/generate_http.py +++ b/codegen/http/generate_http.py @@ -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. @@ -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. @@ -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. @@ -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 = { @@ -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: diff --git a/packages/http/README.md b/packages/http/README.md index c3510ec..2e377a3 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -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`. diff --git a/packages/http/src/turnkey_http/generated/client.py b/packages/http/src/turnkey_http/generated/client.py index 2e4ef16..662c10f 100644 --- a/packages/http/src/turnkey_http/generated/client.py +++ b/packages/http/src/turnkey_http/generated/client.py @@ -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: @@ -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 @@ -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. @@ -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 = { @@ -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: diff --git a/packages/http/tests/test_activity_errors.py b/packages/http/tests/test_activity_errors.py new file mode 100644 index 0000000..293cc69 --- /dev/null +++ b/packages/http/tests/test_activity_errors.py @@ -0,0 +1,298 @@ +"""Offline tests for activity outcome handling. + +These tests mock ``requests.post`` and use a stub stamper, so they need no +credentials and make no network calls. They do not use the fixtures in +``conftest.py``. +""" + +from typing import Any, Dict, List, Optional + +import pytest +from pydantic import ValidationError +from turnkey_api_key_stamper import TStamp +from turnkey_sdk_types import ( + CreateSubOrganizationResponse, + CreateWalletBody, + RequestType, + SignedRequest, + TurnkeyActivityConsensusNeededError, + TurnkeyActivityError, + TurnkeyErrorCodes, +) +from turnkey_http.generated import client as client_module +from turnkey_http.generated.client import TurnkeyClient + +ACTIVITY_ID = "activity-123" +ORG_ID = "org-123" + + +class StubStamper: + def stamp(self, body: str) -> TStamp: + return TStamp(stamp_header_name="X-Stamp", stamp_header_value="stub") + + +class FakeResponse: + def __init__(self, data: Dict[str, Any]): + self._data = data + self.ok = True + self.status_code = 200 + self.reason = "OK" + self.text = "" + + def json(self) -> Dict[str, Any]: + return self._data + + +def activity_payload( + status: str, + result: Optional[Dict[str, Any]] = None, + failure: Optional[Dict[str, Any]] = None, + activity_type: str = "ACTIVITY_TYPE_CREATE_WALLET", +) -> Dict[str, Any]: + activity: Dict[str, Any] = { + "id": ACTIVITY_ID, + "organizationId": ORG_ID, + "status": status, + "type": activity_type, + "intent": {}, + "result": result or {}, + "votes": [], + "fingerprint": "fingerprint", + "canApprove": False, + "canReject": False, + "createdAt": {"seconds": "0", "nanos": "0"}, + "updatedAt": {"seconds": "0", "nanos": "0"}, + } + if failure is not None: + activity["failure"] = failure + return {"activity": activity} + + +@pytest.fixture +def mock_post(monkeypatch): + """Return a function that queues responses for requests.post.""" + calls: List[Dict[str, Any]] = [] + queue: List[Dict[str, Any]] = [] + + def fake_post(url, headers=None, data=None, timeout=None): + calls.append({"url": url, "data": data}) + return FakeResponse(queue.pop(0)) + + monkeypatch.setattr(client_module.requests, "post", fake_post) + + def enqueue(*payloads: Dict[str, Any]) -> List[Dict[str, Any]]: + queue.extend(payloads) + return calls + + return enqueue + + +@pytest.fixture +def client() -> TurnkeyClient: + """Override the live-credential client fixture from conftest.py.""" + return TurnkeyClient( + base_url="https://api.example.invalid", + stamper=StubStamper(), # type: ignore[arg-type] + organization_id=ORG_ID, + polling_interval_ms=0, + max_polling_retries=2, + ) + + +def create_wallet(client: TurnkeyClient): + return client.create_wallet(CreateWalletBody(walletName="w", accounts=[])) + + +def test_completed_activity_unpacks_result(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_COMPLETED", + result={"createWalletResult": {"walletId": "w-1", "addresses": ["0xabc"]}}, + ) + ) + + response = create_wallet(client) + + assert response.walletId == "w-1" + assert response.addresses == ["0xabc"] + assert response.activity.id == ACTIVITY_ID + + +def test_failed_activity_raises_with_failure_text(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_FAILED", + failure={"code": 7, "message": "policy denied"}, + ) + ) + + with pytest.raises(TurnkeyActivityError) as exc_info: + create_wallet(client) + + err = exc_info.value + assert type(err) is TurnkeyActivityError + assert not isinstance(err, ValidationError) + assert err.activity_id == ACTIVITY_ID + assert err.activity_status == "ACTIVITY_STATUS_FAILED" + assert err.activity_type == "ACTIVITY_TYPE_CREATE_WALLET" + assert err.failure == "policy denied" + assert err.code == TurnkeyErrorCodes.ACTIVITY_FAILED + assert "policy denied" in str(err) + assert ACTIVITY_ID in str(err) + + +def test_rejected_activity_raises(client, mock_post): + mock_post(activity_payload("ACTIVITY_STATUS_REJECTED")) + + with pytest.raises(TurnkeyActivityError) as exc_info: + create_wallet(client) + + err = exc_info.value + assert type(err) is TurnkeyActivityError + assert err.activity_id == ACTIVITY_ID + assert err.activity_status == "ACTIVITY_STATUS_REJECTED" + assert "rejected" in str(err) + + +def test_consensus_needed_raises_subclass(client, mock_post): + mock_post(activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED")) + + with pytest.raises(TurnkeyActivityConsensusNeededError) as exc_info: + create_wallet(client) + + err = exc_info.value + assert isinstance(err, TurnkeyActivityError) + assert err.activity_id == ACTIVITY_ID + assert err.activity_status == "ACTIVITY_STATUS_CONSENSUS_NEEDED" + assert err.code == TurnkeyErrorCodes.ACTIVITY_CONSENSUS_NEEDED + + +def test_polling_timeout_raises_with_last_status(client, mock_post): + calls = mock_post( + activity_payload("ACTIVITY_STATUS_CREATED"), + activity_payload("ACTIVITY_STATUS_PENDING"), + activity_payload("ACTIVITY_STATUS_PENDING"), + ) + + with pytest.raises(TurnkeyActivityError) as exc_info: + create_wallet(client) + + err = exc_info.value + assert type(err) is TurnkeyActivityError + assert err.activity_id == ACTIVITY_ID + assert err.activity_status == "ACTIVITY_STATUS_PENDING" + # One submit plus max_polling_retries polls + assert len(calls) == 3 + + +def test_completed_activity_without_result_key_raises(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_COMPLETED", + result={"createPolicyResult": {"policyId": "p-1"}}, + ) + ) + + with pytest.raises(TurnkeyActivityError) as exc_info: + create_wallet(client) + + err = exc_info.value + assert err.code == TurnkeyErrorCodes.ACTIVITY_RESULT_MISSING + assert err.activity_status == "ACTIVITY_STATUS_COMPLETED" + assert "createWalletResult" in str(err) + + +def signed_activity_request() -> SignedRequest: + return SignedRequest( + url="https://api.example.invalid/public/v1/submit/create_sub_organization", + body="{}", + stamp=TStamp(stamp_header_name="X-Stamp", stamp_header_value="stub"), + type=RequestType.ACTIVITY, + ) + + +def test_generic_path_unpacks_versioned_result_key(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_COMPLETED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + result={ + "createSubOrganizationResultV7": { + "subOrganizationId": "sub-1", + "rootUserIds": ["u-1"], + } + }, + ) + ) + + response = client.send_signed_request( + signed_activity_request(), CreateSubOrganizationResponse + ) + + assert response.subOrganizationId == "sub-1" + assert response.rootUserIds == ["u-1"] + + +def test_generic_path_with_type_raises_on_failed_activity(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_FAILED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + failure={"message": "quota exceeded"}, + ) + ) + + with pytest.raises(TurnkeyActivityError) as exc_info: + client.send_signed_request( + signed_activity_request(), CreateSubOrganizationResponse + ) + + assert exc_info.value.failure == "quota exceeded" + assert exc_info.value.activity_id == ACTIVITY_ID + + +def test_raw_path_returns_failed_activity(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_FAILED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + failure={"code": 7, "message": "policy denied"}, + ) + ) + + payload = client.send_signed_request(signed_activity_request()) + + assert isinstance(payload, dict) + assert payload["activity"]["id"] == ACTIVITY_ID + assert payload["activity"]["status"] == "ACTIVITY_STATUS_FAILED" + assert payload["activity"]["failure"]["message"] == "policy denied" + + +def test_raw_path_returns_consensus_needed_activity(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_CONSENSUS_NEEDED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + ) + ) + + payload = client.send_signed_request(signed_activity_request()) + + assert isinstance(payload, dict) + assert payload["activity"]["id"] == ACTIVITY_ID + assert payload["activity"]["status"] == "ACTIVITY_STATUS_CONSENSUS_NEEDED" + + +def test_raw_path_unpacks_versioned_result_key(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_COMPLETED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + result={"createSubOrganizationResultV7": {"subOrganizationId": "sub-1"}}, + ) + ) + + payload = client.send_signed_request(signed_activity_request()) + + assert payload["subOrganizationId"] == "sub-1" + assert payload["activity"]["status"] == "ACTIVITY_STATUS_COMPLETED" diff --git a/packages/sdk-types/src/turnkey_sdk_types/errors.py b/packages/sdk-types/src/turnkey_sdk_types/errors.py index 9e4ce8b..c865a84 100644 --- a/packages/sdk-types/src/turnkey_sdk_types/errors.py +++ b/packages/sdk-types/src/turnkey_sdk_types/errors.py @@ -9,6 +9,9 @@ class TurnkeyErrorCodes(str, Enum): NETWORK_ERROR = "NETWORK_ERROR" BAD_RESPONSE = "BAD_RESPONSE" + ACTIVITY_FAILED = "ACTIVITY_FAILED" + ACTIVITY_CONSENSUS_NEEDED = "ACTIVITY_CONSENSUS_NEEDED" + ACTIVITY_RESULT_MISSING = "ACTIVITY_RESULT_MISSING" class TurnkeyError(Exception): @@ -39,3 +42,79 @@ def __init__( super().__init__(message, code, cause) self.name = "TurnkeyNetworkError" self.status_code = status_code + + +class TurnkeyActivityError(TurnkeyError): + """Exception raised when an activity does not complete. + + Raised when an activity ends as failed or rejected, when polling stops + before the activity reaches a terminal status, or when a completed + activity does not contain the expected result. + + Attributes: + activity_id: ID of the activity. Use it to fetch the activity later + with ``get_activity``. + activity_status: Last status seen for the activity, for example + ``ACTIVITY_STATUS_FAILED`` or ``ACTIVITY_STATUS_PENDING``. + activity_type: Type of the activity, for example + ``ACTIVITY_TYPE_CREATE_WALLET``. + failure: Failure message that Turnkey returned, if any. + """ + + def __init__( + self, + message: str, + activity_id: Optional[str] = None, + activity_status: Optional[str] = None, + activity_type: Optional[str] = None, + failure: Optional[str] = None, + code: Optional[TurnkeyErrorCodes] = TurnkeyErrorCodes.ACTIVITY_FAILED, + cause: Optional[Any] = None, + ): + super().__init__(message, code, cause) + self.name = "TurnkeyActivityError" + self.message = message + self.activity_id = activity_id + self.activity_status = activity_status + self.activity_type = activity_type + self.failure = failure + + def __str__(self) -> str: + text = self.message + if self.failure: + text += f": {self.failure}" + details = [ + f"{label}={value}" + for label, value in ( + ("activity_id", self.activity_id), + ("status", self.activity_status), + ("type", self.activity_type), + ) + if value + ] + if details: + text += f" ({', '.join(details)})" + return text + + +class TurnkeyActivityConsensusNeededError(TurnkeyActivityError): + """Exception raised when an activity needs more approvals. + + The activity is not failed. Store ``activity_id`` and fetch the activity + again with ``get_activity`` after the required approvals are in place. + """ + + def __init__( + self, + message: str, + activity_id: Optional[str] = None, + activity_status: Optional[str] = None, + activity_type: Optional[str] = None, + failure: Optional[str] = None, + code: Optional[TurnkeyErrorCodes] = TurnkeyErrorCodes.ACTIVITY_CONSENSUS_NEEDED, + cause: Optional[Any] = None, + ): + super().__init__( + message, activity_id, activity_status, activity_type, failure, code, cause + ) + self.name = "TurnkeyActivityConsensusNeededError"