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
8 changes: 8 additions & 0 deletions .changeset/approval-polling.yml
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/authenticators-needed-error.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions codegen/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,5 @@
"ACTIVITY_STATUS_FAILED",
"ACTIVITY_STATUS_CONSENSUS_NEEDED",
"ACTIVITY_STATUS_REJECTED",
"ACTIVITY_STATUS_AUTHENTICATORS_NEEDED",
]
133 changes: 130 additions & 3 deletions codegen/http/generate_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.\"\"\"

Expand All @@ -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.

Expand All @@ -122,13 +138,19 @@ 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
self.organization_id = organization_id
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.
Expand Down Expand Up @@ -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.
\"\"\"
Expand All @@ -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":
Expand All @@ -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.

Expand All @@ -291,15 +412,18 @@ 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.
\"\"\"
# 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
Expand Down Expand Up @@ -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.
\"\"\"
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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"
Expand Down
27 changes: 21 additions & 6 deletions packages/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading