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.
@@ -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
+ )