qurl-python 0.2.1__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.
- layerv_qurl/__init__.py +161 -0
- layerv_qurl/_utils.py +1414 -0
- layerv_qurl/async_client.py +1512 -0
- layerv_qurl/client.py +1533 -0
- layerv_qurl/errors.py +152 -0
- layerv_qurl/langchain.py +159 -0
- layerv_qurl/py.typed +0 -0
- layerv_qurl/types.py +690 -0
- qurl_python-0.2.1.dist-info/METADATA +282 -0
- qurl_python-0.2.1.dist-info/RECORD +12 -0
- qurl_python-0.2.1.dist-info/WHEEL +4 -0
- qurl_python-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1512 @@
|
|
|
1
|
+
"""Asynchronous qURL API client.
|
|
2
|
+
|
|
3
|
+
NOTE: Business logic mirrors client.py — keep both in sync.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from layerv_qurl._utils import (
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_MAX_RETRIES,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
RETRYABLE_STATUS,
|
|
19
|
+
RETRYABLE_STATUS_POST,
|
|
20
|
+
UNSET,
|
|
21
|
+
UnsetType,
|
|
22
|
+
build_body,
|
|
23
|
+
build_list_params,
|
|
24
|
+
build_query_params,
|
|
25
|
+
build_string_list,
|
|
26
|
+
default_user_agent,
|
|
27
|
+
domain_path_segment,
|
|
28
|
+
ensure_mutation_idempotency,
|
|
29
|
+
idempotency_headers,
|
|
30
|
+
logger,
|
|
31
|
+
mask_key,
|
|
32
|
+
parse_access_code,
|
|
33
|
+
parse_access_code_list_output,
|
|
34
|
+
parse_access_token,
|
|
35
|
+
parse_agent_bootstrap_output,
|
|
36
|
+
parse_api_key,
|
|
37
|
+
parse_api_key_list_output,
|
|
38
|
+
parse_batch_create_output,
|
|
39
|
+
parse_checkout_session,
|
|
40
|
+
parse_connector_installation_list_output,
|
|
41
|
+
parse_create_output,
|
|
42
|
+
parse_current_period_usage,
|
|
43
|
+
parse_customer,
|
|
44
|
+
parse_daily_usage,
|
|
45
|
+
parse_domain,
|
|
46
|
+
parse_domain_list_output,
|
|
47
|
+
parse_domain_verify_output,
|
|
48
|
+
parse_error,
|
|
49
|
+
parse_invoice_list_output,
|
|
50
|
+
parse_list_output,
|
|
51
|
+
parse_mint_output,
|
|
52
|
+
parse_portal_session,
|
|
53
|
+
parse_quota,
|
|
54
|
+
parse_qurl,
|
|
55
|
+
parse_redeem_access_code_output,
|
|
56
|
+
parse_resolve_output,
|
|
57
|
+
parse_resource,
|
|
58
|
+
parse_resource_detail,
|
|
59
|
+
parse_resource_list_output,
|
|
60
|
+
parse_session_list_output,
|
|
61
|
+
parse_session_terminate_output,
|
|
62
|
+
parse_webhook,
|
|
63
|
+
parse_webhook_delivery_list_output,
|
|
64
|
+
parse_webhook_event_types_output,
|
|
65
|
+
parse_webhook_list_output,
|
|
66
|
+
require_nonempty_update,
|
|
67
|
+
require_resource_id_prefix,
|
|
68
|
+
retry_delay,
|
|
69
|
+
validate_alias,
|
|
70
|
+
validate_create_input,
|
|
71
|
+
validate_domain_input,
|
|
72
|
+
validate_id,
|
|
73
|
+
validate_mint_input,
|
|
74
|
+
validate_nonnegative_int,
|
|
75
|
+
validate_required_string,
|
|
76
|
+
validate_update_input,
|
|
77
|
+
validate_webhook_url,
|
|
78
|
+
)
|
|
79
|
+
from layerv_qurl.errors import QURLError, QURLNetworkError, QURLTimeoutError
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
# The `list()` method on AsyncQURLClient shadows the `list` type
|
|
83
|
+
# inside the class body, so parameter/return annotations that need
|
|
84
|
+
# the builtin must reference `builtins.list[...]` explicitly. The
|
|
85
|
+
# import lives in a TYPE_CHECKING block because it's only needed
|
|
86
|
+
# for type annotations.
|
|
87
|
+
import builtins
|
|
88
|
+
from collections.abc import AsyncIterator, Iterable, Sequence
|
|
89
|
+
from datetime import datetime
|
|
90
|
+
|
|
91
|
+
from layerv_qurl.types import (
|
|
92
|
+
QURL,
|
|
93
|
+
AccessCode,
|
|
94
|
+
AccessCodeListOutput,
|
|
95
|
+
AccessPolicy,
|
|
96
|
+
AccessToken,
|
|
97
|
+
AgentBootstrapOutput,
|
|
98
|
+
APIKey,
|
|
99
|
+
APIKeyListOutput,
|
|
100
|
+
BatchCreateItem,
|
|
101
|
+
BatchCreateOutput,
|
|
102
|
+
CheckoutSession,
|
|
103
|
+
ConnectorInstallationListOutput,
|
|
104
|
+
CreateOutput,
|
|
105
|
+
CurrentPeriodUsage,
|
|
106
|
+
Customer,
|
|
107
|
+
DailyUsage,
|
|
108
|
+
Domain,
|
|
109
|
+
DomainListOutput,
|
|
110
|
+
DomainVerifyOutput,
|
|
111
|
+
InvoiceListOutput,
|
|
112
|
+
ListOutput,
|
|
113
|
+
MintOutput,
|
|
114
|
+
PortalSession,
|
|
115
|
+
Quota,
|
|
116
|
+
QURLStatus,
|
|
117
|
+
RedeemAccessCodeOutput,
|
|
118
|
+
ResolveOutput,
|
|
119
|
+
Resource,
|
|
120
|
+
ResourceDetail,
|
|
121
|
+
ResourceListOutput,
|
|
122
|
+
SessionListOutput,
|
|
123
|
+
SessionTerminateOutput,
|
|
124
|
+
Webhook,
|
|
125
|
+
WebhookDeliveryListOutput,
|
|
126
|
+
WebhookEventTypesOutput,
|
|
127
|
+
WebhookListOutput,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AsyncQURLClient:
|
|
132
|
+
"""Asynchronous qURL API client.
|
|
133
|
+
|
|
134
|
+
Usage::
|
|
135
|
+
|
|
136
|
+
import asyncio
|
|
137
|
+
from layerv_qurl import AsyncQURLClient
|
|
138
|
+
|
|
139
|
+
async def main():
|
|
140
|
+
async with AsyncQURLClient(api_key="lv_live_xxx") as client:
|
|
141
|
+
result = await client.create(target_url="https://example.com")
|
|
142
|
+
access = await client.resolve("at_k8xqp9h2sj9lx7r4a")
|
|
143
|
+
|
|
144
|
+
asyncio.run(main())
|
|
145
|
+
|
|
146
|
+
``api_key`` may be omitted for public endpoints such as
|
|
147
|
+
:meth:`redeem_access_code`; authenticated endpoints will return 401
|
|
148
|
+
without credentials.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
api_key: str | None = None,
|
|
154
|
+
*,
|
|
155
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
156
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
157
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
158
|
+
user_agent: str | None = None,
|
|
159
|
+
http_client: httpx.AsyncClient | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
if api_key is not None and not api_key.strip():
|
|
162
|
+
raise ValueError("api_key must not be empty")
|
|
163
|
+
|
|
164
|
+
self._base_url = base_url.rstrip("/")
|
|
165
|
+
self._api_key = api_key
|
|
166
|
+
self._max_retries = max_retries
|
|
167
|
+
self._user_agent = user_agent or default_user_agent()
|
|
168
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
169
|
+
self._owns_client = http_client is None
|
|
170
|
+
self._base_headers: dict[str, str] = {
|
|
171
|
+
"Accept": "application/json",
|
|
172
|
+
"User-Agent": self._user_agent,
|
|
173
|
+
}
|
|
174
|
+
if api_key is not None:
|
|
175
|
+
self._base_headers["Authorization"] = f"Bearer {api_key}"
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
api_key = mask_key(self._api_key) if self._api_key is not None else None
|
|
179
|
+
return f"AsyncQURLClient(api_key={api_key!r}, base_url='{self._base_url}')"
|
|
180
|
+
|
|
181
|
+
async def close(self) -> None:
|
|
182
|
+
"""Close the underlying HTTP client (only if owned by this instance)."""
|
|
183
|
+
if self._owns_client:
|
|
184
|
+
await self._client.aclose()
|
|
185
|
+
|
|
186
|
+
async def __aenter__(self) -> AsyncQURLClient:
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
async def __aexit__(self, *_: object) -> None:
|
|
190
|
+
await self.close()
|
|
191
|
+
|
|
192
|
+
# --- Public API ---
|
|
193
|
+
|
|
194
|
+
async def create(
|
|
195
|
+
self,
|
|
196
|
+
target_url: str,
|
|
197
|
+
*,
|
|
198
|
+
resource_type: str | None = None,
|
|
199
|
+
expires_in: str | None = None,
|
|
200
|
+
label: str | None = None,
|
|
201
|
+
one_time_use: bool | None = None,
|
|
202
|
+
max_sessions: int | None = None,
|
|
203
|
+
session_duration: str | None = None,
|
|
204
|
+
access_policy: AccessPolicy | None = None,
|
|
205
|
+
custom_domain: str | None = None,
|
|
206
|
+
idempotency_key: str | None = None,
|
|
207
|
+
) -> CreateOutput:
|
|
208
|
+
"""Create a new qURL.
|
|
209
|
+
|
|
210
|
+
Returns a :class:`CreateOutput` with the ``resource_id``, ``qurl_link``,
|
|
211
|
+
``qurl_site``, and ``expires_at``. Use :meth:`get` to fetch the full
|
|
212
|
+
:class:`QURL` object with status, timestamps, and policy details.
|
|
213
|
+
|
|
214
|
+
Note: ``tags`` and ``description`` are not accepted on create — they
|
|
215
|
+
live on the resource and must be set via :meth:`update` after
|
|
216
|
+
creation. The API uses different field names for the create-time
|
|
217
|
+
token label (``label``) and the resource-level description on
|
|
218
|
+
update/get responses.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
target_url: The URL to protect. Max length 2048.
|
|
222
|
+
resource_type: Resource type to create. Defaults to ``"url"``
|
|
223
|
+
server-side. Public callers usually omit this.
|
|
224
|
+
expires_in: Duration string (e.g. ``"24h"``, ``"7d"``). The API
|
|
225
|
+
uses ``expires_in`` on create; use :meth:`update` with
|
|
226
|
+
``expires_at`` if you need an absolute expiry afterwards.
|
|
227
|
+
label: Human-readable label for the qURL. Max length 500.
|
|
228
|
+
one_time_use: If True, the qURL is consumed on first access.
|
|
229
|
+
max_sessions: Maximum concurrent sessions (0 = unlimited).
|
|
230
|
+
Must be between 0 and 1000 inclusive.
|
|
231
|
+
session_duration: Duration string for sessions (e.g. ``"1h"``).
|
|
232
|
+
access_policy: IP/geo/user-agent access restrictions.
|
|
233
|
+
custom_domain: Custom domain for the qURL link. Max length 253.
|
|
234
|
+
idempotency_key: Optional idempotency key for safe retries.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
ValueError: If any field violates the documented API constraints.
|
|
238
|
+
"""
|
|
239
|
+
validate_create_input(
|
|
240
|
+
target_url=target_url,
|
|
241
|
+
label=label,
|
|
242
|
+
max_sessions=max_sessions,
|
|
243
|
+
custom_domain=custom_domain,
|
|
244
|
+
)
|
|
245
|
+
body = build_body(
|
|
246
|
+
{
|
|
247
|
+
"type": resource_type,
|
|
248
|
+
"target_url": target_url,
|
|
249
|
+
"expires_in": expires_in,
|
|
250
|
+
"label": label,
|
|
251
|
+
"one_time_use": one_time_use,
|
|
252
|
+
"max_sessions": max_sessions,
|
|
253
|
+
"session_duration": session_duration,
|
|
254
|
+
"access_policy": access_policy,
|
|
255
|
+
"custom_domain": custom_domain,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
resp = await self._request(
|
|
259
|
+
"POST",
|
|
260
|
+
"/v1/qurls",
|
|
261
|
+
body=body,
|
|
262
|
+
headers=idempotency_headers(idempotency_key),
|
|
263
|
+
)
|
|
264
|
+
return parse_create_output(resp)
|
|
265
|
+
|
|
266
|
+
async def get(self, resource_id: str) -> QURL:
|
|
267
|
+
"""Get a qURL resource and its access tokens.
|
|
268
|
+
|
|
269
|
+
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
|
|
270
|
+
(``q_`` prefix); the API resolves ``q_`` IDs to the parent resource
|
|
271
|
+
automatically.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
resource_id: The resource or qURL display ID.
|
|
275
|
+
"""
|
|
276
|
+
validate_id(resource_id)
|
|
277
|
+
resp = await self._request("GET", f"/v1/qurls/{resource_id}")
|
|
278
|
+
return parse_qurl(resp)
|
|
279
|
+
|
|
280
|
+
async def list(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
limit: int | None = None,
|
|
284
|
+
cursor: str | None = None,
|
|
285
|
+
status: QURLStatus | None = None,
|
|
286
|
+
q: str | None = None,
|
|
287
|
+
sort: str | None = None,
|
|
288
|
+
created_after: datetime | str | None = None,
|
|
289
|
+
created_before: datetime | str | None = None,
|
|
290
|
+
expires_before: datetime | str | None = None,
|
|
291
|
+
expires_after: datetime | str | None = None,
|
|
292
|
+
) -> ListOutput:
|
|
293
|
+
"""List qURLs with optional filters.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
limit: Maximum number of results per page.
|
|
297
|
+
cursor: Pagination cursor from a previous response.
|
|
298
|
+
status: Filter by qURL status (``"active"``, ``"revoked"``).
|
|
299
|
+
q: Search query string.
|
|
300
|
+
sort: Sort order (e.g. ``"created_at"``, ``"-created_at"``).
|
|
301
|
+
created_after: Filter qURLs created after this timestamp.
|
|
302
|
+
Accepts a :class:`datetime` (serialized via ``.isoformat()``)
|
|
303
|
+
or a string. String values must be ISO 8601 / RFC 3339
|
|
304
|
+
format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed
|
|
305
|
+
through to the API unvalidated — the server rejects
|
|
306
|
+
malformed timestamps with a 400.
|
|
307
|
+
created_before: Filter qURLs created before this timestamp.
|
|
308
|
+
Same format rules as ``created_after``.
|
|
309
|
+
expires_before: Filter qURLs expiring before this timestamp.
|
|
310
|
+
Same format rules as ``created_after``.
|
|
311
|
+
expires_after: Filter qURLs expiring after this timestamp.
|
|
312
|
+
Same format rules as ``created_after``.
|
|
313
|
+
"""
|
|
314
|
+
params = build_list_params(
|
|
315
|
+
limit,
|
|
316
|
+
cursor,
|
|
317
|
+
status=status,
|
|
318
|
+
q=q,
|
|
319
|
+
sort=sort,
|
|
320
|
+
created_after=created_after,
|
|
321
|
+
created_before=created_before,
|
|
322
|
+
expires_before=expires_before,
|
|
323
|
+
expires_after=expires_after,
|
|
324
|
+
)
|
|
325
|
+
data, meta = await self._raw_request("GET", "/v1/qurls", params=params)
|
|
326
|
+
return parse_list_output(data, meta)
|
|
327
|
+
|
|
328
|
+
async def list_all(
|
|
329
|
+
self,
|
|
330
|
+
*,
|
|
331
|
+
status: QURLStatus | None = None,
|
|
332
|
+
q: str | None = None,
|
|
333
|
+
sort: str | None = None,
|
|
334
|
+
page_size: int = 50,
|
|
335
|
+
created_after: datetime | str | None = None,
|
|
336
|
+
created_before: datetime | str | None = None,
|
|
337
|
+
expires_before: datetime | str | None = None,
|
|
338
|
+
expires_after: datetime | str | None = None,
|
|
339
|
+
) -> AsyncIterator[QURL]:
|
|
340
|
+
"""Iterate over all qURLs, automatically paginating.
|
|
341
|
+
|
|
342
|
+
Yields individual :class:`QURL` objects, fetching pages transparently.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
status: Filter by status (``"active"``, ``"revoked"``).
|
|
346
|
+
q: Search query string.
|
|
347
|
+
sort: Sort order.
|
|
348
|
+
page_size: Number of items per page (default 50).
|
|
349
|
+
created_after: Filter qURLs created after this timestamp.
|
|
350
|
+
Accepts a :class:`datetime` (serialized via ``.isoformat()``)
|
|
351
|
+
or a string. String values must be ISO 8601 / RFC 3339
|
|
352
|
+
format (e.g. ``"2026-04-01T00:00:00Z"``) and are passed
|
|
353
|
+
through to the API unvalidated — the server rejects
|
|
354
|
+
malformed timestamps with a 400.
|
|
355
|
+
created_before: Filter qURLs created before this timestamp.
|
|
356
|
+
Same format rules as ``created_after``.
|
|
357
|
+
expires_before: Filter qURLs expiring before this timestamp.
|
|
358
|
+
Same format rules as ``created_after``.
|
|
359
|
+
expires_after: Filter qURLs expiring after this timestamp.
|
|
360
|
+
Same format rules as ``created_after``.
|
|
361
|
+
"""
|
|
362
|
+
cursor: str | None = None
|
|
363
|
+
while True:
|
|
364
|
+
page = await self.list(
|
|
365
|
+
limit=page_size,
|
|
366
|
+
cursor=cursor,
|
|
367
|
+
status=status,
|
|
368
|
+
q=q,
|
|
369
|
+
sort=sort,
|
|
370
|
+
created_after=created_after,
|
|
371
|
+
created_before=created_before,
|
|
372
|
+
expires_before=expires_before,
|
|
373
|
+
expires_after=expires_after,
|
|
374
|
+
)
|
|
375
|
+
for qurl in page.qurls:
|
|
376
|
+
yield qurl
|
|
377
|
+
if not page.has_more or not page.next_cursor:
|
|
378
|
+
break
|
|
379
|
+
cursor = page.next_cursor
|
|
380
|
+
|
|
381
|
+
async def delete(self, resource_id: str) -> None:
|
|
382
|
+
"""Delete (revoke) a qURL resource and all its access tokens.
|
|
383
|
+
|
|
384
|
+
Only accepts a resource ID (``r_`` prefix), not a qURL display ID
|
|
385
|
+
(``q_`` prefix). Per the OpenAPI spec:
|
|
386
|
+
*"Requires a resource ID (r_ prefix). To revoke a single token,
|
|
387
|
+
use DELETE /v1/resources/:id/qurls/:qurl_id"*.
|
|
388
|
+
|
|
389
|
+
A client-side prefix check catches the mistake before the API
|
|
390
|
+
round-trip.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
resource_id: The resource ID (must start with ``r_``).
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
ValueError: If ``resource_id`` is malformed or does not start
|
|
397
|
+
with ``r_``.
|
|
398
|
+
"""
|
|
399
|
+
validate_id(resource_id)
|
|
400
|
+
require_resource_id_prefix(resource_id, "delete")
|
|
401
|
+
await self._request("DELETE", f"/v1/qurls/{resource_id}")
|
|
402
|
+
|
|
403
|
+
async def extend(self, resource_id: str, duration: str) -> QURL:
|
|
404
|
+
"""Extend a qURL's expiration.
|
|
405
|
+
|
|
406
|
+
Convenience method — equivalent to ``await update(resource_id, extend_by=duration)``.
|
|
407
|
+
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
|
|
408
|
+
(``q_`` prefix); the API resolves ``q_`` IDs to the parent resource
|
|
409
|
+
automatically.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
resource_id: Resource or qURL display ID.
|
|
413
|
+
duration: Duration to add (e.g. ``"7d"``, ``"24h"``).
|
|
414
|
+
"""
|
|
415
|
+
return await self.update(resource_id, extend_by=duration)
|
|
416
|
+
|
|
417
|
+
async def update(
|
|
418
|
+
self,
|
|
419
|
+
resource_id: str,
|
|
420
|
+
*,
|
|
421
|
+
extend_by: str | None = None,
|
|
422
|
+
expires_at: datetime | str | None = None,
|
|
423
|
+
description: str | None = None,
|
|
424
|
+
tags: builtins.list[str] | None = None,
|
|
425
|
+
idempotency_key: str | None = None,
|
|
426
|
+
) -> QURL:
|
|
427
|
+
"""Update a qURL — extend expiration, change description, set tags.
|
|
428
|
+
|
|
429
|
+
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
|
|
430
|
+
(``q_`` prefix). All fields are optional, but at least one must be
|
|
431
|
+
provided. ``extend_by`` and ``expires_at`` are mutually exclusive.
|
|
432
|
+
|
|
433
|
+
**Cannot change `access_policy`** — the policy is immutable after
|
|
434
|
+
create, per the OpenAPI spec's ``UpdateQurlRequest``. To change
|
|
435
|
+
access policy, either create a new resource via :meth:`create`
|
|
436
|
+
with the new policy, or mint a new access token on the existing
|
|
437
|
+
resource via :meth:`mint_link` with a policy override (per-token
|
|
438
|
+
scope, base resource policy unchanged).
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
resource_id: Resource or qURL display ID.
|
|
442
|
+
extend_by: Duration to add (e.g. ``"7d"``). Mutually exclusive
|
|
443
|
+
with ``expires_at``.
|
|
444
|
+
expires_at: New absolute expiry. Mutually exclusive with
|
|
445
|
+
``extend_by``.
|
|
446
|
+
description: New resource description. Pass an empty string to
|
|
447
|
+
clear. Max length 500.
|
|
448
|
+
tags: Replacement tag list — this is always a REPLACE, never
|
|
449
|
+
a merge. Pass ``tags=[]`` to clear all tags (always a
|
|
450
|
+
real clear operation, even if no tags were set); pass
|
|
451
|
+
``None`` (the default) to leave the existing tags
|
|
452
|
+
unchanged. Max 10 items, each 1-50 chars matching
|
|
453
|
+
``^[a-zA-Z0-9][a-zA-Z0-9 _-]*$``.
|
|
454
|
+
idempotency_key: Optional idempotency key for safe retries.
|
|
455
|
+
|
|
456
|
+
Raises:
|
|
457
|
+
ValueError: If ``extend_by`` and ``expires_at`` are both set, if
|
|
458
|
+
no update fields are provided, or if any field violates the
|
|
459
|
+
documented API constraints.
|
|
460
|
+
"""
|
|
461
|
+
validate_id(resource_id)
|
|
462
|
+
if extend_by is not None and expires_at is not None:
|
|
463
|
+
raise ValueError(
|
|
464
|
+
"update: `extend_by` and `expires_at` are mutually exclusive "
|
|
465
|
+
"— provide at most one"
|
|
466
|
+
)
|
|
467
|
+
validate_update_input(description=description, tags=tags)
|
|
468
|
+
# `build_body` strips top-level ``None`` only — falsy values like
|
|
469
|
+
# ``tags=[]`` and ``description=""`` are preserved. This is
|
|
470
|
+
# load-bearing: ``tags=[]`` is an intentional "clear all tags"
|
|
471
|
+
# API operation and ``description=""`` clears the description.
|
|
472
|
+
# A future refactor that adds a truthiness check here would
|
|
473
|
+
# silently drop both.
|
|
474
|
+
body = build_body(
|
|
475
|
+
{
|
|
476
|
+
"extend_by": extend_by,
|
|
477
|
+
"expires_at": expires_at,
|
|
478
|
+
"description": description,
|
|
479
|
+
"tags": tags,
|
|
480
|
+
}
|
|
481
|
+
)
|
|
482
|
+
require_nonempty_update(
|
|
483
|
+
body, "update", "extend_by, expires_at, description, tags"
|
|
484
|
+
)
|
|
485
|
+
resp = await self._request(
|
|
486
|
+
"PATCH",
|
|
487
|
+
f"/v1/qurls/{resource_id}",
|
|
488
|
+
body=body,
|
|
489
|
+
headers=idempotency_headers(idempotency_key),
|
|
490
|
+
)
|
|
491
|
+
return parse_qurl(resp)
|
|
492
|
+
|
|
493
|
+
async def mint_link(
|
|
494
|
+
self,
|
|
495
|
+
resource_id: str,
|
|
496
|
+
*,
|
|
497
|
+
expires_at: datetime | str | None = None,
|
|
498
|
+
expires_in: str | None = None,
|
|
499
|
+
label: str | None = None,
|
|
500
|
+
one_time_use: bool | None = None,
|
|
501
|
+
max_sessions: int | None = None,
|
|
502
|
+
session_duration: str | None = None,
|
|
503
|
+
access_policy: AccessPolicy | None = None,
|
|
504
|
+
idempotency_key: str | None = None,
|
|
505
|
+
) -> MintOutput:
|
|
506
|
+
"""Mint a new access link for a qURL.
|
|
507
|
+
|
|
508
|
+
Accepts either a resource ID (``r_`` prefix) or a qURL display ID
|
|
509
|
+
(``q_`` prefix). ``expires_in`` and ``expires_at`` are mutually
|
|
510
|
+
exclusive — if neither is set, the link defaults to 24 hours.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
resource_id: Resource or qURL display ID.
|
|
514
|
+
expires_at: Absolute expiry for the minted link. Mutually
|
|
515
|
+
exclusive with ``expires_in``.
|
|
516
|
+
expires_in: Duration string for the link (e.g. ``"24h"``).
|
|
517
|
+
Mutually exclusive with ``expires_at``.
|
|
518
|
+
label: Human-readable label for the link. Max length 500.
|
|
519
|
+
one_time_use: If True, the link can only be used once.
|
|
520
|
+
max_sessions: Maximum concurrent sessions allowed.
|
|
521
|
+
Must be between 0 and 1000 inclusive.
|
|
522
|
+
session_duration: Duration string for sessions (e.g. ``"1h"``).
|
|
523
|
+
access_policy: IP/geo/user-agent access restrictions.
|
|
524
|
+
idempotency_key: Optional idempotency key for safe retries.
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
ValueError: If ``expires_in`` and ``expires_at`` are both set
|
|
528
|
+
or if any field violates the documented API constraints.
|
|
529
|
+
"""
|
|
530
|
+
validate_id(resource_id)
|
|
531
|
+
if expires_in is not None and expires_at is not None:
|
|
532
|
+
raise ValueError(
|
|
533
|
+
"mint_link: `expires_in` and `expires_at` are mutually exclusive "
|
|
534
|
+
"— provide at most one"
|
|
535
|
+
)
|
|
536
|
+
validate_mint_input(label=label, max_sessions=max_sessions)
|
|
537
|
+
body = build_body(
|
|
538
|
+
{
|
|
539
|
+
"expires_at": expires_at,
|
|
540
|
+
"expires_in": expires_in,
|
|
541
|
+
"label": label,
|
|
542
|
+
"one_time_use": one_time_use,
|
|
543
|
+
"max_sessions": max_sessions,
|
|
544
|
+
"session_duration": session_duration,
|
|
545
|
+
"access_policy": access_policy,
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
resp = await self._request(
|
|
549
|
+
"POST",
|
|
550
|
+
f"/v1/qurls/{resource_id}/mint_link",
|
|
551
|
+
body=body,
|
|
552
|
+
headers=idempotency_headers(idempotency_key),
|
|
553
|
+
)
|
|
554
|
+
return parse_mint_output(resp)
|
|
555
|
+
|
|
556
|
+
async def batch_create(
|
|
557
|
+
self,
|
|
558
|
+
items: Sequence[BatchCreateItem],
|
|
559
|
+
*,
|
|
560
|
+
idempotency_key: str | None = None,
|
|
561
|
+
) -> BatchCreateOutput:
|
|
562
|
+
"""Create multiple qURLs at once (1-100 items).
|
|
563
|
+
|
|
564
|
+
Each item is validated against the same spec constraints as
|
|
565
|
+
:meth:`create` before the request is sent, with per-item errors
|
|
566
|
+
attributed by index (``items[N]: ...``).
|
|
567
|
+
|
|
568
|
+
**Partial failures do not raise.** Two API status codes resolve
|
|
569
|
+
normally with structured per-item results:
|
|
570
|
+
|
|
571
|
+
- **HTTP 207 Multi-Status** (some succeeded, some failed).
|
|
572
|
+
- **HTTP 400** (every item failed validation) — the API returns a
|
|
573
|
+
populated ``BatchCreateOutput`` body on this path, so the SDK
|
|
574
|
+
whitelists 400 and surfaces the per-item errors instead of
|
|
575
|
+
raising a generic :class:`ValidationError`.
|
|
576
|
+
|
|
577
|
+
Other error statuses (401, 403, 429, 5xx) still raise the
|
|
578
|
+
appropriate :class:`QURLError` subclass. Inspect
|
|
579
|
+
``result.failed > 0`` and iterate ``result.results`` to see
|
|
580
|
+
which items succeeded and which errored.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
items: List of dicts, each with at least ``target_url``.
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
ValueError: If ``items`` is empty, exceeds 100 items, or any
|
|
587
|
+
item violates the documented API constraints.
|
|
588
|
+
"""
|
|
589
|
+
if not items:
|
|
590
|
+
raise ValueError("batch_create requires at least 1 item")
|
|
591
|
+
if len(items) > 100:
|
|
592
|
+
raise ValueError(
|
|
593
|
+
f"batch_create accepts at most 100 items (got {len(items)})"
|
|
594
|
+
)
|
|
595
|
+
# Validate each item against the same spec constraints as single
|
|
596
|
+
# create() so obvious mistakes fail fast with the offending index.
|
|
597
|
+
for i, item in enumerate(items):
|
|
598
|
+
try:
|
|
599
|
+
validate_create_input(
|
|
600
|
+
target_url=item["target_url"],
|
|
601
|
+
label=item.get("label"),
|
|
602
|
+
max_sessions=item.get("max_sessions"),
|
|
603
|
+
custom_domain=item.get("custom_domain"),
|
|
604
|
+
)
|
|
605
|
+
except KeyError as exc:
|
|
606
|
+
raise ValueError(
|
|
607
|
+
f"batch_create items[{i}]: missing required field 'target_url'"
|
|
608
|
+
) from exc
|
|
609
|
+
except ValueError as exc:
|
|
610
|
+
raise ValueError(f"batch_create items[{i}]: {exc}") from exc
|
|
611
|
+
# `BatchCreateItem` is structurally a `dict[str, Any]` at runtime —
|
|
612
|
+
# TypedDicts compile to plain dicts and carry no runtime overhead.
|
|
613
|
+
# The `cast` narrows the type for `build_body` without any runtime
|
|
614
|
+
# conversion.
|
|
615
|
+
serialized = [build_body(cast("dict[str, Any]", item)) for item in items]
|
|
616
|
+
# HTTP 400 carries structured per-item errors on this endpoint —
|
|
617
|
+
# whitelist it so the generic error path doesn't swallow the body.
|
|
618
|
+
# `allow_statuses=(400,)` only — HTTP 207 Multi-Status (partial
|
|
619
|
+
# success) flows through the normal `status_code < 400` success
|
|
620
|
+
# path automatically, so it doesn't need to be whitelisted. Only
|
|
621
|
+
# the total-failure 400 needs the opt-in, because the API
|
|
622
|
+
# populates a `BatchCreateOutput` body there that the generic
|
|
623
|
+
# error path would otherwise swallow.
|
|
624
|
+
resp = await self._request(
|
|
625
|
+
"POST",
|
|
626
|
+
"/v1/qurls/batch",
|
|
627
|
+
body={"items": serialized},
|
|
628
|
+
allow_statuses=(400,),
|
|
629
|
+
headers=idempotency_headers(idempotency_key),
|
|
630
|
+
)
|
|
631
|
+
# `parse_batch_create_output` runs the shape guard internally
|
|
632
|
+
# (see its docstring) — so if the API returns 400 with an
|
|
633
|
+
# unexpected body shape, the parser raises ValidationError
|
|
634
|
+
# rather than silently producing `(succeeded=0, failed=0,
|
|
635
|
+
# results=[])`. The guard is enforced at the parser boundary,
|
|
636
|
+
# not documented by convention at every call site.
|
|
637
|
+
return parse_batch_create_output(resp)
|
|
638
|
+
|
|
639
|
+
async def resolve(
|
|
640
|
+
self, access_token: str, *, idempotency_key: str | None = None
|
|
641
|
+
) -> ResolveOutput:
|
|
642
|
+
"""Resolve a qURL access token (headless).
|
|
643
|
+
|
|
644
|
+
Triggers an NHP knock to grant network access for the caller's IP.
|
|
645
|
+
Requires ``qurl:resolve`` scope on the API key.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
access_token: The access token string (e.g. ``"at_k8xqp9h2sj9lx7r4a"``).
|
|
649
|
+
"""
|
|
650
|
+
validate_id(access_token, "access_token")
|
|
651
|
+
resp = await self._request(
|
|
652
|
+
"POST",
|
|
653
|
+
"/v1/resolve",
|
|
654
|
+
body={"access_token": access_token},
|
|
655
|
+
headers=idempotency_headers(idempotency_key),
|
|
656
|
+
)
|
|
657
|
+
return parse_resolve_output(resp)
|
|
658
|
+
|
|
659
|
+
async def get_quota(self) -> Quota:
|
|
660
|
+
"""Get quota and usage information."""
|
|
661
|
+
resp = await self._request("GET", "/v1/quota")
|
|
662
|
+
return parse_quota(resp)
|
|
663
|
+
|
|
664
|
+
# --- Resources ---
|
|
665
|
+
|
|
666
|
+
async def list_resources(
|
|
667
|
+
self,
|
|
668
|
+
*,
|
|
669
|
+
cursor: str | None = None,
|
|
670
|
+
limit: int | None = None,
|
|
671
|
+
alias: str | None = None,
|
|
672
|
+
slug: str | None = None,
|
|
673
|
+
status: str | None = None,
|
|
674
|
+
resource_type: str | None = None,
|
|
675
|
+
) -> ResourceListOutput:
|
|
676
|
+
"""List resources.
|
|
677
|
+
|
|
678
|
+
``resource_type`` serializes to the API's ``type`` query parameter.
|
|
679
|
+
Supported public filter values are ``"url"`` and ``"tunnel"``.
|
|
680
|
+
|
|
681
|
+
``alias`` filters use the same format and reserved-word validation
|
|
682
|
+
as resource alias writes.
|
|
683
|
+
"""
|
|
684
|
+
validate_alias(alias)
|
|
685
|
+
params = build_list_params(limit, cursor, status=status)
|
|
686
|
+
params.update(
|
|
687
|
+
build_query_params({"alias": alias, "slug": slug, "type": resource_type})
|
|
688
|
+
)
|
|
689
|
+
data, meta = await self._raw_request("GET", "/v1/resources", params=params)
|
|
690
|
+
return parse_resource_list_output(data, meta)
|
|
691
|
+
|
|
692
|
+
async def create_resource(
|
|
693
|
+
self,
|
|
694
|
+
*,
|
|
695
|
+
resource_type: str | None = None,
|
|
696
|
+
target_url: str | None = None,
|
|
697
|
+
description: str | None = None,
|
|
698
|
+
tags: builtins.list[str] | None = None,
|
|
699
|
+
custom_domain: str | None = None,
|
|
700
|
+
alias: str | None = None,
|
|
701
|
+
slug: str | None = None,
|
|
702
|
+
find_or_create: bool | None = None,
|
|
703
|
+
idempotency_key: str | None = None,
|
|
704
|
+
) -> Resource:
|
|
705
|
+
"""Create or find a resource.
|
|
706
|
+
|
|
707
|
+
``resource_type`` serializes to the API's ``type`` request field.
|
|
708
|
+
Tunnel resources use ``slug`` and may set ``find_or_create=True``.
|
|
709
|
+
|
|
710
|
+
``find_or_create=False`` is treated the same as omitting it because
|
|
711
|
+
the API default is false.
|
|
712
|
+
"""
|
|
713
|
+
validate_alias(alias)
|
|
714
|
+
if target_url is not None:
|
|
715
|
+
validate_create_input(target_url=target_url, custom_domain=custom_domain)
|
|
716
|
+
# `validate_create_input` already checks custom_domain with target_url.
|
|
717
|
+
validate_update_input(
|
|
718
|
+
description=description,
|
|
719
|
+
tags=tags,
|
|
720
|
+
custom_domain=None if target_url is not None else custom_domain,
|
|
721
|
+
)
|
|
722
|
+
if find_or_create is False and not any(
|
|
723
|
+
value is not None
|
|
724
|
+
for value in (
|
|
725
|
+
resource_type,
|
|
726
|
+
target_url,
|
|
727
|
+
description,
|
|
728
|
+
tags,
|
|
729
|
+
custom_domain,
|
|
730
|
+
alias,
|
|
731
|
+
slug,
|
|
732
|
+
)
|
|
733
|
+
):
|
|
734
|
+
raise ValueError(
|
|
735
|
+
"create_resource: find_or_create=False is the API default; "
|
|
736
|
+
"provide at least one resource field"
|
|
737
|
+
)
|
|
738
|
+
body = build_body(
|
|
739
|
+
{
|
|
740
|
+
"type": resource_type,
|
|
741
|
+
"target_url": target_url,
|
|
742
|
+
"description": description,
|
|
743
|
+
"tags": tags,
|
|
744
|
+
"custom_domain": custom_domain,
|
|
745
|
+
"alias": alias,
|
|
746
|
+
"slug": slug,
|
|
747
|
+
"find_or_create": True if find_or_create is True else None,
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
require_nonempty_update(
|
|
751
|
+
body,
|
|
752
|
+
"create_resource",
|
|
753
|
+
"type, target_url, description, tags, custom_domain, alias, slug, find_or_create",
|
|
754
|
+
)
|
|
755
|
+
resp = await self._request(
|
|
756
|
+
"POST",
|
|
757
|
+
"/v1/resources",
|
|
758
|
+
body=body,
|
|
759
|
+
headers=idempotency_headers(idempotency_key),
|
|
760
|
+
)
|
|
761
|
+
return parse_resource(resp)
|
|
762
|
+
|
|
763
|
+
async def get_resource(self, resource_id: str) -> ResourceDetail:
|
|
764
|
+
"""Get resource details and a bounded qURL token preview."""
|
|
765
|
+
validate_id(resource_id)
|
|
766
|
+
resp = await self._request("GET", f"/v1/resources/{resource_id}")
|
|
767
|
+
return parse_resource_detail(resp)
|
|
768
|
+
|
|
769
|
+
async def update_resource(
|
|
770
|
+
self,
|
|
771
|
+
resource_id: str,
|
|
772
|
+
*,
|
|
773
|
+
description: str | None = None,
|
|
774
|
+
tags: builtins.list[str] | None = None,
|
|
775
|
+
custom_domain: str | None = None,
|
|
776
|
+
preserve_host: bool | None = None,
|
|
777
|
+
alias: str | None | UnsetType = UNSET,
|
|
778
|
+
idempotency_key: str | None = None,
|
|
779
|
+
) -> Resource:
|
|
780
|
+
"""Update resource metadata."""
|
|
781
|
+
validate_id(resource_id)
|
|
782
|
+
validate_update_input(
|
|
783
|
+
description=description, tags=tags, custom_domain=custom_domain
|
|
784
|
+
)
|
|
785
|
+
if alias is not UNSET:
|
|
786
|
+
validate_alias(cast("str | None", alias))
|
|
787
|
+
body = build_body(
|
|
788
|
+
{
|
|
789
|
+
"description": description,
|
|
790
|
+
"tags": tags,
|
|
791
|
+
"custom_domain": custom_domain,
|
|
792
|
+
"preserve_host": preserve_host,
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
if alias is not UNSET:
|
|
796
|
+
body["alias"] = alias
|
|
797
|
+
require_nonempty_update(
|
|
798
|
+
body,
|
|
799
|
+
"update_resource",
|
|
800
|
+
"description, tags, custom_domain, preserve_host, alias",
|
|
801
|
+
)
|
|
802
|
+
resp = await self._request(
|
|
803
|
+
"PATCH",
|
|
804
|
+
f"/v1/resources/{resource_id}",
|
|
805
|
+
body=body,
|
|
806
|
+
headers=idempotency_headers(idempotency_key),
|
|
807
|
+
)
|
|
808
|
+
return parse_resource(resp)
|
|
809
|
+
|
|
810
|
+
async def delete_resource(self, resource_id: str) -> None:
|
|
811
|
+
"""Revoke a resource and all qURLs associated with it."""
|
|
812
|
+
validate_id(resource_id)
|
|
813
|
+
await self._request("DELETE", f"/v1/resources/{resource_id}")
|
|
814
|
+
|
|
815
|
+
async def create_qurl_for_resource(
|
|
816
|
+
self,
|
|
817
|
+
resource_id: str,
|
|
818
|
+
*,
|
|
819
|
+
expires_in: str | None = None,
|
|
820
|
+
label: str | None = None,
|
|
821
|
+
one_time_use: bool | None = None,
|
|
822
|
+
max_sessions: int | None = None,
|
|
823
|
+
session_duration: str | None = None,
|
|
824
|
+
access_policy: AccessPolicy | None = None,
|
|
825
|
+
idempotency_key: str | None = None,
|
|
826
|
+
) -> CreateOutput:
|
|
827
|
+
"""Mint a qURL against an existing resource."""
|
|
828
|
+
validate_id(resource_id)
|
|
829
|
+
validate_mint_input(label=label, max_sessions=max_sessions)
|
|
830
|
+
body = build_body(
|
|
831
|
+
{
|
|
832
|
+
"expires_in": expires_in,
|
|
833
|
+
"label": label,
|
|
834
|
+
"one_time_use": one_time_use,
|
|
835
|
+
"max_sessions": max_sessions,
|
|
836
|
+
"session_duration": session_duration,
|
|
837
|
+
"access_policy": access_policy,
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
resp = await self._request(
|
|
841
|
+
"POST",
|
|
842
|
+
f"/v1/resources/{resource_id}/qurls",
|
|
843
|
+
body=body,
|
|
844
|
+
headers=idempotency_headers(idempotency_key),
|
|
845
|
+
)
|
|
846
|
+
return parse_create_output(resp)
|
|
847
|
+
|
|
848
|
+
async def update_resource_qurl(
|
|
849
|
+
self,
|
|
850
|
+
resource_id: str,
|
|
851
|
+
qurl_id: str,
|
|
852
|
+
*,
|
|
853
|
+
extend_by: str | None = None,
|
|
854
|
+
expires_at: datetime | str | None = None,
|
|
855
|
+
label: str | None = None,
|
|
856
|
+
access_policy: AccessPolicy | None = None,
|
|
857
|
+
max_sessions: int | None = None,
|
|
858
|
+
session_duration: str | None = None,
|
|
859
|
+
idempotency_key: str | None = None,
|
|
860
|
+
) -> AccessToken:
|
|
861
|
+
"""Update a specific qURL token on a resource."""
|
|
862
|
+
validate_id(resource_id)
|
|
863
|
+
validate_id(qurl_id, "qurl_id")
|
|
864
|
+
if extend_by is not None and expires_at is not None:
|
|
865
|
+
raise ValueError(
|
|
866
|
+
"update_resource_qurl: `extend_by` and `expires_at` are mutually "
|
|
867
|
+
"exclusive — provide at most one"
|
|
868
|
+
)
|
|
869
|
+
validate_mint_input(label=label, max_sessions=max_sessions)
|
|
870
|
+
body = build_body(
|
|
871
|
+
{
|
|
872
|
+
"extend_by": extend_by,
|
|
873
|
+
"expires_at": expires_at,
|
|
874
|
+
"label": label,
|
|
875
|
+
"access_policy": access_policy,
|
|
876
|
+
"max_sessions": max_sessions,
|
|
877
|
+
"session_duration": session_duration,
|
|
878
|
+
}
|
|
879
|
+
)
|
|
880
|
+
require_nonempty_update(
|
|
881
|
+
body,
|
|
882
|
+
"update_resource_qurl",
|
|
883
|
+
"extend_by, expires_at, label, access_policy, max_sessions, session_duration",
|
|
884
|
+
)
|
|
885
|
+
resp = await self._request(
|
|
886
|
+
"PATCH",
|
|
887
|
+
f"/v1/resources/{resource_id}/qurls/{qurl_id}",
|
|
888
|
+
body=body,
|
|
889
|
+
headers=idempotency_headers(idempotency_key),
|
|
890
|
+
)
|
|
891
|
+
return parse_access_token(resp)
|
|
892
|
+
|
|
893
|
+
async def revoke_resource_qurl(self, resource_id: str, qurl_id: str) -> None:
|
|
894
|
+
"""Revoke one qURL token without revoking the parent resource."""
|
|
895
|
+
validate_id(resource_id)
|
|
896
|
+
validate_id(qurl_id, "qurl_id")
|
|
897
|
+
await self._request("DELETE", f"/v1/resources/{resource_id}/qurls/{qurl_id}")
|
|
898
|
+
|
|
899
|
+
async def list_resource_sessions(self, resource_id: str) -> SessionListOutput:
|
|
900
|
+
"""List all active sessions for a resource."""
|
|
901
|
+
validate_id(resource_id)
|
|
902
|
+
resp = await self._request("GET", f"/v1/resources/{resource_id}/sessions")
|
|
903
|
+
return parse_session_list_output(resp)
|
|
904
|
+
|
|
905
|
+
async def terminate_all_resource_sessions(
|
|
906
|
+
self, resource_id: str
|
|
907
|
+
) -> SessionTerminateOutput:
|
|
908
|
+
"""Terminate all active sessions for a resource."""
|
|
909
|
+
validate_id(resource_id)
|
|
910
|
+
resp = await self._request("DELETE", f"/v1/resources/{resource_id}/sessions")
|
|
911
|
+
return parse_session_terminate_output(resp)
|
|
912
|
+
|
|
913
|
+
async def terminate_resource_session(self, resource_id: str, session_id: str) -> None:
|
|
914
|
+
"""Terminate a specific active session."""
|
|
915
|
+
validate_id(resource_id)
|
|
916
|
+
validate_id(session_id, "session_id")
|
|
917
|
+
await self._request(
|
|
918
|
+
"DELETE",
|
|
919
|
+
f"/v1/resources/{resource_id}/sessions/{session_id}",
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
# --- Custom Domains ---
|
|
923
|
+
|
|
924
|
+
async def register_domain(
|
|
925
|
+
self, domain: str, *, idempotency_key: str | None = None
|
|
926
|
+
) -> Domain:
|
|
927
|
+
"""Register a custom domain and return DNS setup records."""
|
|
928
|
+
validate_domain_input(domain)
|
|
929
|
+
resp = await self._request(
|
|
930
|
+
"POST",
|
|
931
|
+
"/v1/domains",
|
|
932
|
+
body={"domain": domain},
|
|
933
|
+
headers=idempotency_headers(idempotency_key),
|
|
934
|
+
)
|
|
935
|
+
return parse_domain(resp)
|
|
936
|
+
|
|
937
|
+
async def list_domains(
|
|
938
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
939
|
+
) -> DomainListOutput:
|
|
940
|
+
"""List custom domains."""
|
|
941
|
+
data, meta = await self._raw_request(
|
|
942
|
+
"GET",
|
|
943
|
+
"/v1/domains",
|
|
944
|
+
params=build_list_params(limit, cursor),
|
|
945
|
+
)
|
|
946
|
+
return parse_domain_list_output(data, meta)
|
|
947
|
+
|
|
948
|
+
async def get_domain(self, domain: str) -> Domain:
|
|
949
|
+
"""Get custom-domain status and DNS configuration."""
|
|
950
|
+
validate_domain_input(domain)
|
|
951
|
+
resp = await self._request("GET", f"/v1/domains/{domain_path_segment(domain)}")
|
|
952
|
+
return parse_domain(resp)
|
|
953
|
+
|
|
954
|
+
async def delete_domain(self, domain: str) -> None:
|
|
955
|
+
"""Remove a custom-domain registration."""
|
|
956
|
+
validate_domain_input(domain)
|
|
957
|
+
await self._request("DELETE", f"/v1/domains/{domain_path_segment(domain)}")
|
|
958
|
+
|
|
959
|
+
async def verify_domain(
|
|
960
|
+
self, domain: str, *, idempotency_key: str | None = None
|
|
961
|
+
) -> DomainVerifyOutput:
|
|
962
|
+
"""Trigger DNS verification for a custom domain."""
|
|
963
|
+
validate_domain_input(domain)
|
|
964
|
+
resp = await self._request(
|
|
965
|
+
"POST",
|
|
966
|
+
f"/v1/domains/{domain_path_segment(domain)}/verify",
|
|
967
|
+
headers=idempotency_headers(idempotency_key),
|
|
968
|
+
)
|
|
969
|
+
return parse_domain_verify_output(resp)
|
|
970
|
+
|
|
971
|
+
async def regenerate_domain_token(
|
|
972
|
+
self, domain: str, *, idempotency_key: str | None = None
|
|
973
|
+
) -> Domain:
|
|
974
|
+
"""Regenerate a custom-domain verification token."""
|
|
975
|
+
validate_domain_input(domain)
|
|
976
|
+
resp = await self._request(
|
|
977
|
+
"POST",
|
|
978
|
+
f"/v1/domains/{domain_path_segment(domain)}/regenerate-token",
|
|
979
|
+
headers=idempotency_headers(idempotency_key),
|
|
980
|
+
)
|
|
981
|
+
return parse_domain(resp)
|
|
982
|
+
|
|
983
|
+
# --- Webhooks ---
|
|
984
|
+
|
|
985
|
+
async def list_webhooks(
|
|
986
|
+
self,
|
|
987
|
+
*,
|
|
988
|
+
limit: int | None = None,
|
|
989
|
+
cursor: str | None = None,
|
|
990
|
+
event: str | None = None,
|
|
991
|
+
) -> WebhookListOutput:
|
|
992
|
+
"""List webhook subscriptions."""
|
|
993
|
+
data, meta = await self._raw_request(
|
|
994
|
+
"GET",
|
|
995
|
+
"/v1/webhooks",
|
|
996
|
+
params={
|
|
997
|
+
**build_list_params(limit, cursor),
|
|
998
|
+
**build_query_params({"event": event}),
|
|
999
|
+
},
|
|
1000
|
+
)
|
|
1001
|
+
return parse_webhook_list_output(data, meta)
|
|
1002
|
+
|
|
1003
|
+
async def create_webhook(
|
|
1004
|
+
self,
|
|
1005
|
+
*,
|
|
1006
|
+
url: str,
|
|
1007
|
+
events: Iterable[str],
|
|
1008
|
+
description: str | None = None,
|
|
1009
|
+
idempotency_key: str | None = None,
|
|
1010
|
+
) -> Webhook:
|
|
1011
|
+
"""Create a webhook subscription."""
|
|
1012
|
+
validate_webhook_url(url)
|
|
1013
|
+
body = build_body(
|
|
1014
|
+
{
|
|
1015
|
+
"url": url,
|
|
1016
|
+
"events": build_string_list(events, "events"),
|
|
1017
|
+
"description": description,
|
|
1018
|
+
}
|
|
1019
|
+
)
|
|
1020
|
+
resp = await self._request(
|
|
1021
|
+
"POST",
|
|
1022
|
+
"/v1/webhooks",
|
|
1023
|
+
body=body,
|
|
1024
|
+
headers=idempotency_headers(idempotency_key),
|
|
1025
|
+
)
|
|
1026
|
+
return parse_webhook(resp)
|
|
1027
|
+
|
|
1028
|
+
async def get_webhook(self, webhook_id: str) -> Webhook:
|
|
1029
|
+
"""Get webhook subscription details."""
|
|
1030
|
+
validate_id(webhook_id, "webhook_id")
|
|
1031
|
+
resp = await self._request("GET", f"/v1/webhooks/{webhook_id}")
|
|
1032
|
+
return parse_webhook(resp)
|
|
1033
|
+
|
|
1034
|
+
async def update_webhook(
|
|
1035
|
+
self,
|
|
1036
|
+
webhook_id: str,
|
|
1037
|
+
*,
|
|
1038
|
+
url: str | None = None,
|
|
1039
|
+
events: Iterable[str] | None = None,
|
|
1040
|
+
description: str | None = None,
|
|
1041
|
+
status: str | None = None,
|
|
1042
|
+
idempotency_key: str | None = None,
|
|
1043
|
+
) -> Webhook:
|
|
1044
|
+
"""Update a webhook subscription."""
|
|
1045
|
+
validate_id(webhook_id, "webhook_id")
|
|
1046
|
+
if url is not None:
|
|
1047
|
+
validate_webhook_url(url)
|
|
1048
|
+
body = build_body(
|
|
1049
|
+
{
|
|
1050
|
+
"url": url,
|
|
1051
|
+
"events": build_string_list(events, "events") if events is not None else None,
|
|
1052
|
+
"description": description,
|
|
1053
|
+
"status": status,
|
|
1054
|
+
}
|
|
1055
|
+
)
|
|
1056
|
+
require_nonempty_update(
|
|
1057
|
+
body,
|
|
1058
|
+
"update_webhook",
|
|
1059
|
+
"url, events, description, status",
|
|
1060
|
+
)
|
|
1061
|
+
resp = await self._request(
|
|
1062
|
+
"PATCH",
|
|
1063
|
+
f"/v1/webhooks/{webhook_id}",
|
|
1064
|
+
body=body,
|
|
1065
|
+
headers=idempotency_headers(idempotency_key),
|
|
1066
|
+
)
|
|
1067
|
+
return parse_webhook(resp)
|
|
1068
|
+
|
|
1069
|
+
async def delete_webhook(self, webhook_id: str) -> None:
|
|
1070
|
+
"""Delete a webhook subscription."""
|
|
1071
|
+
validate_id(webhook_id, "webhook_id")
|
|
1072
|
+
await self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
|
1073
|
+
|
|
1074
|
+
async def regenerate_webhook_secret(
|
|
1075
|
+
self, webhook_id: str, *, idempotency_key: str | None = None
|
|
1076
|
+
) -> Webhook:
|
|
1077
|
+
"""Regenerate a webhook signing secret."""
|
|
1078
|
+
validate_id(webhook_id, "webhook_id")
|
|
1079
|
+
resp = await self._request(
|
|
1080
|
+
"POST",
|
|
1081
|
+
f"/v1/webhooks/{webhook_id}/secret",
|
|
1082
|
+
headers=idempotency_headers(idempotency_key),
|
|
1083
|
+
)
|
|
1084
|
+
return parse_webhook(resp)
|
|
1085
|
+
|
|
1086
|
+
async def list_webhook_deliveries(
|
|
1087
|
+
self,
|
|
1088
|
+
webhook_id: str,
|
|
1089
|
+
*,
|
|
1090
|
+
limit: int | None = None,
|
|
1091
|
+
cursor: str | None = None,
|
|
1092
|
+
) -> WebhookDeliveryListOutput:
|
|
1093
|
+
"""List delivery attempts for a webhook."""
|
|
1094
|
+
validate_id(webhook_id, "webhook_id")
|
|
1095
|
+
data, meta = await self._raw_request(
|
|
1096
|
+
"GET",
|
|
1097
|
+
f"/v1/webhooks/{webhook_id}/deliveries",
|
|
1098
|
+
params=build_list_params(limit, cursor),
|
|
1099
|
+
)
|
|
1100
|
+
return parse_webhook_delivery_list_output(data, meta)
|
|
1101
|
+
|
|
1102
|
+
async def list_webhook_event_types(self) -> WebhookEventTypesOutput:
|
|
1103
|
+
"""List supported webhook event types."""
|
|
1104
|
+
resp = await self._request("GET", "/v1/webhooks/events")
|
|
1105
|
+
return parse_webhook_event_types_output(resp)
|
|
1106
|
+
|
|
1107
|
+
# --- API Keys ---
|
|
1108
|
+
|
|
1109
|
+
async def create_api_key(
|
|
1110
|
+
self,
|
|
1111
|
+
*,
|
|
1112
|
+
name: str,
|
|
1113
|
+
scopes: Iterable[str],
|
|
1114
|
+
expires_in: str | None = None,
|
|
1115
|
+
purpose: str | None = None,
|
|
1116
|
+
tunnel_slug: str | None = None,
|
|
1117
|
+
idempotency_key: str | None = None,
|
|
1118
|
+
) -> APIKey:
|
|
1119
|
+
"""Create an API key.
|
|
1120
|
+
|
|
1121
|
+
If provided, ``idempotency_key`` must be 32-256 characters for
|
|
1122
|
+
this security-sensitive endpoint so replay keys have enough
|
|
1123
|
+
caller-controlled entropy.
|
|
1124
|
+
"""
|
|
1125
|
+
validate_required_string(name, "name")
|
|
1126
|
+
body = build_body(
|
|
1127
|
+
{
|
|
1128
|
+
"name": name,
|
|
1129
|
+
"scopes": build_string_list(scopes, "scopes"),
|
|
1130
|
+
"expires_in": expires_in,
|
|
1131
|
+
"purpose": purpose,
|
|
1132
|
+
"tunnel_slug": tunnel_slug,
|
|
1133
|
+
}
|
|
1134
|
+
)
|
|
1135
|
+
resp = await self._request(
|
|
1136
|
+
"POST",
|
|
1137
|
+
"/v1/api-keys",
|
|
1138
|
+
body=body,
|
|
1139
|
+
headers=idempotency_headers(
|
|
1140
|
+
idempotency_key,
|
|
1141
|
+
min_length=32, # API-key issuance uses a higher entropy floor.
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
return parse_api_key(resp)
|
|
1145
|
+
|
|
1146
|
+
async def list_api_keys(
|
|
1147
|
+
self,
|
|
1148
|
+
*,
|
|
1149
|
+
limit: int | None = None,
|
|
1150
|
+
cursor: str | None = None,
|
|
1151
|
+
status: str | None = None,
|
|
1152
|
+
) -> APIKeyListOutput:
|
|
1153
|
+
"""List API keys. JWT auth is required by the API."""
|
|
1154
|
+
data, meta = await self._raw_request(
|
|
1155
|
+
"GET",
|
|
1156
|
+
"/v1/api-keys",
|
|
1157
|
+
params=build_list_params(limit, cursor, status=status),
|
|
1158
|
+
)
|
|
1159
|
+
return parse_api_key_list_output(data, meta)
|
|
1160
|
+
|
|
1161
|
+
async def update_api_key(
|
|
1162
|
+
self,
|
|
1163
|
+
key_id: str,
|
|
1164
|
+
*,
|
|
1165
|
+
name: str | None = None,
|
|
1166
|
+
scopes: Iterable[str] | None = None,
|
|
1167
|
+
idempotency_key: str | None = None,
|
|
1168
|
+
) -> APIKey:
|
|
1169
|
+
"""Update API key name or scopes. JWT auth is required by the API."""
|
|
1170
|
+
validate_id(key_id, "key_id")
|
|
1171
|
+
body = build_body(
|
|
1172
|
+
{
|
|
1173
|
+
"name": name,
|
|
1174
|
+
"scopes": build_string_list(scopes, "scopes")
|
|
1175
|
+
if scopes is not None
|
|
1176
|
+
else None,
|
|
1177
|
+
}
|
|
1178
|
+
)
|
|
1179
|
+
require_nonempty_update(body, "update_api_key", "name, scopes")
|
|
1180
|
+
resp = await self._request(
|
|
1181
|
+
"PATCH",
|
|
1182
|
+
f"/v1/api-keys/{key_id}",
|
|
1183
|
+
body=body,
|
|
1184
|
+
headers=idempotency_headers(idempotency_key),
|
|
1185
|
+
)
|
|
1186
|
+
return parse_api_key(resp)
|
|
1187
|
+
|
|
1188
|
+
async def revoke_api_key(self, key_id: str) -> None:
|
|
1189
|
+
"""Revoke an API key. JWT auth is required by the API."""
|
|
1190
|
+
validate_id(key_id, "key_id")
|
|
1191
|
+
await self._request("DELETE", f"/v1/api-keys/{key_id}")
|
|
1192
|
+
|
|
1193
|
+
# --- Access Codes ---
|
|
1194
|
+
|
|
1195
|
+
async def redeem_access_code(
|
|
1196
|
+
self,
|
|
1197
|
+
*,
|
|
1198
|
+
code: str,
|
|
1199
|
+
honeypot: str | None = None,
|
|
1200
|
+
elapsed_ms: int | None = None,
|
|
1201
|
+
idempotency_key: str | None = None,
|
|
1202
|
+
) -> RedeemAccessCodeOutput:
|
|
1203
|
+
"""Redeem a public access code and return its redirect URL."""
|
|
1204
|
+
validate_required_string(code, "code")
|
|
1205
|
+
if elapsed_ms is not None:
|
|
1206
|
+
validate_nonnegative_int(elapsed_ms, "elapsed_ms")
|
|
1207
|
+
body = build_body({"code": code, "honeypot": honeypot, "elapsed_ms": elapsed_ms})
|
|
1208
|
+
resp = await self._request(
|
|
1209
|
+
"POST",
|
|
1210
|
+
"/v1/access-codes/redeem",
|
|
1211
|
+
body=body,
|
|
1212
|
+
headers=idempotency_headers(idempotency_key),
|
|
1213
|
+
include_auth=False,
|
|
1214
|
+
)
|
|
1215
|
+
return parse_redeem_access_code_output(resp)
|
|
1216
|
+
|
|
1217
|
+
async def create_access_code(
|
|
1218
|
+
self,
|
|
1219
|
+
*,
|
|
1220
|
+
resource_id: str,
|
|
1221
|
+
name: str | None = None,
|
|
1222
|
+
max_uses: int | None = None,
|
|
1223
|
+
expires_at: datetime | str | None = None,
|
|
1224
|
+
idempotency_key: str | None = None,
|
|
1225
|
+
) -> AccessCode:
|
|
1226
|
+
"""Create an access code for a resource."""
|
|
1227
|
+
validate_id(resource_id)
|
|
1228
|
+
body = build_body(
|
|
1229
|
+
{
|
|
1230
|
+
"resource_id": resource_id,
|
|
1231
|
+
"name": name,
|
|
1232
|
+
"max_uses": max_uses,
|
|
1233
|
+
"expires_at": expires_at,
|
|
1234
|
+
}
|
|
1235
|
+
)
|
|
1236
|
+
resp = await self._request(
|
|
1237
|
+
"POST",
|
|
1238
|
+
"/v1/access-codes",
|
|
1239
|
+
body=body,
|
|
1240
|
+
headers=idempotency_headers(idempotency_key),
|
|
1241
|
+
)
|
|
1242
|
+
return parse_access_code(resp)
|
|
1243
|
+
|
|
1244
|
+
async def list_access_codes(
|
|
1245
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
1246
|
+
) -> AccessCodeListOutput:
|
|
1247
|
+
"""List access codes."""
|
|
1248
|
+
data, meta = await self._raw_request(
|
|
1249
|
+
"GET",
|
|
1250
|
+
"/v1/access-codes",
|
|
1251
|
+
params=build_list_params(limit, cursor),
|
|
1252
|
+
)
|
|
1253
|
+
return parse_access_code_list_output(data, meta)
|
|
1254
|
+
|
|
1255
|
+
async def revoke_access_code(self, access_code_id: str) -> None:
|
|
1256
|
+
"""Revoke an access code."""
|
|
1257
|
+
validate_id(access_code_id, "access_code_id")
|
|
1258
|
+
await self._request("DELETE", f"/v1/access-codes/{access_code_id}")
|
|
1259
|
+
|
|
1260
|
+
# --- Usage, Customer, Billing, Connectors, Agent ---
|
|
1261
|
+
|
|
1262
|
+
async def get_usage_current_period(self) -> CurrentPeriodUsage:
|
|
1263
|
+
"""Get current billing-period usage. JWT auth is required by the API."""
|
|
1264
|
+
resp = await self._request("GET", "/v1/usage/current-period")
|
|
1265
|
+
return parse_current_period_usage(resp)
|
|
1266
|
+
|
|
1267
|
+
async def get_usage_daily(self) -> DailyUsage:
|
|
1268
|
+
"""Get daily qURL creation counts. JWT auth is required by the API."""
|
|
1269
|
+
resp = await self._request("GET", "/v1/usage/daily")
|
|
1270
|
+
return parse_daily_usage(resp)
|
|
1271
|
+
|
|
1272
|
+
async def get_customer(self) -> Customer:
|
|
1273
|
+
"""Get the authenticated customer profile. JWT auth is required by the API."""
|
|
1274
|
+
resp = await self._request("GET", "/v1/customer")
|
|
1275
|
+
return parse_customer(resp)
|
|
1276
|
+
|
|
1277
|
+
async def update_customer(
|
|
1278
|
+
self, *, spending_cap_cents: int, idempotency_key: str | None = None
|
|
1279
|
+
) -> Customer:
|
|
1280
|
+
"""Update customer billing settings. JWT auth is required by the API."""
|
|
1281
|
+
validate_nonnegative_int(spending_cap_cents, "spending_cap_cents")
|
|
1282
|
+
resp = await self._request(
|
|
1283
|
+
"PATCH",
|
|
1284
|
+
"/v1/customer",
|
|
1285
|
+
body={"spending_cap_cents": spending_cap_cents},
|
|
1286
|
+
headers=idempotency_headers(idempotency_key),
|
|
1287
|
+
)
|
|
1288
|
+
return parse_customer(resp)
|
|
1289
|
+
|
|
1290
|
+
async def create_billing_checkout(
|
|
1291
|
+
self, *, plan: str, idempotency_key: str | None = None
|
|
1292
|
+
) -> CheckoutSession:
|
|
1293
|
+
"""Create a Stripe checkout session. JWT auth is required by the API."""
|
|
1294
|
+
validate_required_string(plan, "plan")
|
|
1295
|
+
resp = await self._request(
|
|
1296
|
+
"POST",
|
|
1297
|
+
"/v1/billing/checkout",
|
|
1298
|
+
body={"plan": plan},
|
|
1299
|
+
headers=idempotency_headers(idempotency_key),
|
|
1300
|
+
)
|
|
1301
|
+
return parse_checkout_session(resp)
|
|
1302
|
+
|
|
1303
|
+
async def create_billing_portal(
|
|
1304
|
+
self, *, idempotency_key: str | None = None
|
|
1305
|
+
) -> PortalSession:
|
|
1306
|
+
"""Create a Stripe billing portal session. JWT auth is required by the API."""
|
|
1307
|
+
resp = await self._request(
|
|
1308
|
+
"POST",
|
|
1309
|
+
"/v1/billing/portal",
|
|
1310
|
+
headers=idempotency_headers(idempotency_key),
|
|
1311
|
+
)
|
|
1312
|
+
return parse_portal_session(resp)
|
|
1313
|
+
|
|
1314
|
+
async def list_billing_invoices(
|
|
1315
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
1316
|
+
) -> InvoiceListOutput:
|
|
1317
|
+
"""List billing invoices. JWT auth is required by the API."""
|
|
1318
|
+
data, meta = await self._raw_request(
|
|
1319
|
+
"GET",
|
|
1320
|
+
"/v1/billing/invoices",
|
|
1321
|
+
params=build_list_params(limit, cursor),
|
|
1322
|
+
)
|
|
1323
|
+
return parse_invoice_list_output(data, meta)
|
|
1324
|
+
|
|
1325
|
+
async def list_connector_installations(
|
|
1326
|
+
self, *, limit: int | None = None, cursor: str | None = None
|
|
1327
|
+
) -> ConnectorInstallationListOutput:
|
|
1328
|
+
"""List normalized connector installations."""
|
|
1329
|
+
data, meta = await self._raw_request(
|
|
1330
|
+
"GET",
|
|
1331
|
+
"/v1/connectors/installations",
|
|
1332
|
+
params=build_list_params(limit, cursor),
|
|
1333
|
+
)
|
|
1334
|
+
return parse_connector_installation_list_output(data, meta)
|
|
1335
|
+
|
|
1336
|
+
async def bootstrap_agent(
|
|
1337
|
+
self,
|
|
1338
|
+
*,
|
|
1339
|
+
public_key: str,
|
|
1340
|
+
agent_id: str | None = None,
|
|
1341
|
+
hostname: str | None = None,
|
|
1342
|
+
version: str | None = None,
|
|
1343
|
+
idempotency_key: str | None = None,
|
|
1344
|
+
) -> AgentBootstrapOutput:
|
|
1345
|
+
"""Bootstrap a LayerV qURL Connector agent."""
|
|
1346
|
+
validate_required_string(public_key, "public_key")
|
|
1347
|
+
body = build_body(
|
|
1348
|
+
{
|
|
1349
|
+
"public_key": public_key,
|
|
1350
|
+
"agent_id": agent_id,
|
|
1351
|
+
"hostname": hostname,
|
|
1352
|
+
"version": version,
|
|
1353
|
+
}
|
|
1354
|
+
)
|
|
1355
|
+
resp = await self._request(
|
|
1356
|
+
"POST",
|
|
1357
|
+
"/v1/agent/bootstrap",
|
|
1358
|
+
body=body,
|
|
1359
|
+
headers=idempotency_headers(idempotency_key),
|
|
1360
|
+
)
|
|
1361
|
+
return parse_agent_bootstrap_output(resp)
|
|
1362
|
+
|
|
1363
|
+
# --- Internal HTTP plumbing ---
|
|
1364
|
+
|
|
1365
|
+
async def _request(
|
|
1366
|
+
self,
|
|
1367
|
+
method: str,
|
|
1368
|
+
path: str,
|
|
1369
|
+
*,
|
|
1370
|
+
body: dict[str, Any] | None = None,
|
|
1371
|
+
params: dict[str, str] | None = None,
|
|
1372
|
+
allow_statuses: tuple[int, ...] = (),
|
|
1373
|
+
headers: dict[str, str] | None = None,
|
|
1374
|
+
include_auth: bool = True,
|
|
1375
|
+
) -> Any:
|
|
1376
|
+
return (
|
|
1377
|
+
await self._raw_request(
|
|
1378
|
+
method,
|
|
1379
|
+
path,
|
|
1380
|
+
body=body,
|
|
1381
|
+
params=params,
|
|
1382
|
+
allow_statuses=allow_statuses,
|
|
1383
|
+
headers=headers,
|
|
1384
|
+
include_auth=include_auth,
|
|
1385
|
+
)
|
|
1386
|
+
)[0]
|
|
1387
|
+
|
|
1388
|
+
async def _raw_request(
|
|
1389
|
+
self,
|
|
1390
|
+
method: str,
|
|
1391
|
+
path: str,
|
|
1392
|
+
*,
|
|
1393
|
+
body: dict[str, Any] | None = None,
|
|
1394
|
+
params: dict[str, str] | None = None,
|
|
1395
|
+
allow_statuses: tuple[int, ...] = (),
|
|
1396
|
+
headers: dict[str, str] | None = None,
|
|
1397
|
+
include_auth: bool = True,
|
|
1398
|
+
) -> tuple[Any, dict[str, Any] | None]:
|
|
1399
|
+
"""Issue an HTTP request and parse the JSON envelope.
|
|
1400
|
+
|
|
1401
|
+
``include_auth=False`` is for public flows such as
|
|
1402
|
+
:meth:`redeem_access_code`, where the bearer token should stay off
|
|
1403
|
+
the wire even when the client was constructed with one.
|
|
1404
|
+
|
|
1405
|
+
``allow_statuses`` lets a caller opt specific non-2xx codes out of
|
|
1406
|
+
the default raise-on-error path and receive the parsed body
|
|
1407
|
+
instead. This is used by :meth:`batch_create`, where the API
|
|
1408
|
+
returns a structured ``BatchCreateOutput`` on HTTP 400 (all items
|
|
1409
|
+
rejected) — raising would drop the per-item errors.
|
|
1410
|
+
|
|
1411
|
+
**`allow_statuses` takes precedence over retries.** The check
|
|
1412
|
+
order in the response-handling loop is:
|
|
1413
|
+
|
|
1414
|
+
1. ``response.status_code < 400 or in allow_statuses`` →
|
|
1415
|
+
return the parsed body immediately as a success.
|
|
1416
|
+
2. Otherwise, build an error and check the retry filter
|
|
1417
|
+
(``RETRYABLE_STATUS_POST`` for POST, ``RETRYABLE_STATUS``
|
|
1418
|
+
for everything else).
|
|
1419
|
+
|
|
1420
|
+
This means a status listed in ``allow_statuses`` is returned
|
|
1421
|
+
to the caller **without ever running through the retry
|
|
1422
|
+
filter**, even if that status would normally be retried. For
|
|
1423
|
+
the only current use case (``batch_create`` with
|
|
1424
|
+
``allow_statuses=(400,)``) the interaction is harmless because
|
|
1425
|
+
400 isn't in any retry set — a 400 carries the authoritative
|
|
1426
|
+
per-item errors and retrying would just reproduce them.
|
|
1427
|
+
|
|
1428
|
+
Callers adding a *retryable* status (e.g. 429 or 5xx) to
|
|
1429
|
+
``allow_statuses`` should be aware this bypasses the SDK's
|
|
1430
|
+
retry path entirely: the status is surfaced on the first
|
|
1431
|
+
attempt with no transparent backoff. If that's not what you
|
|
1432
|
+
want, leave the status out of ``allow_statuses`` and let the
|
|
1433
|
+
normal retry logic handle it.
|
|
1434
|
+
"""
|
|
1435
|
+
url = f"{self._base_url}{path}"
|
|
1436
|
+
last_error: Exception | None = None
|
|
1437
|
+
request_headers = dict(self._base_headers)
|
|
1438
|
+
if not include_auth:
|
|
1439
|
+
request_headers.pop("Authorization", None)
|
|
1440
|
+
if headers:
|
|
1441
|
+
request_headers.update(headers)
|
|
1442
|
+
ensure_mutation_idempotency(method, request_headers)
|
|
1443
|
+
|
|
1444
|
+
for attempt in range(self._max_retries + 1):
|
|
1445
|
+
if attempt > 0:
|
|
1446
|
+
delay = retry_delay(attempt, last_error)
|
|
1447
|
+
logger.debug("Retry %d/%d after %.1fs", attempt, self._max_retries, delay)
|
|
1448
|
+
await asyncio.sleep(delay)
|
|
1449
|
+
|
|
1450
|
+
logger.debug("%s %s", method, url)
|
|
1451
|
+
|
|
1452
|
+
try:
|
|
1453
|
+
response = await self._client.request(
|
|
1454
|
+
method,
|
|
1455
|
+
url,
|
|
1456
|
+
json=body,
|
|
1457
|
+
params=params,
|
|
1458
|
+
headers=request_headers,
|
|
1459
|
+
)
|
|
1460
|
+
except httpx.TimeoutException as exc:
|
|
1461
|
+
logger.debug("%s %s timed out", method, url)
|
|
1462
|
+
if attempt < self._max_retries:
|
|
1463
|
+
last_error = exc
|
|
1464
|
+
continue
|
|
1465
|
+
raise QURLTimeoutError(str(exc), cause=exc) from exc
|
|
1466
|
+
except httpx.TransportError as exc:
|
|
1467
|
+
logger.debug("%s %s transport error: %s", method, url, exc)
|
|
1468
|
+
if attempt < self._max_retries:
|
|
1469
|
+
last_error = exc
|
|
1470
|
+
continue
|
|
1471
|
+
raise QURLNetworkError(str(exc), cause=exc) from exc
|
|
1472
|
+
|
|
1473
|
+
logger.debug("%s %s → %d", method, url, response.status_code)
|
|
1474
|
+
|
|
1475
|
+
if response.status_code < 400 or response.status_code in allow_statuses:
|
|
1476
|
+
if response.status_code == 204 or not response.content:
|
|
1477
|
+
return None, None
|
|
1478
|
+
try:
|
|
1479
|
+
envelope = response.json()
|
|
1480
|
+
except (json.JSONDecodeError, ValueError):
|
|
1481
|
+
# If a whitelisted status (e.g. 400 on batch_create)
|
|
1482
|
+
# comes back with a non-JSON body — a proxy HTML
|
|
1483
|
+
# error page, a truncated response, a gateway's own
|
|
1484
|
+
# plaintext error — we CAN'T surface it as success.
|
|
1485
|
+
# Fall through to `parse_error`, which handles
|
|
1486
|
+
# non-JSON error bodies gracefully and returns a
|
|
1487
|
+
# well-formed QURLError using the response status
|
|
1488
|
+
# and reason phrase. `raise ... from None` hides
|
|
1489
|
+
# the JSONDecodeError chain since it's noise: the
|
|
1490
|
+
# QURLError already captures "the body wasn't a
|
|
1491
|
+
# parseable envelope" as its detail.
|
|
1492
|
+
raise parse_error(response) from None
|
|
1493
|
+
return envelope.get("data"), envelope.get("meta")
|
|
1494
|
+
|
|
1495
|
+
err = parse_error(response)
|
|
1496
|
+
# PATCH already uses the wider retry set; attaching a stable
|
|
1497
|
+
# per-call idempotency key makes those retries safe. POST remains
|
|
1498
|
+
# 429-only because resolve can consume one-time tokens on server
|
|
1499
|
+
# failures.
|
|
1500
|
+
retryable = RETRYABLE_STATUS_POST if method == "POST" else RETRYABLE_STATUS
|
|
1501
|
+
if response.status_code in retryable and attempt < self._max_retries:
|
|
1502
|
+
last_error = err
|
|
1503
|
+
continue
|
|
1504
|
+
raise err
|
|
1505
|
+
|
|
1506
|
+
if isinstance(last_error, httpx.TimeoutException):
|
|
1507
|
+
raise QURLTimeoutError(str(last_error), cause=last_error) from last_error
|
|
1508
|
+
if isinstance(last_error, httpx.TransportError):
|
|
1509
|
+
raise QURLNetworkError(str(last_error), cause=last_error) from last_error
|
|
1510
|
+
raise last_error or QURLError(
|
|
1511
|
+
status=0, code="unknown", title="Request failed", detail="Exhausted retries"
|
|
1512
|
+
)
|