sprntrl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sprntrl/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Sprntrl Python SDK — stealth browser-as-a-service client."""
2
+
3
+ from ._client import Sprntrl, AsyncSprntrl
4
+ from ._errors import (
5
+ SprntrlError,
6
+ APIError,
7
+ BadRequestError,
8
+ AuthenticationError,
9
+ PermissionDeniedError,
10
+ NotFoundError,
11
+ ConflictError,
12
+ UnprocessableEntityError,
13
+ RateLimitError,
14
+ InternalServerError,
15
+ APIConnectionError,
16
+ APIConnectionTimeoutError,
17
+ )
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "Sprntrl",
23
+ "AsyncSprntrl",
24
+ "SprntrlError",
25
+ "APIError",
26
+ "BadRequestError",
27
+ "AuthenticationError",
28
+ "PermissionDeniedError",
29
+ "NotFoundError",
30
+ "ConflictError",
31
+ "UnprocessableEntityError",
32
+ "RateLimitError",
33
+ "InternalServerError",
34
+ "APIConnectionError",
35
+ "APIConnectionTimeoutError",
36
+ "__version__",
37
+ ]
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ import asyncio
6
+ import random
7
+ from typing import Any, Mapping, Union
8
+ from urllib.parse import urljoin
9
+
10
+ import httpx
11
+
12
+ from ._errors import (
13
+ APIConnectionError,
14
+ APIConnectionTimeoutError,
15
+ error_for_status,
16
+ SprntrlError,
17
+ )
18
+
19
+
20
+ DEFAULT_BASE_URL = "https://api.supernatural.sh"
21
+ DEFAULT_TIMEOUT = 60.0
22
+ DEFAULT_MAX_RETRIES = 2
23
+ _USER_AGENT = "sprntrl-python/0.1.0"
24
+
25
+ JSONLike = Union[Mapping[str, Any], list, str, int, float, bool, None]
26
+
27
+
28
+ class _BaseClient:
29
+ """Shared config and helpers for sync and async clients."""
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ api_key: str | None = None,
35
+ base_url: str | None = None,
36
+ timeout: float = DEFAULT_TIMEOUT,
37
+ max_retries: int = DEFAULT_MAX_RETRIES,
38
+ default_headers: Mapping[str, str] | None = None,
39
+ ) -> None:
40
+ api_key = api_key or os.environ.get("SPRNTRL_API_KEY")
41
+ if not api_key:
42
+ raise SprntrlError(
43
+ "No API key provided. Pass api_key= or set SPRNTRL_API_KEY."
44
+ )
45
+ base_url = base_url or os.environ.get("SPRNTRL_BASE_URL") or DEFAULT_BASE_URL
46
+ self.api_key = api_key
47
+ self.base_url = base_url.rstrip("/")
48
+ self.timeout = timeout
49
+ self.max_retries = max_retries
50
+ self._default_headers = dict(default_headers or {})
51
+
52
+ def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
53
+ h = {
54
+ "Authorization": f"ApiKey {self.api_key}",
55
+ "Accept": "application/json",
56
+ "User-Agent": _USER_AGENT,
57
+ **self._default_headers,
58
+ }
59
+ if extra:
60
+ h.update(extra)
61
+ return h
62
+
63
+ def _url(self, path: str) -> str:
64
+ if path.startswith("http://") or path.startswith("https://"):
65
+ return path
66
+ if not path.startswith("/"):
67
+ path = "/" + path
68
+ return self.base_url + path
69
+
70
+ @staticmethod
71
+ def _should_retry(exc: Exception | None, status: int | None) -> bool:
72
+ if exc is not None:
73
+ return True # connection-level errors
74
+ if status is None:
75
+ return False
76
+ return status == 408 or status == 409 or status == 429 or status >= 500
77
+
78
+ @staticmethod
79
+ def _backoff(attempt: int) -> float:
80
+ # 0.5s, 1s, 2s + jitter
81
+ base = 0.5 * (2 ** attempt)
82
+ return base + random.uniform(0, 0.25)
83
+
84
+ @staticmethod
85
+ def _parse_error(response: httpx.Response) -> tuple[str, Any]:
86
+ body: Any = None
87
+ try:
88
+ body = response.json()
89
+ except Exception:
90
+ body = response.text
91
+ msg = None
92
+ if isinstance(body, dict):
93
+ msg = body.get("error") or body.get("message") or body.get("detail")
94
+ if not msg:
95
+ msg = f"HTTP {response.status_code}"
96
+ return msg, body
97
+
98
+
99
+ class SyncClient(_BaseClient):
100
+ def __init__(self, **kwargs: Any) -> None:
101
+ super().__init__(**kwargs)
102
+ self._http = httpx.Client(timeout=self.timeout)
103
+
104
+ def close(self) -> None:
105
+ self._http.close()
106
+
107
+ def __enter__(self) -> "SyncClient":
108
+ return self
109
+
110
+ def __exit__(self, *exc: Any) -> None:
111
+ self.close()
112
+
113
+ def _request(
114
+ self,
115
+ method: str,
116
+ path: str,
117
+ *,
118
+ json: JSONLike = None,
119
+ params: Mapping[str, Any] | None = None,
120
+ headers: Mapping[str, str] | None = None,
121
+ files: Any = None,
122
+ data: Any = None,
123
+ stream: bool = False,
124
+ ) -> Any:
125
+ url = self._url(path)
126
+ h = self._headers(headers)
127
+ last_exc: Exception | None = None
128
+ for attempt in range(self.max_retries + 1):
129
+ try:
130
+ response = self._http.request(
131
+ method,
132
+ url,
133
+ json=json if files is None and data is None else None,
134
+ params=params,
135
+ headers=h,
136
+ files=files,
137
+ data=data,
138
+ )
139
+ except httpx.TimeoutException as exc:
140
+ last_exc = APIConnectionTimeoutError(str(exc))
141
+ except httpx.RequestError as exc:
142
+ last_exc = APIConnectionError(str(exc), cause=exc)
143
+ else:
144
+ status = response.status_code
145
+ if 200 <= status < 300:
146
+ if stream:
147
+ return response
148
+ if not response.content:
149
+ return None
150
+ ctype = response.headers.get("content-type", "")
151
+ if "application/json" in ctype:
152
+ return response.json()
153
+ return response.content
154
+ if self._should_retry(None, status) and attempt < self.max_retries:
155
+ time.sleep(self._backoff(attempt))
156
+ continue
157
+ msg, body = self._parse_error(response)
158
+ raise error_for_status(status, msg, body=body)
159
+ if attempt < self.max_retries:
160
+ time.sleep(self._backoff(attempt))
161
+ continue
162
+ raise last_exc
163
+ assert last_exc is not None
164
+ raise last_exc
165
+
166
+
167
+ class AsyncClient(_BaseClient):
168
+ def __init__(self, **kwargs: Any) -> None:
169
+ super().__init__(**kwargs)
170
+ self._http = httpx.AsyncClient(timeout=self.timeout)
171
+
172
+ async def close(self) -> None:
173
+ await self._http.aclose()
174
+
175
+ async def __aenter__(self) -> "AsyncClient":
176
+ return self
177
+
178
+ async def __aexit__(self, *exc: Any) -> None:
179
+ await self.close()
180
+
181
+ async def _request(
182
+ self,
183
+ method: str,
184
+ path: str,
185
+ *,
186
+ json: JSONLike = None,
187
+ params: Mapping[str, Any] | None = None,
188
+ headers: Mapping[str, str] | None = None,
189
+ files: Any = None,
190
+ data: Any = None,
191
+ stream: bool = False,
192
+ ) -> Any:
193
+ url = self._url(path)
194
+ h = self._headers(headers)
195
+ last_exc: Exception | None = None
196
+ for attempt in range(self.max_retries + 1):
197
+ try:
198
+ response = await self._http.request(
199
+ method,
200
+ url,
201
+ json=json if files is None and data is None else None,
202
+ params=params,
203
+ headers=h,
204
+ files=files,
205
+ data=data,
206
+ )
207
+ except httpx.TimeoutException as exc:
208
+ last_exc = APIConnectionTimeoutError(str(exc))
209
+ except httpx.RequestError as exc:
210
+ last_exc = APIConnectionError(str(exc), cause=exc)
211
+ else:
212
+ status = response.status_code
213
+ if 200 <= status < 300:
214
+ if stream:
215
+ return response
216
+ if not response.content:
217
+ return None
218
+ ctype = response.headers.get("content-type", "")
219
+ if "application/json" in ctype:
220
+ return response.json()
221
+ return response.content
222
+ if self._should_retry(None, status) and attempt < self.max_retries:
223
+ await asyncio.sleep(self._backoff(attempt))
224
+ continue
225
+ msg, body = self._parse_error(response)
226
+ raise error_for_status(status, msg, body=body)
227
+ if attempt < self.max_retries:
228
+ await asyncio.sleep(self._backoff(attempt))
229
+ continue
230
+ raise last_exc
231
+ assert last_exc is not None
232
+ raise last_exc
sprntrl/_client.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from ._base_client import SyncClient, AsyncClient
4
+ from .resources import (
5
+ Sessions,
6
+ AsyncSessions,
7
+ Profiles,
8
+ AsyncProfiles,
9
+ Templates,
10
+ AsyncTemplates,
11
+ IPWhitelist,
12
+ AsyncIPWhitelist,
13
+ Usage,
14
+ AsyncUsage,
15
+ User,
16
+ AsyncUser,
17
+ APIKeys,
18
+ AsyncAPIKeys,
19
+ )
20
+
21
+
22
+ class Sprntrl(SyncClient):
23
+ """Sync Sprntrl SDK client.
24
+
25
+ Usage:
26
+ with Sprntrl() as client:
27
+ session = client.sessions.create(os="macos", location="us-east")
28
+ client.sessions.wait_until_ready(session["id"])
29
+ """
30
+
31
+ def __init__(self, **kwargs) -> None:
32
+ super().__init__(**kwargs)
33
+ self.sessions = Sessions(self)
34
+ self.profiles = Profiles(self)
35
+ self.templates = Templates(self)
36
+ self.ip_whitelist = IPWhitelist(self)
37
+ self.usage = Usage(self)
38
+ self.user = User(self)
39
+ self.api_keys = APIKeys(self)
40
+
41
+
42
+ class AsyncSprntrl(AsyncClient):
43
+ """Async Sprntrl SDK client.
44
+
45
+ Usage:
46
+ async with AsyncSprntrl() as client:
47
+ session = await client.sessions.create(os="macos", location="us-east")
48
+ await client.sessions.wait_until_ready(session["id"])
49
+ """
50
+
51
+ def __init__(self, **kwargs) -> None:
52
+ super().__init__(**kwargs)
53
+ self.sessions = AsyncSessions(self)
54
+ self.profiles = AsyncProfiles(self)
55
+ self.templates = AsyncTemplates(self)
56
+ self.ip_whitelist = AsyncIPWhitelist(self)
57
+ self.usage = AsyncUsage(self)
58
+ self.user = AsyncUser(self)
59
+ self.api_keys = AsyncAPIKeys(self)
sprntrl/_errors.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SprntrlError(Exception):
7
+ pass
8
+
9
+
10
+ class APIError(SprntrlError):
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int | None = None,
16
+ body: Any = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.status = status
20
+ # NOTE: the live httpx.Response is deliberately NOT retained. Its
21
+ # `.request.headers` carries `Authorization: ApiKey <key>`, so keeping
22
+ # it here would leak the API key into any log/Sentry capture of the
23
+ # exception. Only the status code and the parsed (server-controlled)
24
+ # error body are exposed.
25
+ self.body = body
26
+
27
+
28
+ class BadRequestError(APIError): pass
29
+ class AuthenticationError(APIError): pass
30
+ class PermissionDeniedError(APIError): pass
31
+ class NotFoundError(APIError): pass
32
+ class ConflictError(APIError): pass
33
+ class UnprocessableEntityError(APIError): pass
34
+ class RateLimitError(APIError): pass
35
+ class InternalServerError(APIError): pass
36
+
37
+
38
+ class APIConnectionError(APIError):
39
+ def __init__(self, message: str = "Connection error", *, cause: BaseException | None = None) -> None:
40
+ super().__init__(message)
41
+ self.__cause__ = cause
42
+
43
+
44
+ class APIConnectionTimeoutError(APIConnectionError):
45
+ def __init__(self, message: str = "Request timed out") -> None:
46
+ super().__init__(message)
47
+
48
+
49
+ _STATUS_MAP: dict[int, type[APIError]] = {
50
+ 400: BadRequestError,
51
+ 401: AuthenticationError,
52
+ 403: PermissionDeniedError,
53
+ 404: NotFoundError,
54
+ 409: ConflictError,
55
+ 422: UnprocessableEntityError,
56
+ 429: RateLimitError,
57
+ }
58
+
59
+
60
+ def error_for_status(status: int, message: str, *, body: Any = None) -> APIError:
61
+ if status >= 500:
62
+ cls = InternalServerError
63
+ else:
64
+ cls = _STATUS_MAP.get(status, APIError)
65
+ return cls(message, status=status, body=body)
sprntrl/_types.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, TypedDict
4
+
5
+
6
+ ProxyProtocol = Literal["HTTP", "HTTPS", "SOCKS5"]
7
+ SessionStatus = Literal["creating", "running", "stopping", "stopped", "failed", "archiving"]
8
+ OS = Literal["macos", "windows"]
9
+
10
+
11
+ class ProxyConfig(TypedDict, total=False):
12
+ protocol: ProxyProtocol
13
+ host: str
14
+ port: int
15
+ username: str
16
+ password: str
17
+
18
+
19
+ class ProxySummary(TypedDict, total=False):
20
+ """Display-friendly view of a session's proxy. Present only for BYO
21
+ (bring-your-own) proxies — pool proxies are shared infra and the API
22
+ deliberately hides their host/port. The password is never returned."""
23
+
24
+ protocol: str
25
+ host: str
26
+ port: int
27
+ username: str
28
+ address: str # convenience form: protocol://host:port
29
+
30
+
31
+ class Session(TypedDict, total=False):
32
+ id: str
33
+ user_id: str
34
+ profile_id: str | None
35
+ container_id: str | None
36
+ chrome_port: int | None
37
+ status: SessionStatus
38
+ persistent: bool
39
+ captcha_solver: bool
40
+ session_name: str | None
41
+ data_dir_path: str | None
42
+ data_dir_size: int
43
+ storage_status: str
44
+ os: OS
45
+ location: str
46
+ started_at: str | None
47
+ stopped_at: str | None
48
+ created_at: str
49
+ cdp_url: str
50
+ uptime_seconds: int
51
+ sidecar_port: int
52
+ proxy: ProxySummary # present only for BYO-proxy sessions
53
+
54
+
55
+ class PaginatedSessions(TypedDict):
56
+ sessions: list[Session]
57
+ total: int
58
+ page: int
59
+ per_page: int
60
+
61
+
62
+ class Profile(TypedDict, total=False):
63
+ id: str
64
+ user_id: str
65
+ name: str
66
+ description: str | None
67
+ os: str | None
68
+ location: str | None
69
+ persona: str | None
70
+ config_json: Any
71
+ template_id: str | None
72
+ created_at: str
73
+ updated_at: str
74
+
75
+
76
+ class Template(TypedDict, total=False):
77
+ id: str
78
+ name: str
79
+ os: str
80
+ config: Any
81
+
82
+
83
+ class IPWhitelistEntry(TypedDict, total=False):
84
+ id: str
85
+ user_id: str
86
+ ip_address: str
87
+ label: str | None
88
+ created_at: str
89
+
90
+
91
+ class APIKey(TypedDict, total=False):
92
+ id: str
93
+ name: str
94
+ key_prefix: str
95
+ last_used_at: str | None
96
+ created_at: str
97
+ revoked_at: str | None
98
+
99
+
100
+ class APIKeyCreated(APIKey, total=False):
101
+ key: str # Only present once at creation time
102
+
103
+
104
+ class Usage(TypedDict, total=False):
105
+ total_minutes: int
106
+ plan_minutes: int
107
+ overage_minutes: int
108
+ month: str
109
+ plan: str
110
+ usage_percentage: float
111
+ allow_hours_overage: bool
112
+ profile_count: int
113
+ max_profiles: int
114
+ bandwidth_rate_cents: int # pool-proxy bandwidth, cents per GB
115
+ hours_overage_rate_cents: int # cents per hour over plan minutes
116
+ bandwidth_bytes: int # pool-proxy bytes consumed this period
117
+ bandwidth_charge_amount_cents: int # accrued bandwidth overage, cents
118
+ hours_overage_minutes: int # minutes used beyond plan allowance
119
+ hours_overage_amount_cents: int # accrued hours-overage charge, cents
120
+
121
+
122
+ class UsageMonth(TypedDict):
123
+ month: str
124
+ total_minutes: int
125
+ plan_minutes: int
126
+ overage_minutes: int
127
+
128
+
129
+ AccountStatus = Literal["pending_verification", "pending_payment", "active"]
130
+
131
+
132
+ class User(TypedDict, total=False):
133
+ id: str
134
+ email: str
135
+ name: str | None
136
+ plan: str
137
+ role: str
138
+ allow_hours_overage: bool
139
+ byo_proxy_only: bool # account may only use BYO proxies (no pool proxy)
140
+ bandwidth_rate_cents: int # pool-proxy bandwidth, cents per GB
141
+ hours_overage_rate_cents: int # cents per hour over plan minutes
142
+ must_change_password: bool
143
+ email_verified: bool
144
+ account_status: AccountStatus
145
+ oauth_provider: str # "google" | "github"; absent for password accounts
146
+ created_at: str
147
+
148
+
149
+ class ChangePasswordResult(TypedDict, total=False):
150
+ message: str
151
+ # Fresh tokens — issued so a programmatic client can swap credentials
152
+ # without a re-login. Absent for cookie/session-only flows.
153
+ access_token: str
154
+ refresh_token: str
155
+ token_type: str
156
+
157
+
158
+ class FileInfo(TypedDict, total=False):
159
+ name: str
160
+ size: int
161
+ modified: str
sprntrl/_utils.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+
6
+ def seg(value: object) -> str:
7
+ """Percent-encode a single URL path segment.
8
+
9
+ Caller- or server-supplied values (session ids, filenames, profile ids,
10
+ etc.) are interpolated into request paths. Without encoding, a value
11
+ containing ``/``, ``..``, ``?`` or ``#`` could traverse to a different
12
+ authenticated endpoint or inject a query/fragment — all with the API key
13
+ attached. ``safe=""`` ensures even ``/`` is escaped so the value can only
14
+ ever be a single segment. Mirrors the Node SDK's ``encodeURIComponent``.
15
+ """
16
+ return quote(str(value), safe="")
File without changes
sprntrl/lib/browser.py ADDED
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping
4
+ from urllib.parse import urlparse, urlunparse
5
+
6
+ from .._errors import SprntrlError
7
+ from .._utils import seg
8
+
9
+ if TYPE_CHECKING:
10
+ from .._base_client import SyncClient, AsyncClient
11
+
12
+
13
+ def _cdp_url_for(client: "SyncClient | AsyncClient", session: Mapping[str, Any]) -> str:
14
+ """Build the CDP WebSocket URL from the client's base URL. The API proxies
15
+ the WebSocket through /api/v1/sessions/:id/cdp with an IP-whitelist check —
16
+ this path is what external callers must use. (The server also returns a
17
+ cdp_url field, but that's an internal host:port that isn't reachable from
18
+ outside the API host.)"""
19
+ session_id = session["id"]
20
+ parsed = urlparse(client.base_url)
21
+ scheme = "wss" if parsed.scheme == "https" else "ws"
22
+ return urlunparse((
23
+ scheme, parsed.netloc, f"/api/v1/sessions/{seg(session_id)}/cdp", "", "", "",
24
+ ))
25
+
26
+
27
+ def _ensure_playwright() -> Any:
28
+ try:
29
+ from playwright.sync_api import sync_playwright # type: ignore
30
+ except ImportError as exc:
31
+ raise SprntrlError(
32
+ "Playwright is not installed. Run `pip install playwright` "
33
+ "(and `playwright install chromium` once)."
34
+ ) from exc
35
+ return sync_playwright
36
+
37
+
38
+ def _ensure_playwright_async() -> Any:
39
+ try:
40
+ from playwright.async_api import async_playwright # type: ignore
41
+ except ImportError as exc:
42
+ raise SprntrlError(
43
+ "Playwright is not installed. Run `pip install playwright` "
44
+ "(and `playwright install chromium` once)."
45
+ ) from exc
46
+ return async_playwright
47
+
48
+
49
+ def connect_sync(
50
+ client: "SyncClient",
51
+ session: Mapping[str, Any],
52
+ *,
53
+ framework: str = "playwright",
54
+ auto_whitelist: bool = False,
55
+ ) -> Any:
56
+ if framework != "playwright":
57
+ raise SprntrlError(
58
+ f"Unsupported framework {framework!r}. Only 'playwright' is supported in Python."
59
+ )
60
+ if auto_whitelist:
61
+ try:
62
+ client._request(
63
+ "POST", "/api/v1/settings/ip-whitelist", json={"ip": "current"}
64
+ )
65
+ except Exception:
66
+ # Already-whitelisted or race; ignore — connect attempt will surface the real problem.
67
+ pass
68
+
69
+ cdp_url = _cdp_url_for(client, session)
70
+ sync_playwright = _ensure_playwright()
71
+ pw = sync_playwright().start()
72
+ try:
73
+ return pw.chromium.connect_over_cdp(cdp_url)
74
+ except Exception:
75
+ pw.stop()
76
+ raise
77
+
78
+
79
+ async def connect_async(
80
+ client: "AsyncClient",
81
+ session: Mapping[str, Any],
82
+ *,
83
+ framework: str = "playwright",
84
+ auto_whitelist: bool = False,
85
+ ) -> Any:
86
+ if framework != "playwright":
87
+ raise SprntrlError(
88
+ f"Unsupported framework {framework!r}. Only 'playwright' is supported in Python."
89
+ )
90
+ if auto_whitelist:
91
+ try:
92
+ await client._request(
93
+ "POST", "/api/v1/settings/ip-whitelist", json={"ip": "current"}
94
+ )
95
+ except Exception:
96
+ pass
97
+
98
+ cdp_url = _cdp_url_for(client, session)
99
+ async_playwright = _ensure_playwright_async()
100
+ pw = await async_playwright().start()
101
+ try:
102
+ return await pw.chromium.connect_over_cdp(cdp_url)
103
+ except Exception:
104
+ await pw.stop()
105
+ raise
sprntrl/py.typed ADDED
File without changes
@@ -0,0 +1,24 @@
1
+ from .sessions import Sessions, AsyncSessions
2
+ from .profiles import Profiles, AsyncProfiles
3
+ from .templates import Templates, AsyncTemplates
4
+ from .ip_whitelist import IPWhitelist, AsyncIPWhitelist
5
+ from .usage import Usage, AsyncUsage
6
+ from .user import User, AsyncUser
7
+ from .api_keys import APIKeys, AsyncAPIKeys
8
+
9
+ __all__ = [
10
+ "Sessions",
11
+ "AsyncSessions",
12
+ "Profiles",
13
+ "AsyncProfiles",
14
+ "Templates",
15
+ "AsyncTemplates",
16
+ "IPWhitelist",
17
+ "AsyncIPWhitelist",
18
+ "Usage",
19
+ "AsyncUsage",
20
+ "User",
21
+ "AsyncUser",
22
+ "APIKeys",
23
+ "AsyncAPIKeys",
24
+ ]