diff --git a/.changeset/approval-polling.yml b/.changeset/approval-polling.yml new file mode 100644 index 0000000..9a26612 --- /dev/null +++ b/.changeset/approval-polling.yml @@ -0,0 +1,8 @@ +type: minor +packages: [ http ] +changelog: |- + Added MFA and consensus handling for activities. + - Authenticators-needed stops polling and raises `TurnkeyActivityAuthenticatorsNeededError`. + - Added `mfa_polling` and `consensus_polling`. + - `mfa_polling` keeps polling an authenticators-needed activity until the status changes or the timeout is reached. The interval doubles on each attempt, up to 5 seconds. Omit it to raise `TurnkeyActivityAuthenticatorsNeededError` immediately. + - `consensus_polling` keeps polling a consensus-needed activity until the status changes or the timeout is reached. The interval stays fixed. Omit it to raise `TurnkeyActivityConsensusNeededError` immediately. diff --git a/.changeset/authenticators-needed-error.yml b/.changeset/authenticators-needed-error.yml new file mode 100644 index 0000000..a01d1ca --- /dev/null +++ b/.changeset/authenticators-needed-error.yml @@ -0,0 +1,6 @@ +type: minor +packages: [ sdk-types ] +changelog: |- + Added an error for activities that need authenticator approval. + - `TurnkeyActivityAuthenticatorsNeededError` is the exception raised when an activity is waiting on authenticator approval. + - `ACTIVITY_AUTHENTICATORS_NEEDED` is the `TurnkeyErrorCodes` value set on that exception diff --git a/codegen/constants.py b/codegen/constants.py index 620c250..ade5da5 100644 --- a/codegen/constants.py +++ b/codegen/constants.py @@ -126,4 +126,5 @@ "ACTIVITY_STATUS_FAILED", "ACTIVITY_STATUS_CONSENSUS_NEEDED", "ACTIVITY_STATUS_REJECTED", + "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED", ] diff --git a/codegen/http/generate_http.py b/codegen/http/generate_http.py index df6d48a..6f27288 100644 --- a/codegen/http/generate_http.py +++ b/codegen/http/generate_http.py @@ -101,6 +101,20 @@ def generate_sdk_client(swagger: Dict[str, Any]) -> str: # Generate class header code_buffer.append(""" +@dataclass +class PollingConfig: + \"\"\"Interval and timeout for an optional approval poll. + + Both values are milliseconds. Pass one as ``mfa_polling`` or + ``consensus_polling``. The poll stays off when the config is omitted, or + when either value is ``0``. The MFA poll doubles its interval on each + attempt, up to 5 seconds. The consensus poll keeps this interval. + \"\"\" + + interval_ms: int + timeout_ms: int + + class TurnkeyClient: \"\"\"Turnkey API HTTP client with auto-generated methods.\"\"\" @@ -111,7 +125,9 @@ def __init__( organization_id: str, default_timeout: int = 30, polling_interval_ms: int = 1000, - max_polling_retries: int = 10 + max_polling_retries: int = 10, + mfa_polling: Optional[PollingConfig] = None, + consensus_polling: Optional[PollingConfig] = None, ): \"\"\"Initialize the Turnkey client. @@ -122,6 +138,10 @@ def __init__( default_timeout: Default request timeout in seconds polling_interval_ms: Polling interval for activity status in milliseconds max_polling_retries: Maximum number of polling retries + mfa_polling: Optional authenticators-needed poll. Omit it to raise + TurnkeyActivityAuthenticatorsNeededError immediately. + consensus_polling: Optional consensus-needed poll. Omit it to raise + TurnkeyActivityConsensusNeededError immediately. \"\"\" self.base_url = base_url.rstrip("/") self.stamper = stamper @@ -129,6 +149,8 @@ def __init__( self.default_timeout = default_timeout self.polling_interval_ms = polling_interval_ms self.max_polling_retries = max_polling_retries + self.mfa_polling = mfa_polling + self.consensus_polling = consensus_polling def _serialize_body(self, body: Any) -> str: \"\"\"Serialize request body, handling Pydantic models recursively. @@ -240,6 +262,8 @@ def _raise_for_activity_status(self, activity: Any) -> None: Raises: TurnkeyActivityConsensusNeededError: If the activity needs more approvals. + TurnkeyActivityAuthenticatorsNeededError: If the activity needs authenticator + approval. TurnkeyActivityError: If the activity failed, was rejected, or polling stopped before it reached a terminal status. \"\"\" @@ -260,6 +284,15 @@ def _raise_for_activity_status(self, activity: Any) -> None: failure=failure_message, ) + if status == "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED": + raise TurnkeyActivityAuthenticatorsNeededError( + f"Authenticators 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": @@ -277,6 +310,94 @@ def _raise_for_activity_status(self, activity: Any) -> None: failure=failure_message, ) + def _poll_while_status( + self, + activity: Any, + watched_status: str, + interval_ms: int, + timeout_ms: int, + backoff: bool, + ) -> Any: + \"\"\"Poll while an activity stays in one approval status. + + Disabled unless interval and timeout are both greater than zero. If the + timeout is reached first, the latest activity is returned so the caller can + raise. If the status changes, normal activity polling resumes. A later + approval status is not handed to the other approval poll. + + Args: + activity: Activity already in ``watched_status``. + watched_status: Status to keep polling. + interval_ms: Delay before each fetch, in milliseconds. + timeout_ms: How long to keep polling, in milliseconds. + backoff: When true, double the delay each attempt, up to 5 seconds. + + Returns: + The latest activity. + \"\"\" + status = getattr(activity.status, "value", activity.status) + if status != watched_status: + return activity + if interval_ms <= 0 or timeout_ms <= 0: + return activity + + deadline = time.monotonic() + (timeout_ms / 1000.0) + delay_ms = interval_ms + while True: + remaining_s = deadline - time.monotonic() + if remaining_s <= 0: + return activity + + time.sleep(min(delay_ms / 1000.0, remaining_s)) + if time.monotonic() >= deadline: + return activity + + poll_response = self.get_activity(GetActivityBody(activityId=activity.id)) + activity = poll_response.activity + status = getattr(activity.status, "value", activity.status) + if status != watched_status: + return self._poll_for_completion(activity) + + if backoff: + delay_ms = min(delay_ms * 2, 5000) + + def _poll_mfa(self, activity: Any) -> Any: + \"\"\"Poll an authenticators-needed activity on its own interval and timeout.\"\"\" + interval_ms, timeout_ms = self._polling_bounds(self.mfa_polling) + return self._poll_while_status( + activity, + "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED", + interval_ms, + timeout_ms, + True, + ) + + def _poll_consensus(self, activity: Any) -> Any: + \"\"\"Poll a consensus-needed activity on its own interval and timeout.\"\"\" + interval_ms, timeout_ms = self._polling_bounds(self.consensus_polling) + return self._poll_while_status( + activity, + "ACTIVITY_STATUS_CONSENSUS_NEEDED", + interval_ms, + timeout_ms, + False, + ) + + def _polling_bounds(self, polling: Optional[PollingConfig]) -> tuple[int, int]: + \"\"\"Return an interval and timeout, or zeros when the poll is off.\"\"\" + if polling is None: + return 0, 0 + return polling.interval_ms, polling.timeout_ms + + def _poll_approval_if_needed(self, activity: Any) -> Any: + \"\"\"Run the consensus or MFA poll once, when that poll is enabled.\"\"\" + status = getattr(activity.status, "value", activity.status) + if status == "ACTIVITY_STATUS_CONSENSUS_NEEDED": + return self._poll_consensus(activity) + if status == "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED": + return self._poll_mfa(activity) + return activity + def _activity(self, url: str, body: Dict[str, Any], result_key: str, response_type: type) -> Any: \"\"\"Execute an activity and poll for completion. @@ -291,6 +412,8 @@ def _activity(self, url: str, body: Dict[str, Any], result_key: str, response_ty Raises: TurnkeyActivityConsensusNeededError: If the activity needs more approvals. + TurnkeyActivityAuthenticatorsNeededError: If the activity needs authenticator + approval. TurnkeyActivityError: If the activity did not complete, or completed without the expected result. TurnkeyNetworkError: If a request fails. @@ -298,8 +421,9 @@ def _activity(self, url: str, body: Dict[str, Any], result_key: str, response_ty # Make initial request, we parse as activity response without result fields initial_response = self._request(url, body, GetActivityResponse) - # Poll for completion + # Poll for completion. Consensus and authenticators each have their own poll. activity = self._poll_for_completion(initial_response.activity) + activity = self._poll_approval_if_needed(activity) self._raise_for_activity_status(activity) # Get the versioned result (e.g., 'createApiKeysResultV2') and flatten its fields into the response @@ -356,6 +480,8 @@ def send_signed_request(self, signed_request: SignedRequest, response_type: type Raises: TurnkeyActivityConsensusNeededError: If the activity needs more approvals. + TurnkeyActivityAuthenticatorsNeededError: If the activity needs authenticator + approval. TurnkeyActivityError: If response_type is given and the activity did not complete. TurnkeyNetworkError: If the request fails. \"\"\" @@ -400,6 +526,7 @@ def send_signed_request(self, signed_request: SignedRequest, response_type: type if signed_request.type == RequestType.ACTIVITY: activity_response = GetActivityResponse(**payload) activity = self._poll_for_completion(activity_response.activity) + activity = self._poll_approval_if_needed(activity) # Update payload with polled activity activity_dict = activity.model_dump(by_alias=True, exclude_none=True) if hasattr(activity, 'model_dump') else {} @@ -685,7 +812,7 @@ def main(): # Build full output output = f"{COMMENT_HEADER}\n\n" - output += "import json\nimport time\nfrom typing import Any, Callable, Dict, Optional, TypeVar, overload\nimport requests\n" + output += "import json\nimport time\nfrom dataclasses import dataclass\nfrom typing import Any, Callable, Dict, Optional, TypeVar, overload\nimport requests\n" output += "from turnkey_api_key_stamper import ApiKeyStamper\n" output += "from turnkey_sdk_types import *\n" output += "from ..version import VERSION\n\n" diff --git a/packages/http/README.md b/packages/http/README.md index 2e377a3..76bbf7a 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -41,21 +41,36 @@ 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`. +Activity methods poll until the activity finishes. If it does not complete, they raise an error. `TurnkeyActivityConsensusNeededError` means the activity needs more approvals. `TurnkeyActivityAuthenticatorsNeededError` means it needs authenticator approval. `TurnkeyActivityError` means it failed, was rejected, or was still pending when polling stopped. These errors carry `activity_id`, `activity_status`, `activity_type`, and `failure`. Catch the subclasses first, because they extend `TurnkeyActivityError`. + +Consensus-needed and authenticators-needed each stop the normal activity poll. Pass a `PollingConfig` as `consensus_polling` or `mfa_polling` to keep polling that status. The MFA interval doubles on each attempt, up to 5 seconds. The consensus interval stays fixed. Omit a config to raise that status's error immediately. A timeout raises the same error. Use `activity_id` with `get_activity` after the missing approvals are in place. ```python -from turnkey_sdk_types import TurnkeyActivityConsensusNeededError, TurnkeyActivityError +from turnkey_http import PollingConfig, TurnkeyClient +from turnkey_sdk_types import ( + TurnkeyActivityAuthenticatorsNeededError, + TurnkeyActivityConsensusNeededError, + TurnkeyActivityError, +) + +client = TurnkeyClient( + base_url="https://api.turnkey.com", + stamper=stamper, + organization_id="your-org-id", + consensus_polling=PollingConfig(interval_ms=500, timeout_ms=30_000), + mfa_polling=PollingConfig(interval_ms=500, timeout_ms=30_000), +) try: - wallet = client.create_wallet(body) + response = ... # any activity method except TurnkeyActivityConsensusNeededError as err: - save_for_later(err.activity_id) # fetch it again with get_activity after approval + activity_id = err.activity_id +except TurnkeyActivityAuthenticatorsNeededError as err: + activity_id = err.activity_id 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 662c10f..f67fe87 100644 --- a/packages/http/src/turnkey_http/generated/client.py +++ b/packages/http/src/turnkey_http/generated/client.py @@ -2,6 +2,7 @@ import json import time +from dataclasses import dataclass from typing import Any, Callable, Dict, Optional, TypeVar, overload import requests from turnkey_api_key_stamper import ApiKeyStamper @@ -15,9 +16,24 @@ "ACTIVITY_STATUS_FAILED", "ACTIVITY_STATUS_CONSENSUS_NEEDED", "ACTIVITY_STATUS_REJECTED", + "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED", ] +@dataclass +class PollingConfig: + """Interval and timeout for an optional approval poll. + + Both values are milliseconds. Pass one as ``mfa_polling`` or + ``consensus_polling``. The poll stays off when the config is omitted, or + when either value is ``0``. The MFA poll doubles its interval on each + attempt, up to 5 seconds. The consensus poll keeps this interval. + """ + + interval_ms: int + timeout_ms: int + + class TurnkeyClient: """Turnkey API HTTP client with auto-generated methods.""" @@ -29,6 +45,8 @@ def __init__( default_timeout: int = 30, polling_interval_ms: int = 1000, max_polling_retries: int = 10, + mfa_polling: Optional[PollingConfig] = None, + consensus_polling: Optional[PollingConfig] = None, ): """Initialize the Turnkey client. @@ -39,6 +57,10 @@ def __init__( default_timeout: Default request timeout in seconds polling_interval_ms: Polling interval for activity status in milliseconds max_polling_retries: Maximum number of polling retries + mfa_polling: Optional authenticators-needed poll. Omit it to raise + TurnkeyActivityAuthenticatorsNeededError immediately. + consensus_polling: Optional consensus-needed poll. Omit it to raise + TurnkeyActivityConsensusNeededError immediately. """ self.base_url = base_url.rstrip("/") self.stamper = stamper @@ -46,6 +68,8 @@ def __init__( self.default_timeout = default_timeout self.polling_interval_ms = polling_interval_ms self.max_polling_retries = max_polling_retries + self.mfa_polling = mfa_polling + self.consensus_polling = consensus_polling def _serialize_body(self, body: Any) -> str: """Serialize request body, handling Pydantic models recursively. @@ -154,6 +178,8 @@ def _raise_for_activity_status(self, activity: Any) -> None: Raises: TurnkeyActivityConsensusNeededError: If the activity needs more approvals. + TurnkeyActivityAuthenticatorsNeededError: If the activity needs authenticator + approval. TurnkeyActivityError: If the activity failed, was rejected, or polling stopped before it reached a terminal status. """ @@ -174,6 +200,15 @@ def _raise_for_activity_status(self, activity: Any) -> None: failure=failure_message, ) + if status == "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED": + raise TurnkeyActivityAuthenticatorsNeededError( + f"Authenticators 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": @@ -193,6 +228,94 @@ def _raise_for_activity_status(self, activity: Any) -> None: failure=failure_message, ) + def _poll_while_status( + self, + activity: Any, + watched_status: str, + interval_ms: int, + timeout_ms: int, + backoff: bool, + ) -> Any: + """Poll while an activity stays in one approval status. + + Disabled unless interval and timeout are both greater than zero. If the + timeout is reached first, the latest activity is returned so the caller can + raise. If the status changes, normal activity polling resumes. A later + approval status is not handed to the other approval poll. + + Args: + activity: Activity already in ``watched_status``. + watched_status: Status to keep polling. + interval_ms: Delay before each fetch, in milliseconds. + timeout_ms: How long to keep polling, in milliseconds. + backoff: When true, double the delay each attempt, up to 5 seconds. + + Returns: + The latest activity. + """ + status = getattr(activity.status, "value", activity.status) + if status != watched_status: + return activity + if interval_ms <= 0 or timeout_ms <= 0: + return activity + + deadline = time.monotonic() + (timeout_ms / 1000.0) + delay_ms = interval_ms + while True: + remaining_s = deadline - time.monotonic() + if remaining_s <= 0: + return activity + + time.sleep(min(delay_ms / 1000.0, remaining_s)) + if time.monotonic() >= deadline: + return activity + + poll_response = self.get_activity(GetActivityBody(activityId=activity.id)) + activity = poll_response.activity + status = getattr(activity.status, "value", activity.status) + if status != watched_status: + return self._poll_for_completion(activity) + + if backoff: + delay_ms = min(delay_ms * 2, 5000) + + def _poll_mfa(self, activity: Any) -> Any: + """Poll an authenticators-needed activity on its own interval and timeout.""" + interval_ms, timeout_ms = self._polling_bounds(self.mfa_polling) + return self._poll_while_status( + activity, + "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED", + interval_ms, + timeout_ms, + True, + ) + + def _poll_consensus(self, activity: Any) -> Any: + """Poll a consensus-needed activity on its own interval and timeout.""" + interval_ms, timeout_ms = self._polling_bounds(self.consensus_polling) + return self._poll_while_status( + activity, + "ACTIVITY_STATUS_CONSENSUS_NEEDED", + interval_ms, + timeout_ms, + False, + ) + + def _polling_bounds(self, polling: Optional[PollingConfig]) -> tuple[int, int]: + """Return an interval and timeout, or zeros when the poll is off.""" + if polling is None: + return 0, 0 + return polling.interval_ms, polling.timeout_ms + + def _poll_approval_if_needed(self, activity: Any) -> Any: + """Run the consensus or MFA poll once, when that poll is enabled.""" + status = getattr(activity.status, "value", activity.status) + if status == "ACTIVITY_STATUS_CONSENSUS_NEEDED": + return self._poll_consensus(activity) + if status == "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED": + return self._poll_mfa(activity) + return activity + def _activity( self, url: str, body: Dict[str, Any], result_key: str, response_type: type ) -> Any: @@ -209,6 +332,8 @@ def _activity( Raises: TurnkeyActivityConsensusNeededError: If the activity needs more approvals. + TurnkeyActivityAuthenticatorsNeededError: If the activity needs authenticator + approval. TurnkeyActivityError: If the activity did not complete, or completed without the expected result. TurnkeyNetworkError: If a request fails. @@ -216,8 +341,9 @@ def _activity( # Make initial request, we parse as activity response without result fields initial_response = self._request(url, body, GetActivityResponse) - # Poll for completion + # Poll for completion. Consensus and authenticators each have their own poll. activity = self._poll_for_completion(initial_response.activity) + activity = self._poll_approval_if_needed(activity) self._raise_for_activity_status(activity) # Get the versioned result (e.g., 'createApiKeysResultV2') and flatten its fields into the response @@ -282,6 +408,8 @@ def send_signed_request( Raises: TurnkeyActivityConsensusNeededError: If the activity needs more approvals. + TurnkeyActivityAuthenticatorsNeededError: If the activity needs authenticator + approval. TurnkeyActivityError: If response_type is given and the activity did not complete. TurnkeyNetworkError: If the request fails. """ @@ -325,6 +453,7 @@ def send_signed_request( if signed_request.type == RequestType.ACTIVITY: activity_response = GetActivityResponse(**payload) activity = self._poll_for_completion(activity_response.activity) + activity = self._poll_approval_if_needed(activity) # Update payload with polled activity activity_dict = ( diff --git a/packages/http/tests/test_activity_errors.py b/packages/http/tests/test_activity_errors.py index 293cc69..eba5846 100644 --- a/packages/http/tests/test_activity_errors.py +++ b/packages/http/tests/test_activity_errors.py @@ -15,12 +15,13 @@ CreateWalletBody, RequestType, SignedRequest, + TurnkeyActivityAuthenticatorsNeededError, TurnkeyActivityConsensusNeededError, TurnkeyActivityError, TurnkeyErrorCodes, ) from turnkey_http.generated import client as client_module -from turnkey_http.generated.client import TurnkeyClient +from turnkey_http.generated.client import PollingConfig, TurnkeyClient ACTIVITY_ID = "activity-123" ORG_ID = "org-123" @@ -154,8 +155,135 @@ def test_rejected_activity_raises(client, mock_post): assert "rejected" in str(err) +def completed_wallet_payload() -> Dict[str, Any]: + return activity_payload( + "ACTIVITY_STATUS_COMPLETED", + result={"createWalletResult": {"walletId": "w-1", "addresses": ["0xabc"]}}, + ) + + +def enable_mfa_polling(client: TurnkeyClient) -> None: + client.mfa_polling = PollingConfig(interval_ms=1, timeout_ms=5000) + + +def enable_consensus_polling(client: TurnkeyClient) -> None: + client.consensus_polling = PollingConfig(interval_ms=1, timeout_ms=5000) + + +def record_sleeps(monkeypatch) -> List[float]: + sleeps: List[float] = [] + clock = {"now": 0.0} + + def monotonic() -> float: + return clock["now"] + + def sleep(seconds: float) -> None: + sleeps.append(seconds) + clock["now"] += seconds + + monkeypatch.setattr(client_module.time, "monotonic", monotonic) + monkeypatch.setattr(client_module.time, "sleep", sleep) + return sleeps + + +def test_authenticators_needed_raises_when_mfa_polling_is_off(client, mock_post): + calls = mock_post(activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED")) + + with pytest.raises(TurnkeyActivityAuthenticatorsNeededError) 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_AUTHENTICATORS_NEEDED" + assert err.activity_type == "ACTIVITY_TYPE_CREATE_WALLET" + assert err.code == TurnkeyErrorCodes.ACTIVITY_AUTHENTICATORS_NEEDED + # Terminal status: one submit, no extra polls. + assert len(calls) == 1 + + +def test_mfa_polling_waits_then_unpacks_result(client, mock_post): + calls = mock_post( + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + completed_wallet_payload(), + ) + enable_mfa_polling(client) + + response = create_wallet(client) + + assert response.walletId == "w-1" + # Submit, then two MFA polls. + assert len(calls) == 3 + + +def test_mfa_polling_timeout_raises(client, mock_post, monkeypatch): + calls = mock_post(activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED")) + clock = {"now": 0.0} + + def monotonic(): + return clock["now"] + + def sleep(seconds): + clock["now"] += seconds + + monkeypatch.setattr(client_module.time, "monotonic", monotonic) + monkeypatch.setattr(client_module.time, "sleep", sleep) + client.mfa_polling = PollingConfig(interval_ms=1000, timeout_ms=1) + + with pytest.raises(TurnkeyActivityAuthenticatorsNeededError) as exc_info: + create_wallet(client) + + assert exc_info.value.activity_id == ACTIVITY_ID + # Timeout fires before the first MFA fetch. + assert len(calls) == 1 + + +def test_mfa_polling_requires_both_interval_and_timeout(client, mock_post): + calls = mock_post(activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED")) + client.mfa_polling = PollingConfig(interval_ms=1, timeout_ms=0) + + with pytest.raises(TurnkeyActivityAuthenticatorsNeededError): + create_wallet(client) + + assert len(calls) == 1 + + +def test_mfa_polling_failure_raises_activity_error(client, mock_post): + mock_post( + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + activity_payload( + "ACTIVITY_STATUS_FAILED", + failure={"message": "policy denied"}, + ), + ) + enable_mfa_polling(client) + + with pytest.raises(TurnkeyActivityError) as exc_info: + create_wallet(client) + + err = exc_info.value + assert type(err) is TurnkeyActivityError + assert err.activity_status == "ACTIVITY_STATUS_FAILED" + assert err.failure == "policy denied" + + +def test_mfa_polling_resumes_activity_poll_when_pending(client, mock_post): + calls = mock_post( + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + activity_payload("ACTIVITY_STATUS_PENDING"), + completed_wallet_payload(), + ) + enable_mfa_polling(client) + + response = create_wallet(client) + + assert response.walletId == "w-1" + assert len(calls) == 3 + + def test_consensus_needed_raises_subclass(client, mock_post): - mock_post(activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED")) + calls = mock_post(activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED")) with pytest.raises(TurnkeyActivityConsensusNeededError) as exc_info: create_wallet(client) @@ -165,6 +293,95 @@ def test_consensus_needed_raises_subclass(client, mock_post): assert err.activity_id == ACTIVITY_ID assert err.activity_status == "ACTIVITY_STATUS_CONSENSUS_NEEDED" assert err.code == TurnkeyErrorCodes.ACTIVITY_CONSENSUS_NEEDED + assert len(calls) == 1 + + +def test_consensus_polling_waits_then_unpacks_result(client, mock_post): + calls = mock_post( + activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED"), + activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED"), + completed_wallet_payload(), + ) + enable_consensus_polling(client) + + response = create_wallet(client) + + assert response.walletId == "w-1" + assert len(calls) == 3 + + +def test_consensus_polling_timeout_raises(client, mock_post, monkeypatch): + calls = mock_post(activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED")) + record_sleeps(monkeypatch) + client.consensus_polling = PollingConfig(interval_ms=1000, timeout_ms=1) + + with pytest.raises(TurnkeyActivityConsensusNeededError) as exc_info: + create_wallet(client) + + assert exc_info.value.activity_id == ACTIVITY_ID + assert len(calls) == 1 + + +def test_consensus_polling_requires_both_interval_and_timeout(client, mock_post): + calls = mock_post(activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED")) + client.consensus_polling = PollingConfig(interval_ms=1, timeout_ms=0) + + with pytest.raises(TurnkeyActivityConsensusNeededError): + create_wallet(client) + + assert len(calls) == 1 + + +def test_consensus_polling_uses_a_fixed_interval(client, mock_post, monkeypatch): + mock_post( + activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED"), + activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED"), + completed_wallet_payload(), + ) + sleeps = record_sleeps(monkeypatch) + client.consensus_polling = PollingConfig(interval_ms=1000, timeout_ms=10_000) + + create_wallet(client) + + assert sleeps == [1.0, 1.0] + + +def test_mfa_polling_backs_off(client, mock_post, monkeypatch): + mock_post( + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + completed_wallet_payload(), + ) + sleeps = record_sleeps(monkeypatch) + client.mfa_polling = PollingConfig(interval_ms=1000, timeout_ms=10_000) + + create_wallet(client) + + assert sleeps == [1.0, 2.0] + + +def test_approval_polls_do_not_chain(client, mock_post): + calls = mock_post( + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED"), + ) + enable_mfa_polling(client) + enable_consensus_polling(client) + + with pytest.raises(TurnkeyActivityConsensusNeededError): + create_wallet(client) + + assert len(calls) == 2 + + mock_post( + activity_payload("ACTIVITY_STATUS_CONSENSUS_NEEDED"), + activity_payload("ACTIVITY_STATUS_AUTHENTICATORS_NEEDED"), + ) + + with pytest.raises(TurnkeyActivityAuthenticatorsNeededError): + create_wallet(client) + + assert len(calls) == 4 def test_polling_timeout_raises_with_last_status(client, mock_post): @@ -268,6 +485,46 @@ def test_raw_path_returns_failed_activity(client, mock_post): assert payload["activity"]["failure"]["message"] == "policy denied" +def test_raw_path_returns_authenticators_needed_activity(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + ) + ) + + payload = client.send_signed_request(signed_activity_request()) + + assert isinstance(payload, dict) + assert payload["activity"]["status"] == "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED" + + +def test_signed_request_mfa_polling_unpacks_result(client, mock_post): + mock_post( + activity_payload( + "ACTIVITY_STATUS_AUTHENTICATORS_NEEDED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + ), + activity_payload( + "ACTIVITY_STATUS_COMPLETED", + activity_type="ACTIVITY_TYPE_CREATE_SUB_ORGANIZATION_V7", + result={ + "createSubOrganizationResultV7": { + "subOrganizationId": "sub-1", + "rootUserIds": ["u-1"], + } + }, + ), + ) + enable_mfa_polling(client) + + response = client.send_signed_request( + signed_activity_request(), CreateSubOrganizationResponse + ) + + assert response.subOrganizationId == "sub-1" + + def test_raw_path_returns_consensus_needed_activity(client, mock_post): mock_post( activity_payload( diff --git a/packages/sdk-types/src/turnkey_sdk_types/errors.py b/packages/sdk-types/src/turnkey_sdk_types/errors.py index c865a84..7672883 100644 --- a/packages/sdk-types/src/turnkey_sdk_types/errors.py +++ b/packages/sdk-types/src/turnkey_sdk_types/errors.py @@ -11,6 +11,7 @@ class TurnkeyErrorCodes(str, Enum): BAD_RESPONSE = "BAD_RESPONSE" ACTIVITY_FAILED = "ACTIVITY_FAILED" ACTIVITY_CONSENSUS_NEEDED = "ACTIVITY_CONSENSUS_NEEDED" + ACTIVITY_AUTHENTICATORS_NEEDED = "ACTIVITY_AUTHENTICATORS_NEEDED" ACTIVITY_RESULT_MISSING = "ACTIVITY_RESULT_MISSING" @@ -102,6 +103,10 @@ class TurnkeyActivityConsensusNeededError(TurnkeyActivityError): The activity is not failed. Store ``activity_id`` and fetch the activity again with ``get_activity`` after the required approvals are in place. + + Pass ``consensus_polling`` when creating the client to keep polling until + the status changes or the timeout is reached. This error is raised when + that config is omitted, or when the poll times out. """ def __init__( @@ -118,3 +123,32 @@ def __init__( message, activity_id, activity_status, activity_type, failure, code, cause ) self.name = "TurnkeyActivityConsensusNeededError" + + +class TurnkeyActivityAuthenticatorsNeededError(TurnkeyActivityError): + """Exception raised when an activity needs authenticator approval. + + The activity is not failed. Store ``activity_id`` and fetch the activity + again with ``get_activity`` after the required authenticators are in place. + + Pass ``mfa_polling`` when creating the client to keep polling until the + status changes or the timeout is reached. This error is raised when that + config is omitted, or when the poll times out. + """ + + 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_AUTHENTICATORS_NEEDED, + cause: Optional[Any] = None, + ): + super().__init__( + message, activity_id, activity_status, activity_type, failure, code, cause + ) + self.name = "TurnkeyActivityAuthenticatorsNeededError"