tabsail 0.1.0__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.
- tabsail/__init__.py +69 -0
- tabsail/client.py +286 -0
- tabsail/errors.py +125 -0
- tabsail/models.py +268 -0
- tabsail/py.typed +0 -0
- tabsail/resources.py +612 -0
- tabsail/transport.py +294 -0
- tabsail/updates.py +148 -0
- tabsail-0.1.0.dist-info/METADATA +131 -0
- tabsail-0.1.0.dist-info/RECORD +11 -0
- tabsail-0.1.0.dist-info/WHEEL +4 -0
tabsail/__init__.py
ADDED
|
@@ -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
|
+
]
|
tabsail/client.py
ADDED
|
@@ -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
|
tabsail/errors.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Safe errors: credentials and connection URLs never appear in SDK error messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def redact(value: str, api_key: str = "") -> str:
|
|
10
|
+
if api_key:
|
|
11
|
+
value = value.replace(api_key, "[REDACTED]")
|
|
12
|
+
value = re.sub(r"(?i)Bearer\s+\S+", "Bearer [REDACTED]", value)
|
|
13
|
+
value = re.sub(r"(?:https?|wss?)://[^\s<>\"']+", "[REDACTED URL]", value)
|
|
14
|
+
value = re.sub(
|
|
15
|
+
r"(?i)(?:api[_-]?key|authorization|token)\s*[=:]\s*[^\s,;]+", "[REDACTED]", value
|
|
16
|
+
)
|
|
17
|
+
return value[:512]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SDKError(Exception):
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
status: int | None = None,
|
|
26
|
+
code: str | None = None,
|
|
27
|
+
retryable: bool = False,
|
|
28
|
+
request_id: str | None = None,
|
|
29
|
+
retry_after: str | None = None,
|
|
30
|
+
session_id: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(redact(message))
|
|
33
|
+
self.status = status
|
|
34
|
+
self.code = code
|
|
35
|
+
self.retryable = retryable
|
|
36
|
+
self.request_id = request_id
|
|
37
|
+
self.retry_after = retry_after
|
|
38
|
+
self.session_id = session_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigurationError(SDKError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class APIError(SDKError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NetworkError(SDKError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RequestTimeoutError(SDKError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WaitTimeoutError(SDKError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProtocolError(SDKError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SessionTerminatedError(SDKError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HandoffCancelledError(SDKError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RequestCancelledError(asyncio.CancelledError):
|
|
74
|
+
"""Cancellation retains normal asyncio cancellation semantics."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
session_id: str | None = None,
|
|
80
|
+
idempotency_key: str | None = None,
|
|
81
|
+
result_unknown: bool = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__("TabSail request was cancelled.")
|
|
84
|
+
self.session_id = session_id
|
|
85
|
+
self.idempotency_key = idempotency_key
|
|
86
|
+
self.result_unknown = result_unknown
|
|
87
|
+
self.cleanup_error: BaseException | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ResultUnknownError(SDKError):
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
primary_error: SDKError,
|
|
94
|
+
*,
|
|
95
|
+
idempotency_key: str | None = None,
|
|
96
|
+
session_id: str | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
super().__init__(
|
|
99
|
+
"The request outcome is unknown. Do not blindly repeat this operation; "
|
|
100
|
+
"recover session creation with the same idempotency key or inspect session files.",
|
|
101
|
+
status=primary_error.status,
|
|
102
|
+
code=primary_error.code,
|
|
103
|
+
retryable=primary_error.retryable,
|
|
104
|
+
retry_after=primary_error.retry_after,
|
|
105
|
+
request_id=primary_error.request_id,
|
|
106
|
+
session_id=session_id,
|
|
107
|
+
)
|
|
108
|
+
self.primary_error = primary_error
|
|
109
|
+
self.idempotency_key = idempotency_key
|
|
110
|
+
self.result_unknown = True
|
|
111
|
+
self.cleanup_error: BaseException | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CleanupError(SDKError):
|
|
115
|
+
def __init__(
|
|
116
|
+
self, cleanup_error: BaseException, *, session_id: str | None, idempotency_key: str
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(
|
|
119
|
+
"Owned session cleanup could not confirm release. Recover using the same "
|
|
120
|
+
"idempotency key, close the session, and wait for released_at.",
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
)
|
|
123
|
+
self.cleanup_error = cleanup_error
|
|
124
|
+
self.idempotency_key = idempotency_key
|
|
125
|
+
self.result_unknown = session_id is None
|