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/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
+ )