tabsail 0.1.0__tar.gz
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.
- tabsail-0.1.0/.gitignore +3 -0
- tabsail-0.1.0/PKG-INFO +131 -0
- tabsail-0.1.0/README.md +118 -0
- tabsail-0.1.0/pyproject.toml +35 -0
- tabsail-0.1.0/src/tabsail/__init__.py +69 -0
- tabsail-0.1.0/src/tabsail/client.py +286 -0
- tabsail-0.1.0/src/tabsail/errors.py +125 -0
- tabsail-0.1.0/src/tabsail/models.py +268 -0
- tabsail-0.1.0/src/tabsail/py.typed +0 -0
- tabsail-0.1.0/src/tabsail/resources.py +612 -0
- tabsail-0.1.0/src/tabsail/transport.py +294 -0
- tabsail-0.1.0/src/tabsail/updates.py +148 -0
- tabsail-0.1.0/tests/pack_consumer.py +164 -0
- tabsail-0.1.0/tests/test_capacity.py +152 -0
- tabsail-0.1.0/tests/test_sdk.py +1058 -0
- tabsail-0.1.0/uv.lock +377 -0
tabsail-0.1.0/.gitignore
ADDED
tabsail-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tabsail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official async Python SDK for TabSail
|
|
5
|
+
Project-URL: Documentation, https://tabsail.com/docs/sdks/
|
|
6
|
+
Project-URL: Homepage, https://tabsail.com
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: certifi>=2026.10.19
|
|
11
|
+
Requires-Dist: httpx<0.29,>=0.28.1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# TabSail Python SDK
|
|
15
|
+
|
|
16
|
+
Hosted endpoint: `https://tabsail.com`. Set `TABSAIL_BASE_URL=https://tabsail.com` and `TABSAIL_API_KEY` to your project key. For local or separate deployments, use that instance's root URL.
|
|
17
|
+
|
|
18
|
+
An asynchronous, typed Python >=3.12 client for TabSail v1 session, file, handoff and Profile resources. Distribution `tabsail`, import `tabsail`, version `0.1.0`. It requires httpx, and does not install Playwright, a model provider, Chromium or Docker.
|
|
19
|
+
|
|
20
|
+
Fund the Tenant through the console before creating a browser. At the base rate of 100 credits/hour, each billable session rounds up to whole credits (minimum 1 credit, or one per started 36 seconds). The maximum-duration budget is also rounded up before reservation; unused credits are released and platform failures are free. Raw duration remains measured in milliseconds; $1 purchases 1,000 credits. `402 INSUFFICIENT_CREDITS` and `403 BILLING_FROZEN` never enter capacity waiting. Projects and Keys share their Tenant's credits and concurrency.
|
|
21
|
+
|
|
22
|
+
Install from PyPI:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
uv add "tabsail==0.1.0"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from tabsail import AsyncTabSail, RequestOptions
|
|
30
|
+
|
|
31
|
+
async with AsyncTabSail(base_url="https://tabsail.com", api_key="PROJECT_KEY") as client:
|
|
32
|
+
async with client.with_session(
|
|
33
|
+
timeout_seconds=900,
|
|
34
|
+
options=RequestOptions(idempotency_key="your-task-id"),
|
|
35
|
+
) as session:
|
|
36
|
+
grant = await client.sessions.connections.create(session.id, type="cdp")
|
|
37
|
+
# Pass grant.url to your native async Playwright connect_over_cdp().
|
|
38
|
+
# Reuse the browser's existing context/page; actions belong to Playwright.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The base URL is the instance root; trailing slashes are removed and `/v1` is appended. Explicit configuration overrides `TABSAIL_BASE_URL` and `TABSAIL_API_KEY`. No `.env` or consumer filesystem secrets are searched. Redirects are not followed, so the Project Key is not forwarded to another origin. Treat grant URLs as credentials; their model repr hides the URL.
|
|
42
|
+
|
|
43
|
+
`client.aclose()` closes local HTTP connections. CDP disconnect does not release the remote browser. `with_session()` creates and owns one BrowserSession, waits for ready and yields that resource; it does not create a Playwright Browser. On exit it closes its session and waits for non-null `released_at`. Explicitly created sessions are caller-owned and are never closed by the client's HTTP context. Failed status alone does not prove resource/quota release.
|
|
44
|
+
|
|
45
|
+
All SDK timeouts are named with `_ms`: `request_timeout_ms` bounds one HTTP attempt; `RequestOptions(timeout_ms=...)` bounds one logical HTTP operation including retry/backoff; lifecycle `timeout_ms` bounds the entire polling operation including requests and sleeps. `timeout_seconds` is the server's browser lifetime, independent of HTTP/wait deadlines. Omitted server creation fields remain omitted. Default request timeout is 15,000 ms, lifecycle polling 500 ms, ready/released wait 60,000 ms, handoff wait 300,000 ms. Capacity errors return immediately with default `max_retries=0`. Explicit retries are limited to 10; only GET, keyed create/handoff create and state-idempotent close/complete can replay. Exponential backoff starts with a 250 ms window capped at 5 seconds, randomly waiting between half and all of that window after the full Retry-After minimum. A deadline can end the call before another retry; the server minimum is never shortened. Grants and uploads never replay automatically.
|
|
46
|
+
|
|
47
|
+
`with_session(wait_for_capacity=True)` waits for `503 NO_CAPACITY/STARTUP_BUSY` and `429 CREATE_RATE_LIMITED` using one idempotency key and unchanged creation parameters. Its backoff window grows from 250 ms to 10 seconds, randomized after Retry-After. The default total create + ready deadline is 300,000 ms; override it with `RequestOptions(timeout_ms=...)`. Startup retains its separate ready limit and task cancellation interrupts waiting. Tenant concurrency limits, insufficient credits and billing freezes are not capacity waits. This helper does not promise a queue position or eventual allocation, and never replays your business code.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from tabsail import HandoffCheckpoint
|
|
51
|
+
|
|
52
|
+
stable = await client.sessions.get(session.id) # ready + automation, one snapshot
|
|
53
|
+
checkpoint = HandoffCheckpoint.from_session(stable)
|
|
54
|
+
resumed = await client.sessions.wait_for_handoff(session.id, after=checkpoint)
|
|
55
|
+
# resumed.latest_handoff.status == "completed", resumed.controller == "automation"
|
|
56
|
+
checkpoint = HandoffCheckpoint.from_session(resumed)
|
|
57
|
+
# Request a new CDP grant, reconnect the SAME session and retrieve fresh context/page.
|
|
58
|
+
# Observe human modifications before choosing the next action.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`wait_for_handoff` compares arbitrary-size decimal epochs as integers and requires a new latest Handoff ID. It detects a completed handoff between observations, never consumes the old completion again, and waits through consecutive handoffs until the current latest handoff is completed with automation control in the same snapshot. The checkpoint must originate from stable ready + automation; do not capture it during human control. This helper returns a BrowserSession; known IDs can use `handoffs.wait_until_completed(id, handoff_id)`, which returns a Handoff. Completion payload `returning` is pending even if an older server returns HTTP 200. Session termination, cancellation and cancelled handoffs are explicit errors. The SDK does not reconnect Playwright or replay webpage actions/callbacks. Natural CDP disconnect without an epoch change is not an observed handoff.
|
|
62
|
+
|
|
63
|
+
Lifecycle helpers use an authenticated SSE snapshot subscription after the initial GET. Reconnects read the latest complete state. Older servers without this endpoint retain polling compatibility; `poll_interval_ms` controls fallback and reconnect backoff. No action or callback is replayed.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from contextlib import aclosing
|
|
67
|
+
|
|
68
|
+
async with aclosing(client.sessions.watch(session.id, timeout_ms=300000)) as updates:
|
|
69
|
+
async for snapshot in updates:
|
|
70
|
+
print(snapshot.status, snapshot.controller)
|
|
71
|
+
if snapshot.released_at:
|
|
72
|
+
break
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`aclosing` releases the local subscription on early exit. It does not close the remote browser. Native asyncio cancellation and total wait deadlines remain supported.
|
|
76
|
+
|
|
77
|
+
Resources: `sessions.create/get/list/close`, `sessions.connections.create(type="cdp" or "live", mode="view" or "control", handoff_id=...)`, `sessions.handoffs.create/get/complete`, `sessions.files.upload/list/get/download/delete`. Request fields use Python snake_case and serialize to the existing camelCase HTTP contract. List methods return `Page(items, next_cursor)`; pass its cursor explicitly for the next page. Models reject unknown state values with ProtocolError and tolerate extra response fields.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
|
|
82
|
+
file = await client.sessions.files.upload(session.id, Path("报告.bin"))
|
|
83
|
+
async with client.sessions.files.download(session.id, file.id) as download:
|
|
84
|
+
with Path("retrieved.bin").open("wb") as output:
|
|
85
|
+
async for chunk in download.aiter_bytes():
|
|
86
|
+
output.write(chunk)
|
|
87
|
+
await client.sessions.files.delete(session.id, file.id) # 204 has no JSON body
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Uploads accept bytes, Path or a binary file object and use multipart; callers close their own file objects. A Path is opened and closed by the SDK. Downloads are streamed and must be used with `async with`; cancellation or early context exit closes the local response. The entire HTTP attempt, including headers and stream reads, shares `request_timeout_ms`; an optional `timeout_ms` additionally limits the logical operation including retries. Each read checks the remaining absolute deadline. Processing a yielded chunk does not arm a timer that cancels your task; if processing uses the remaining budget, the next read raises RequestTimeoutError and closes the stream. `browser_path` is remote, and files must be retrieved before closing the session. No durable file hosting or resume is promised.
|
|
91
|
+
|
|
92
|
+
Errors distinguish configuration, network, HTTP request timeout, wait deadline, asyncio cancellation, API errors, protocol errors and unknown mutation outcomes. SDK error fields include `status`, `code`, `retryable`, `request_id`, `retry_after`, `session_id`. Unknown API codes remain APIError. Gateway HTML/empty 502 responses retain bounded sanitized HTTP context. Raw credentials/grant URLs do not appear in SDK exception messages.
|
|
93
|
+
|
|
94
|
+
Session/handoff creation generates one Idempotency-Key per logical call; supply `RequestOptions(idempotency_key=...)` for cross-process recovery. Replay can return an old starting snapshot; always GET/wait for current state. Mutation 5xx errors can follow a commit and are ResultUnknownError with their original APIError/status/request ID; explicit `NO_CAPACITY` and `STARTUP_BUSY` rejection remain APIError. A failed/lost upload response is ResultUnknownError: inspect files instead of blindly uploading again.
|
|
95
|
+
|
|
96
|
+
Owned creation cancelled before sending is not transmitted. Once creation outcome is unknown, cleanup uses the same key within an independent `cleanup_timeout_ms` budget (30,000 ms by default), obtains the unique ID, closes and confirms release. If the original request never arrived, this recovery may briefly create then close a new session. Cancellation never cancels this shielded cleanup, and business code is not retried. A primary exception remains primary; cleanup failure is attached as `.cleanup_error`, with `.session_id` and `.idempotency_key` for manual recovery. Without a primary failure, CleanupError is raised. Never reuse the recovery key with a changed creation body.
|
|
97
|
+
|
|
98
|
+
Development: `uv sync --group dev`, `uv run pytest`, `uv run mypy`, `uv run ruff check src tests`, `uv build`. `uv run python tests/pack_consumer.py` builds wheel/sdist, installs each in a clean external environment (rebuilding the sdist wheel), checks typed imports and runs a real loopback HTTP consumer. These local tests do not claim PG/Steel/CDP qualification; that evidence belongs to the repository's separate isolated SDK acceptance suite.
|
|
99
|
+
|
|
100
|
+
For elastic capacity, use `client.with_session(wait_for_capacity=True, options=RequestOptions(timeout_ms=300000))`. Enabling it defaults the total acquisition budget to five minutes, including capacity retries and readiness. Only confirmed `NO_CAPACITY` / `STARTUP_BUSY` refusals wait; tenant quota failures return immediately. The same idempotency key and request are retained. Browser lifetime and the context body have separate budgets.
|
|
101
|
+
|
|
102
|
+
## Browser region
|
|
103
|
+
|
|
104
|
+
Creation defaults to automatic selection from the API caller's public egress IP: Asia/Oceania use `asia`, other or unknown locations use `west`. Override when a backend's location differs from the desired browser location.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
async with client.with_session(region="asia", wait_for_capacity=True) as session:
|
|
108
|
+
print(session.region) # actual "asia" or "west"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`await client.sessions.create(region="west")` also accepts the override. Region stays fixed across idempotent retries and the session lifetime. Capacity waiting does not switch regions; use a new request and idempotency key to change region. Use the returned CDP/Live connection URL.
|
|
112
|
+
|
|
113
|
+
## Browser settings, lifetime, and Profiles
|
|
114
|
+
|
|
115
|
+
With an initialized client:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
profile = await client.profiles.create("Report login")
|
|
119
|
+
async with client.with_session(
|
|
120
|
+
browser={"locale": "en-US", "timezone": "Asia/Tokyo", "headless": True},
|
|
121
|
+
idle_timeout_seconds=120,
|
|
122
|
+
profile={"id": profile.id, "persistOnClose": True},
|
|
123
|
+
) as session:
|
|
124
|
+
await client.sessions.update(session.id, timeout_seconds=1200)
|
|
125
|
+
await client.sessions.touch(session.id)
|
|
126
|
+
# Connect and perform your task. The scope closes and releases the browser.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Nested `browser` and `profile` dictionaries use the HTTP field names shown above. `timeout_seconds` in an update is the new total lifetime from `started_at`, not extra seconds. It cannot decrease; extra budget is reserved atomically. Omit `idle_timeout_seconds` to leave it unchanged; pass `None` to disable idle expiry. GET, SSE and read-only Live do not renew idle activity.
|
|
130
|
+
|
|
131
|
+
Profiles support `create`, `list`, `get`, and `delete`. One unreleased session may use a Profile. Normal close/expiry saves cookies and localStorage when `persistOnClose` is true; inspect the ended session's `profile["saveStatus"]`. A failed save preserves the previous version. Profiles exclude downloads, page memory, and Agent history.
|
tabsail-0.1.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# TabSail Python SDK
|
|
2
|
+
|
|
3
|
+
Hosted endpoint: `https://tabsail.com`. Set `TABSAIL_BASE_URL=https://tabsail.com` and `TABSAIL_API_KEY` to your project key. For local or separate deployments, use that instance's root URL.
|
|
4
|
+
|
|
5
|
+
An asynchronous, typed Python >=3.12 client for TabSail v1 session, file, handoff and Profile resources. Distribution `tabsail`, import `tabsail`, version `0.1.0`. It requires httpx, and does not install Playwright, a model provider, Chromium or Docker.
|
|
6
|
+
|
|
7
|
+
Fund the Tenant through the console before creating a browser. At the base rate of 100 credits/hour, each billable session rounds up to whole credits (minimum 1 credit, or one per started 36 seconds). The maximum-duration budget is also rounded up before reservation; unused credits are released and platform failures are free. Raw duration remains measured in milliseconds; $1 purchases 1,000 credits. `402 INSUFFICIENT_CREDITS` and `403 BILLING_FROZEN` never enter capacity waiting. Projects and Keys share their Tenant's credits and concurrency.
|
|
8
|
+
|
|
9
|
+
Install from PyPI:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uv add "tabsail==0.1.0"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from tabsail import AsyncTabSail, RequestOptions
|
|
17
|
+
|
|
18
|
+
async with AsyncTabSail(base_url="https://tabsail.com", api_key="PROJECT_KEY") as client:
|
|
19
|
+
async with client.with_session(
|
|
20
|
+
timeout_seconds=900,
|
|
21
|
+
options=RequestOptions(idempotency_key="your-task-id"),
|
|
22
|
+
) as session:
|
|
23
|
+
grant = await client.sessions.connections.create(session.id, type="cdp")
|
|
24
|
+
# Pass grant.url to your native async Playwright connect_over_cdp().
|
|
25
|
+
# Reuse the browser's existing context/page; actions belong to Playwright.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The base URL is the instance root; trailing slashes are removed and `/v1` is appended. Explicit configuration overrides `TABSAIL_BASE_URL` and `TABSAIL_API_KEY`. No `.env` or consumer filesystem secrets are searched. Redirects are not followed, so the Project Key is not forwarded to another origin. Treat grant URLs as credentials; their model repr hides the URL.
|
|
29
|
+
|
|
30
|
+
`client.aclose()` closes local HTTP connections. CDP disconnect does not release the remote browser. `with_session()` creates and owns one BrowserSession, waits for ready and yields that resource; it does not create a Playwright Browser. On exit it closes its session and waits for non-null `released_at`. Explicitly created sessions are caller-owned and are never closed by the client's HTTP context. Failed status alone does not prove resource/quota release.
|
|
31
|
+
|
|
32
|
+
All SDK timeouts are named with `_ms`: `request_timeout_ms` bounds one HTTP attempt; `RequestOptions(timeout_ms=...)` bounds one logical HTTP operation including retry/backoff; lifecycle `timeout_ms` bounds the entire polling operation including requests and sleeps. `timeout_seconds` is the server's browser lifetime, independent of HTTP/wait deadlines. Omitted server creation fields remain omitted. Default request timeout is 15,000 ms, lifecycle polling 500 ms, ready/released wait 60,000 ms, handoff wait 300,000 ms. Capacity errors return immediately with default `max_retries=0`. Explicit retries are limited to 10; only GET, keyed create/handoff create and state-idempotent close/complete can replay. Exponential backoff starts with a 250 ms window capped at 5 seconds, randomly waiting between half and all of that window after the full Retry-After minimum. A deadline can end the call before another retry; the server minimum is never shortened. Grants and uploads never replay automatically.
|
|
33
|
+
|
|
34
|
+
`with_session(wait_for_capacity=True)` waits for `503 NO_CAPACITY/STARTUP_BUSY` and `429 CREATE_RATE_LIMITED` using one idempotency key and unchanged creation parameters. Its backoff window grows from 250 ms to 10 seconds, randomized after Retry-After. The default total create + ready deadline is 300,000 ms; override it with `RequestOptions(timeout_ms=...)`. Startup retains its separate ready limit and task cancellation interrupts waiting. Tenant concurrency limits, insufficient credits and billing freezes are not capacity waits. This helper does not promise a queue position or eventual allocation, and never replays your business code.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from tabsail import HandoffCheckpoint
|
|
38
|
+
|
|
39
|
+
stable = await client.sessions.get(session.id) # ready + automation, one snapshot
|
|
40
|
+
checkpoint = HandoffCheckpoint.from_session(stable)
|
|
41
|
+
resumed = await client.sessions.wait_for_handoff(session.id, after=checkpoint)
|
|
42
|
+
# resumed.latest_handoff.status == "completed", resumed.controller == "automation"
|
|
43
|
+
checkpoint = HandoffCheckpoint.from_session(resumed)
|
|
44
|
+
# Request a new CDP grant, reconnect the SAME session and retrieve fresh context/page.
|
|
45
|
+
# Observe human modifications before choosing the next action.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`wait_for_handoff` compares arbitrary-size decimal epochs as integers and requires a new latest Handoff ID. It detects a completed handoff between observations, never consumes the old completion again, and waits through consecutive handoffs until the current latest handoff is completed with automation control in the same snapshot. The checkpoint must originate from stable ready + automation; do not capture it during human control. This helper returns a BrowserSession; known IDs can use `handoffs.wait_until_completed(id, handoff_id)`, which returns a Handoff. Completion payload `returning` is pending even if an older server returns HTTP 200. Session termination, cancellation and cancelled handoffs are explicit errors. The SDK does not reconnect Playwright or replay webpage actions/callbacks. Natural CDP disconnect without an epoch change is not an observed handoff.
|
|
49
|
+
|
|
50
|
+
Lifecycle helpers use an authenticated SSE snapshot subscription after the initial GET. Reconnects read the latest complete state. Older servers without this endpoint retain polling compatibility; `poll_interval_ms` controls fallback and reconnect backoff. No action or callback is replayed.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from contextlib import aclosing
|
|
54
|
+
|
|
55
|
+
async with aclosing(client.sessions.watch(session.id, timeout_ms=300000)) as updates:
|
|
56
|
+
async for snapshot in updates:
|
|
57
|
+
print(snapshot.status, snapshot.controller)
|
|
58
|
+
if snapshot.released_at:
|
|
59
|
+
break
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`aclosing` releases the local subscription on early exit. It does not close the remote browser. Native asyncio cancellation and total wait deadlines remain supported.
|
|
63
|
+
|
|
64
|
+
Resources: `sessions.create/get/list/close`, `sessions.connections.create(type="cdp" or "live", mode="view" or "control", handoff_id=...)`, `sessions.handoffs.create/get/complete`, `sessions.files.upload/list/get/download/delete`. Request fields use Python snake_case and serialize to the existing camelCase HTTP contract. List methods return `Page(items, next_cursor)`; pass its cursor explicitly for the next page. Models reject unknown state values with ProtocolError and tolerate extra response fields.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
file = await client.sessions.files.upload(session.id, Path("报告.bin"))
|
|
70
|
+
async with client.sessions.files.download(session.id, file.id) as download:
|
|
71
|
+
with Path("retrieved.bin").open("wb") as output:
|
|
72
|
+
async for chunk in download.aiter_bytes():
|
|
73
|
+
output.write(chunk)
|
|
74
|
+
await client.sessions.files.delete(session.id, file.id) # 204 has no JSON body
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Uploads accept bytes, Path or a binary file object and use multipart; callers close their own file objects. A Path is opened and closed by the SDK. Downloads are streamed and must be used with `async with`; cancellation or early context exit closes the local response. The entire HTTP attempt, including headers and stream reads, shares `request_timeout_ms`; an optional `timeout_ms` additionally limits the logical operation including retries. Each read checks the remaining absolute deadline. Processing a yielded chunk does not arm a timer that cancels your task; if processing uses the remaining budget, the next read raises RequestTimeoutError and closes the stream. `browser_path` is remote, and files must be retrieved before closing the session. No durable file hosting or resume is promised.
|
|
78
|
+
|
|
79
|
+
Errors distinguish configuration, network, HTTP request timeout, wait deadline, asyncio cancellation, API errors, protocol errors and unknown mutation outcomes. SDK error fields include `status`, `code`, `retryable`, `request_id`, `retry_after`, `session_id`. Unknown API codes remain APIError. Gateway HTML/empty 502 responses retain bounded sanitized HTTP context. Raw credentials/grant URLs do not appear in SDK exception messages.
|
|
80
|
+
|
|
81
|
+
Session/handoff creation generates one Idempotency-Key per logical call; supply `RequestOptions(idempotency_key=...)` for cross-process recovery. Replay can return an old starting snapshot; always GET/wait for current state. Mutation 5xx errors can follow a commit and are ResultUnknownError with their original APIError/status/request ID; explicit `NO_CAPACITY` and `STARTUP_BUSY` rejection remain APIError. A failed/lost upload response is ResultUnknownError: inspect files instead of blindly uploading again.
|
|
82
|
+
|
|
83
|
+
Owned creation cancelled before sending is not transmitted. Once creation outcome is unknown, cleanup uses the same key within an independent `cleanup_timeout_ms` budget (30,000 ms by default), obtains the unique ID, closes and confirms release. If the original request never arrived, this recovery may briefly create then close a new session. Cancellation never cancels this shielded cleanup, and business code is not retried. A primary exception remains primary; cleanup failure is attached as `.cleanup_error`, with `.session_id` and `.idempotency_key` for manual recovery. Without a primary failure, CleanupError is raised. Never reuse the recovery key with a changed creation body.
|
|
84
|
+
|
|
85
|
+
Development: `uv sync --group dev`, `uv run pytest`, `uv run mypy`, `uv run ruff check src tests`, `uv build`. `uv run python tests/pack_consumer.py` builds wheel/sdist, installs each in a clean external environment (rebuilding the sdist wheel), checks typed imports and runs a real loopback HTTP consumer. These local tests do not claim PG/Steel/CDP qualification; that evidence belongs to the repository's separate isolated SDK acceptance suite.
|
|
86
|
+
|
|
87
|
+
For elastic capacity, use `client.with_session(wait_for_capacity=True, options=RequestOptions(timeout_ms=300000))`. Enabling it defaults the total acquisition budget to five minutes, including capacity retries and readiness. Only confirmed `NO_CAPACITY` / `STARTUP_BUSY` refusals wait; tenant quota failures return immediately. The same idempotency key and request are retained. Browser lifetime and the context body have separate budgets.
|
|
88
|
+
|
|
89
|
+
## Browser region
|
|
90
|
+
|
|
91
|
+
Creation defaults to automatic selection from the API caller's public egress IP: Asia/Oceania use `asia`, other or unknown locations use `west`. Override when a backend's location differs from the desired browser location.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
async with client.with_session(region="asia", wait_for_capacity=True) as session:
|
|
95
|
+
print(session.region) # actual "asia" or "west"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`await client.sessions.create(region="west")` also accepts the override. Region stays fixed across idempotent retries and the session lifetime. Capacity waiting does not switch regions; use a new request and idempotency key to change region. Use the returned CDP/Live connection URL.
|
|
99
|
+
|
|
100
|
+
## Browser settings, lifetime, and Profiles
|
|
101
|
+
|
|
102
|
+
With an initialized client:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
profile = await client.profiles.create("Report login")
|
|
106
|
+
async with client.with_session(
|
|
107
|
+
browser={"locale": "en-US", "timezone": "Asia/Tokyo", "headless": True},
|
|
108
|
+
idle_timeout_seconds=120,
|
|
109
|
+
profile={"id": profile.id, "persistOnClose": True},
|
|
110
|
+
) as session:
|
|
111
|
+
await client.sessions.update(session.id, timeout_seconds=1200)
|
|
112
|
+
await client.sessions.touch(session.id)
|
|
113
|
+
# Connect and perform your task. The scope closes and releases the browser.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Nested `browser` and `profile` dictionaries use the HTTP field names shown above. `timeout_seconds` in an update is the new total lifetime from `started_at`, not extra seconds. It cannot decrease; extra budget is reserved atomically. Omit `idle_timeout_seconds` to leave it unchanged; pass `None` to disable idle expiry. GET, SSE and read-only Live do not renew idle activity.
|
|
117
|
+
|
|
118
|
+
Profiles support `create`, `list`, `get`, and `delete`. One unreleased session may use a Profile. Normal close/expiry saves cookies and localStorage when `persistOnClose` is true; inspect the ended session's `profile["saveStatus"]`. A failed save preserves the previous version. Profiles exclude downloads, page memory, and Agent history.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tabsail"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official async Python SDK for TabSail"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = ["httpx>=0.28.1,<0.29", "certifi>=2026.10.19"]
|
|
12
|
+
classifiers = ["Programming Language :: Python :: 3", "Typing :: Typed"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Documentation = "https://tabsail.com/docs/sdks/"
|
|
16
|
+
Homepage = "https://tabsail.com"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = ["pytest>=8.4,<9", "pytest-asyncio>=1.2,<2", "mypy>=1.18,<2", "ruff>=0.14,<0.15"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/tabsail"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
asyncio_mode = "auto"
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.mypy]
|
|
29
|
+
python_version = "3.12"
|
|
30
|
+
strict = true
|
|
31
|
+
files = ["src/tabsail"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
target-version = "py312"
|
|
35
|
+
line-length = 100
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""TabSail v1 asynchronous SDK; browser actions belong to your CDP client."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncTabSail, OwnedSession
|
|
4
|
+
from .errors import (
|
|
5
|
+
APIError,
|
|
6
|
+
CleanupError,
|
|
7
|
+
ConfigurationError,
|
|
8
|
+
HandoffCancelledError,
|
|
9
|
+
NetworkError,
|
|
10
|
+
ProtocolError,
|
|
11
|
+
RequestCancelledError,
|
|
12
|
+
RequestTimeoutError,
|
|
13
|
+
ResultUnknownError,
|
|
14
|
+
SDKError,
|
|
15
|
+
SessionTerminatedError,
|
|
16
|
+
WaitTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
BrowserSession,
|
|
20
|
+
Profile,
|
|
21
|
+
ConnectionGrant,
|
|
22
|
+
Controller,
|
|
23
|
+
FileOrigin,
|
|
24
|
+
FileStatus,
|
|
25
|
+
Handoff,
|
|
26
|
+
HandoffCheckpoint,
|
|
27
|
+
HandoffStatus,
|
|
28
|
+
HandoffSummary,
|
|
29
|
+
Page,
|
|
30
|
+
ResourceError,
|
|
31
|
+
SessionFile,
|
|
32
|
+
SessionStatus,
|
|
33
|
+
)
|
|
34
|
+
from .resources import DownloadStream
|
|
35
|
+
from .transport import RequestOptions
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AsyncTabSail",
|
|
40
|
+
"OwnedSession",
|
|
41
|
+
"RequestOptions",
|
|
42
|
+
"BrowserSession",
|
|
43
|
+
"Profile",
|
|
44
|
+
"ConnectionGrant",
|
|
45
|
+
"Handoff",
|
|
46
|
+
"HandoffSummary",
|
|
47
|
+
"HandoffCheckpoint",
|
|
48
|
+
"SessionFile",
|
|
49
|
+
"Page",
|
|
50
|
+
"ResourceError",
|
|
51
|
+
"SessionStatus",
|
|
52
|
+
"Controller",
|
|
53
|
+
"HandoffStatus",
|
|
54
|
+
"FileStatus",
|
|
55
|
+
"FileOrigin",
|
|
56
|
+
"DownloadStream",
|
|
57
|
+
"SDKError",
|
|
58
|
+
"APIError",
|
|
59
|
+
"ConfigurationError",
|
|
60
|
+
"NetworkError",
|
|
61
|
+
"RequestTimeoutError",
|
|
62
|
+
"WaitTimeoutError",
|
|
63
|
+
"ProtocolError",
|
|
64
|
+
"RequestCancelledError",
|
|
65
|
+
"ResultUnknownError",
|
|
66
|
+
"SessionTerminatedError",
|
|
67
|
+
"HandoffCancelledError",
|
|
68
|
+
"CleanupError",
|
|
69
|
+
]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Async HTTP client and ownership scoped session context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
APIError,
|
|
18
|
+
CleanupError,
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
ProtocolError,
|
|
21
|
+
ResultUnknownError,
|
|
22
|
+
WaitTimeoutError,
|
|
23
|
+
)
|
|
24
|
+
from .models import BrowserSession
|
|
25
|
+
from .resources import Profiles, Sessions, keyed
|
|
26
|
+
from .transport import RequestOptions, Transport
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncTabSail:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
base_url: str | None = None,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
request_timeout_ms: float = 15000,
|
|
36
|
+
max_retries: int = 0,
|
|
37
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
base_url = base_url if base_url is not None else os.environ.get("TABSAIL_BASE_URL")
|
|
40
|
+
api_key = api_key if api_key is not None else os.environ.get("TABSAIL_API_KEY")
|
|
41
|
+
if not base_url or not api_key:
|
|
42
|
+
raise ConfigurationError(
|
|
43
|
+
"Set base_url and api_key or TABSAIL_BASE_URL and TABSAIL_API_KEY."
|
|
44
|
+
)
|
|
45
|
+
self._transport = Transport(base_url, api_key, request_timeout_ms, max_retries, transport)
|
|
46
|
+
self.sessions = Sessions(self._transport)
|
|
47
|
+
self.profiles = Profiles(self._transport)
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return "<AsyncTabSail credentials=[REDACTED]>"
|
|
51
|
+
|
|
52
|
+
async def __aenter__(self) -> AsyncTabSail:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __aexit__(
|
|
56
|
+
self,
|
|
57
|
+
exc_type: type[BaseException] | None,
|
|
58
|
+
exc: BaseException | None,
|
|
59
|
+
tb: TracebackType | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
await self.aclose()
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None:
|
|
64
|
+
"""Close local HTTP connections; remote BrowserSessions are unchanged."""
|
|
65
|
+
await self._transport.aclose()
|
|
66
|
+
|
|
67
|
+
def with_session(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
timeout_seconds: int | None = None,
|
|
71
|
+
viewport: dict[str, int] | None = None,
|
|
72
|
+
metadata: dict[str, str] | None = None,
|
|
73
|
+
region: Literal["auto", "west", "asia"] | None = None,
|
|
74
|
+
options: RequestOptions | None = None,
|
|
75
|
+
ready_timeout_ms: float = 60000,
|
|
76
|
+
cleanup_timeout_ms: float = 30000,
|
|
77
|
+
poll_interval_ms: float = 500,
|
|
78
|
+
wait_for_capacity: bool = False,
|
|
79
|
+
idle_timeout_seconds: int | None = None,
|
|
80
|
+
browser: dict[str, Any] | None = None,
|
|
81
|
+
profile: dict[str, Any] | None = None,
|
|
82
|
+
) -> OwnedSession:
|
|
83
|
+
return OwnedSession(
|
|
84
|
+
self,
|
|
85
|
+
timeout_seconds,
|
|
86
|
+
viewport,
|
|
87
|
+
metadata,
|
|
88
|
+
region,
|
|
89
|
+
options,
|
|
90
|
+
ready_timeout_ms,
|
|
91
|
+
cleanup_timeout_ms,
|
|
92
|
+
poll_interval_ms,
|
|
93
|
+
wait_for_capacity,
|
|
94
|
+
idle_timeout_seconds,
|
|
95
|
+
browser,
|
|
96
|
+
profile,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OwnedSession:
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
client: AsyncTabSail,
|
|
104
|
+
timeout_seconds: int | None,
|
|
105
|
+
viewport: dict[str, int] | None,
|
|
106
|
+
metadata: dict[str, str] | None,
|
|
107
|
+
region: Literal["auto", "west", "asia"] | None,
|
|
108
|
+
options: RequestOptions | None,
|
|
109
|
+
ready_timeout_ms: float,
|
|
110
|
+
cleanup_timeout_ms: float,
|
|
111
|
+
poll_interval_ms: float,
|
|
112
|
+
wait_for_capacity: bool,
|
|
113
|
+
idle_timeout_seconds: int | None = None,
|
|
114
|
+
browser: dict[str, Any] | None = None,
|
|
115
|
+
profile: dict[str, Any] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
if any(
|
|
118
|
+
not math.isfinite(v) or v <= 0
|
|
119
|
+
for v in (ready_timeout_ms, cleanup_timeout_ms, poll_interval_ms)
|
|
120
|
+
):
|
|
121
|
+
raise ConfigurationError(
|
|
122
|
+
"Session wait and cleanup budgets must be finite positive milliseconds."
|
|
123
|
+
)
|
|
124
|
+
self._client = client
|
|
125
|
+
self._timeout_seconds = timeout_seconds
|
|
126
|
+
self._idle_timeout_seconds = idle_timeout_seconds
|
|
127
|
+
self._browser = deepcopy(browser)
|
|
128
|
+
self._profile = deepcopy(profile)
|
|
129
|
+
self._viewport = deepcopy(viewport)
|
|
130
|
+
self._metadata = deepcopy(metadata)
|
|
131
|
+
self._region = region
|
|
132
|
+
self._options = keyed(options)
|
|
133
|
+
if wait_for_capacity and self._options.timeout_ms is None:
|
|
134
|
+
self._options = replace(self._options, timeout_ms=300000)
|
|
135
|
+
self._wait_for_capacity = wait_for_capacity
|
|
136
|
+
self._ready_timeout_ms = ready_timeout_ms
|
|
137
|
+
self._cleanup_timeout_ms = cleanup_timeout_ms
|
|
138
|
+
self._poll_interval_ms = poll_interval_ms
|
|
139
|
+
self.session_id: str | None = None
|
|
140
|
+
self._unknown = False
|
|
141
|
+
self._entered = False
|
|
142
|
+
|
|
143
|
+
async def __aenter__(self) -> BrowserSession:
|
|
144
|
+
if self._entered:
|
|
145
|
+
raise ConfigurationError("An owned session context may only be entered once.")
|
|
146
|
+
self._entered = True
|
|
147
|
+
try:
|
|
148
|
+
# Cancellation here proves creation was not sent.
|
|
149
|
+
await asyncio.sleep(0)
|
|
150
|
+
started = time.monotonic()
|
|
151
|
+
capacity_attempt = 0
|
|
152
|
+
while True:
|
|
153
|
+
remaining_create = (
|
|
154
|
+
None if self._options.timeout_ms is None
|
|
155
|
+
else self._options.timeout_ms - (time.monotonic() - started) * 1000
|
|
156
|
+
)
|
|
157
|
+
if remaining_create is not None and remaining_create <= 0:
|
|
158
|
+
raise WaitTimeoutError("Browser capacity wait timed out.", code="CAPACITY_WAIT_TIMEOUT")
|
|
159
|
+
try:
|
|
160
|
+
session = await self._client.sessions.create(
|
|
161
|
+
timeout_seconds=self._timeout_seconds,
|
|
162
|
+
idle_timeout_seconds=self._idle_timeout_seconds,
|
|
163
|
+
browser=self._browser,
|
|
164
|
+
profile=self._profile,
|
|
165
|
+
viewport=self._viewport,
|
|
166
|
+
metadata=self._metadata,
|
|
167
|
+
region=self._region,
|
|
168
|
+
options=replace(self._options, timeout_ms=remaining_create),
|
|
169
|
+
)
|
|
170
|
+
break
|
|
171
|
+
except APIError as error:
|
|
172
|
+
if not (
|
|
173
|
+
self._wait_for_capacity and error.retryable and (
|
|
174
|
+
(error.status == 503 and error.code in ("NO_CAPACITY", "STARTUP_BUSY"))
|
|
175
|
+
or (error.status == 429 and error.code == "CREATE_RATE_LIMITED")
|
|
176
|
+
)
|
|
177
|
+
):
|
|
178
|
+
raise
|
|
179
|
+
assert self._options.timeout_ms is not None
|
|
180
|
+
left = self._options.timeout_ms - (time.monotonic() - started) * 1000
|
|
181
|
+
if left <= 0:
|
|
182
|
+
raise WaitTimeoutError("Browser capacity wait timed out.", code="CAPACITY_WAIT_TIMEOUT") from None
|
|
183
|
+
delay = self._client._transport._delay(capacity_attempt, error.retry_after, 10.0)
|
|
184
|
+
capacity_attempt += 1
|
|
185
|
+
await asyncio.sleep(min(left / 1000, delay))
|
|
186
|
+
if delay >= left / 1000:
|
|
187
|
+
raise WaitTimeoutError("Browser capacity wait timed out.", code="CAPACITY_WAIT_TIMEOUT") from None
|
|
188
|
+
self.session_id = session.id
|
|
189
|
+
remaining = self._ready_timeout_ms
|
|
190
|
+
if self._options.timeout_ms is not None:
|
|
191
|
+
remaining = min(
|
|
192
|
+
remaining, self._options.timeout_ms - (time.monotonic() - started) * 1000
|
|
193
|
+
)
|
|
194
|
+
if remaining <= 0:
|
|
195
|
+
raise WaitTimeoutError(
|
|
196
|
+
"The owned session readiness deadline elapsed.", session_id=session.id
|
|
197
|
+
)
|
|
198
|
+
return await self._client.sessions.wait_until_ready(
|
|
199
|
+
session.id,
|
|
200
|
+
timeout_ms=remaining,
|
|
201
|
+
poll_interval_ms=self._poll_interval_ms,
|
|
202
|
+
options=self._options,
|
|
203
|
+
)
|
|
204
|
+
except BaseException as primary:
|
|
205
|
+
self._unknown = isinstance(primary, (ResultUnknownError, ProtocolError)) or getattr(
|
|
206
|
+
primary, "result_unknown", False
|
|
207
|
+
)
|
|
208
|
+
if self.session_id is not None or self._unknown:
|
|
209
|
+
await self._finish(primary)
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
async def __aexit__(
|
|
213
|
+
self,
|
|
214
|
+
exc_type: type[BaseException] | None,
|
|
215
|
+
exc: BaseException | None,
|
|
216
|
+
tb: TracebackType | None,
|
|
217
|
+
) -> Literal[False]:
|
|
218
|
+
await self._finish(exc)
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
async def _cleanup(self) -> None:
|
|
222
|
+
# This deadline covers recovery, close, all requests/retries and released confirmation.
|
|
223
|
+
async with asyncio.timeout(self._cleanup_timeout_ms / 1000):
|
|
224
|
+
options = replace(
|
|
225
|
+
self._options,
|
|
226
|
+
timeout_ms=None,
|
|
227
|
+
max_retries=2,
|
|
228
|
+
request_timeout_ms=min(self._cleanup_timeout_ms, 5000),
|
|
229
|
+
)
|
|
230
|
+
if self.session_id is None:
|
|
231
|
+
recovered = await self._client.sessions.create(
|
|
232
|
+
timeout_seconds=self._timeout_seconds,
|
|
233
|
+
idle_timeout_seconds=self._idle_timeout_seconds,
|
|
234
|
+
browser=self._browser,
|
|
235
|
+
profile=self._profile,
|
|
236
|
+
viewport=self._viewport,
|
|
237
|
+
metadata=self._metadata,
|
|
238
|
+
region=self._region,
|
|
239
|
+
options=options,
|
|
240
|
+
)
|
|
241
|
+
self.session_id = recovered.id
|
|
242
|
+
await self._client.sessions.close(self.session_id, options=options)
|
|
243
|
+
await self._client.sessions.wait_until_released(
|
|
244
|
+
self.session_id,
|
|
245
|
+
timeout_ms=self._cleanup_timeout_ms,
|
|
246
|
+
poll_interval_ms=self._poll_interval_ms,
|
|
247
|
+
options=options,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def _finish(self, primary: BaseException | None) -> None:
|
|
251
|
+
task = asyncio.create_task(self._cleanup())
|
|
252
|
+
cancellation: asyncio.CancelledError | None = None
|
|
253
|
+
# Shield makes business cancellation independent of the bounded cleanup task.
|
|
254
|
+
while not task.done():
|
|
255
|
+
try:
|
|
256
|
+
await asyncio.shield(task)
|
|
257
|
+
except asyncio.CancelledError as error:
|
|
258
|
+
if task.done():
|
|
259
|
+
break
|
|
260
|
+
cancellation = cancellation or error
|
|
261
|
+
continue
|
|
262
|
+
except BaseException:
|
|
263
|
+
break
|
|
264
|
+
cleanup_error: BaseException | None
|
|
265
|
+
try:
|
|
266
|
+
task.result()
|
|
267
|
+
cleanup_error = None
|
|
268
|
+
except BaseException as error:
|
|
269
|
+
cleanup_error = CleanupError(
|
|
270
|
+
error,
|
|
271
|
+
session_id=self.session_id,
|
|
272
|
+
idempotency_key=self._options.idempotency_key or "",
|
|
273
|
+
)
|
|
274
|
+
original = primary or cancellation
|
|
275
|
+
if original is not None:
|
|
276
|
+
original.session_id = self.session_id # type: ignore[attr-defined]
|
|
277
|
+
original.idempotency_key = self._options.idempotency_key # type: ignore[attr-defined]
|
|
278
|
+
if cleanup_error is not None:
|
|
279
|
+
original.cleanup_error = cleanup_error # type: ignore[attr-defined]
|
|
280
|
+
original.add_note(
|
|
281
|
+
"Owned session cleanup failed; see cleanup_error and session_id/idempotency_key."
|
|
282
|
+
)
|
|
283
|
+
if primary is None:
|
|
284
|
+
raise original
|
|
285
|
+
elif cleanup_error is not None:
|
|
286
|
+
raise cleanup_error from None
|