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