mappa-conduit 0.1.2__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.
- mappa_conduit-0.1.2/PKG-INFO +36 -0
- mappa_conduit-0.1.2/README.md +26 -0
- mappa_conduit-0.1.2/pyproject.toml +59 -0
- mappa_conduit-0.1.2/src/conduit/__init__.py +87 -0
- mappa_conduit-0.1.2/src/conduit/_transport.py +308 -0
- mappa_conduit-0.1.2/src/conduit/client.py +1257 -0
- mappa_conduit-0.1.2/src/conduit/errors.py +248 -0
- mappa_conduit-0.1.2/src/conduit/models.py +575 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mappa-conduit
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Official Python SDK for the Conduit API
|
|
5
|
+
Author: drsh4dow
|
|
6
|
+
Author-email: drsh4dow <daniel.morettiv@gmail.com>
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Conduit Python SDK
|
|
12
|
+
|
|
13
|
+
Official Python SDK for the Conduit API.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install mappa-conduit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from conduit import Conduit
|
|
25
|
+
|
|
26
|
+
conduit = Conduit(api_key="sk_...")
|
|
27
|
+
|
|
28
|
+
receipt = conduit.reports.create(
|
|
29
|
+
source={"path": "./call.mp3"},
|
|
30
|
+
output={"template": "general_report"},
|
|
31
|
+
target={"strategy": "dominant"},
|
|
32
|
+
webhook={"url": "https://your-app.com/webhooks/conduit"},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
print(receipt.job_id)
|
|
36
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Conduit Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the Conduit API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mappa-conduit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from conduit import Conduit
|
|
15
|
+
|
|
16
|
+
conduit = Conduit(api_key="sk_...")
|
|
17
|
+
|
|
18
|
+
receipt = conduit.reports.create(
|
|
19
|
+
source={"path": "./call.mp3"},
|
|
20
|
+
output={"template": "general_report"},
|
|
21
|
+
target={"strategy": "dominant"},
|
|
22
|
+
webhook={"url": "https://your-app.com/webhooks/conduit"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
print(receipt.job_id)
|
|
26
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mappa-conduit"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "Official Python SDK for the Conduit API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "drsh4dow", email = "daniel.morettiv@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[tool.uv.build-backend]
|
|
19
|
+
module-name = "conduit"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pyright>=1.1.408",
|
|
24
|
+
"pytest>=9.0.2",
|
|
25
|
+
"ruff>=0.15.6",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.pyright]
|
|
29
|
+
pythonVersion = "3.12"
|
|
30
|
+
typeCheckingMode = "strict"
|
|
31
|
+
include = ["src", "tests"]
|
|
32
|
+
reportCallInDefaultInitializer = "error"
|
|
33
|
+
reportImplicitOverride = "error"
|
|
34
|
+
reportImportCycles = "error"
|
|
35
|
+
reportImplicitStringConcatenation = "error"
|
|
36
|
+
reportMissingTypeArgument = "error"
|
|
37
|
+
reportMissingTypeStubs = "error"
|
|
38
|
+
reportPrivateUsage = "error"
|
|
39
|
+
reportPropertyTypeMismatch = "error"
|
|
40
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
41
|
+
reportUnknownArgumentType = "error"
|
|
42
|
+
reportUnknownLambdaType = "error"
|
|
43
|
+
reportUnknownMemberType = "error"
|
|
44
|
+
reportUnknownParameterType = "error"
|
|
45
|
+
reportUnknownVariableType = "error"
|
|
46
|
+
reportUnnecessaryCast = "error"
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
target-version = "py312"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["ALL"]
|
|
56
|
+
ignore = ["A001", "COM812", "EM101", "EM102", "PLR0913", "PLR2004", "TRY003"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.per-file-ignores]
|
|
59
|
+
"tests/*.py" = ["S105"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Official Python SDK for the Conduit API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
Conduit,
|
|
5
|
+
MatchingJobReceipt,
|
|
6
|
+
MatchingRunHandle,
|
|
7
|
+
ReportJobReceipt,
|
|
8
|
+
ReportRunHandle,
|
|
9
|
+
)
|
|
10
|
+
from .errors import (
|
|
11
|
+
ApiError,
|
|
12
|
+
AuthError,
|
|
13
|
+
ConduitError,
|
|
14
|
+
InitializationError,
|
|
15
|
+
InsufficientCreditsError,
|
|
16
|
+
InvalidSourceError,
|
|
17
|
+
JobCanceledError,
|
|
18
|
+
JobFailedError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
RemoteFetchError,
|
|
21
|
+
RemoteFetchTimeoutError,
|
|
22
|
+
RemoteFetchTooLargeError,
|
|
23
|
+
RequestAbortedError,
|
|
24
|
+
SourceError,
|
|
25
|
+
StreamError,
|
|
26
|
+
UnsupportedRuntimeError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
WebhookVerificationError,
|
|
29
|
+
)
|
|
30
|
+
from .errors import (
|
|
31
|
+
TimeoutError as SDKTimeoutError,
|
|
32
|
+
)
|
|
33
|
+
from .models import (
|
|
34
|
+
Entity,
|
|
35
|
+
FileDeleteReceipt,
|
|
36
|
+
Job,
|
|
37
|
+
JobEvent,
|
|
38
|
+
ListEntitiesResponse,
|
|
39
|
+
ListFilesResponse,
|
|
40
|
+
MatchingAnalysisResponse,
|
|
41
|
+
MediaFile,
|
|
42
|
+
MediaObject,
|
|
43
|
+
Report,
|
|
44
|
+
RetentionLockResult,
|
|
45
|
+
WebhookEvent,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
TimeoutError = SDKTimeoutError
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ApiError",
|
|
52
|
+
"AuthError",
|
|
53
|
+
"Conduit",
|
|
54
|
+
"ConduitError",
|
|
55
|
+
"Entity",
|
|
56
|
+
"FileDeleteReceipt",
|
|
57
|
+
"InitializationError",
|
|
58
|
+
"InsufficientCreditsError",
|
|
59
|
+
"InvalidSourceError",
|
|
60
|
+
"Job",
|
|
61
|
+
"JobCanceledError",
|
|
62
|
+
"JobEvent",
|
|
63
|
+
"JobFailedError",
|
|
64
|
+
"ListEntitiesResponse",
|
|
65
|
+
"ListFilesResponse",
|
|
66
|
+
"MatchingAnalysisResponse",
|
|
67
|
+
"MatchingJobReceipt",
|
|
68
|
+
"MatchingRunHandle",
|
|
69
|
+
"MediaFile",
|
|
70
|
+
"MediaObject",
|
|
71
|
+
"RateLimitError",
|
|
72
|
+
"RemoteFetchError",
|
|
73
|
+
"RemoteFetchTimeoutError",
|
|
74
|
+
"RemoteFetchTooLargeError",
|
|
75
|
+
"Report",
|
|
76
|
+
"ReportJobReceipt",
|
|
77
|
+
"ReportRunHandle",
|
|
78
|
+
"RequestAbortedError",
|
|
79
|
+
"RetentionLockResult",
|
|
80
|
+
"SourceError",
|
|
81
|
+
"StreamError",
|
|
82
|
+
"TimeoutError",
|
|
83
|
+
"UnsupportedRuntimeError",
|
|
84
|
+
"ValidationError",
|
|
85
|
+
"WebhookEvent",
|
|
86
|
+
"WebhookVerificationError",
|
|
87
|
+
]
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""HTTP transport helpers for the Conduit Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from typing import TYPE_CHECKING, cast
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
ApiError,
|
|
17
|
+
AuthError,
|
|
18
|
+
ConduitError,
|
|
19
|
+
InsufficientCreditsError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
TimeoutError as ConduitTimeoutError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class TransportResponse:
|
|
33
|
+
"""Normalized HTTP response payload."""
|
|
34
|
+
|
|
35
|
+
data: object
|
|
36
|
+
status: int
|
|
37
|
+
request_id: str | None
|
|
38
|
+
headers: httpx.Headers
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Transport:
|
|
42
|
+
"""Small authenticated transport with retry support."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
api_key: str,
|
|
48
|
+
base_url: str,
|
|
49
|
+
timeout_ms: int,
|
|
50
|
+
max_retries: int,
|
|
51
|
+
user_agent: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize the transport."""
|
|
54
|
+
self._api_key = api_key
|
|
55
|
+
self._timeout_ms = timeout_ms
|
|
56
|
+
self._max_retries = max_retries
|
|
57
|
+
self._user_agent = user_agent
|
|
58
|
+
self._client = httpx.Client(
|
|
59
|
+
base_url=base_url.rstrip("/"),
|
|
60
|
+
follow_redirects=False,
|
|
61
|
+
timeout=timeout_ms / 1000,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
"""Close the underlying HTTP client."""
|
|
66
|
+
self._client.close()
|
|
67
|
+
|
|
68
|
+
def request(
|
|
69
|
+
self,
|
|
70
|
+
method: str,
|
|
71
|
+
path: str,
|
|
72
|
+
*,
|
|
73
|
+
json_body: object | None = None,
|
|
74
|
+
data: Mapping[str, str] | None = None,
|
|
75
|
+
files: Mapping[str, tuple[str, bytes, str | None]] | None = None,
|
|
76
|
+
query: Mapping[str, str] | None = None,
|
|
77
|
+
headers: Mapping[str, str] | None = None,
|
|
78
|
+
request_id: str | None = None,
|
|
79
|
+
idempotency_key: str | None = None,
|
|
80
|
+
retryable: bool = False,
|
|
81
|
+
timeout_ms: int | None = None,
|
|
82
|
+
) -> TransportResponse:
|
|
83
|
+
"""Issue an authenticated API request."""
|
|
84
|
+
resolved_request_id = request_id or f"req_{uuid4().hex}"
|
|
85
|
+
attempts = self._max_retries + 1 if retryable else 1
|
|
86
|
+
|
|
87
|
+
for attempt in range(1, attempts + 1):
|
|
88
|
+
try:
|
|
89
|
+
response = self._client.request(
|
|
90
|
+
method,
|
|
91
|
+
path,
|
|
92
|
+
params=query,
|
|
93
|
+
json=json_body,
|
|
94
|
+
data=data,
|
|
95
|
+
files=files,
|
|
96
|
+
headers=self._headers(
|
|
97
|
+
request_id=resolved_request_id,
|
|
98
|
+
idempotency_key=idempotency_key,
|
|
99
|
+
headers=headers,
|
|
100
|
+
),
|
|
101
|
+
timeout=(timeout_ms or self._timeout_ms) / 1000,
|
|
102
|
+
)
|
|
103
|
+
server_request_id = response.headers.get(
|
|
104
|
+
"x-request-id", resolved_request_id
|
|
105
|
+
)
|
|
106
|
+
if response.is_success:
|
|
107
|
+
return TransportResponse(
|
|
108
|
+
data=_read_response_data(response),
|
|
109
|
+
status=response.status_code,
|
|
110
|
+
request_id=server_request_id,
|
|
111
|
+
headers=response.headers,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
error = _coerce_api_error(response, server_request_id)
|
|
115
|
+
if attempt < attempts and _should_retry_error(error):
|
|
116
|
+
_sleep(_retry_after_ms(error, attempt))
|
|
117
|
+
continue
|
|
118
|
+
raise error
|
|
119
|
+
except httpx.TimeoutException as exc:
|
|
120
|
+
error = ConduitTimeoutError(
|
|
121
|
+
f"Request timed out after {timeout_ms or self._timeout_ms}ms",
|
|
122
|
+
code="timeout",
|
|
123
|
+
request_id=resolved_request_id,
|
|
124
|
+
cause=exc,
|
|
125
|
+
)
|
|
126
|
+
if attempt < attempts:
|
|
127
|
+
_sleep(_backoff_ms(attempt))
|
|
128
|
+
continue
|
|
129
|
+
raise error from exc
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
error = ConduitError(
|
|
132
|
+
"Request failed",
|
|
133
|
+
code="transport_error",
|
|
134
|
+
request_id=resolved_request_id,
|
|
135
|
+
cause=exc,
|
|
136
|
+
)
|
|
137
|
+
if attempt < attempts:
|
|
138
|
+
_sleep(_backoff_ms(attempt))
|
|
139
|
+
continue
|
|
140
|
+
raise error from exc
|
|
141
|
+
|
|
142
|
+
raise ConduitError("Unexpected transport exit", code="transport_error")
|
|
143
|
+
|
|
144
|
+
def _headers(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
request_id: str,
|
|
148
|
+
idempotency_key: str | None,
|
|
149
|
+
headers: Mapping[str, str] | None,
|
|
150
|
+
) -> dict[str, str]:
|
|
151
|
+
values = {
|
|
152
|
+
"Mappa-Api-Key": self._api_key,
|
|
153
|
+
"X-Request-Id": request_id,
|
|
154
|
+
}
|
|
155
|
+
if self._user_agent:
|
|
156
|
+
values["User-Agent"] = self._user_agent
|
|
157
|
+
if idempotency_key:
|
|
158
|
+
values["Idempotency-Key"] = idempotency_key
|
|
159
|
+
if headers:
|
|
160
|
+
values.update(headers)
|
|
161
|
+
return values
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _read_response_data(response: httpx.Response) -> object:
|
|
165
|
+
content_type = response.headers.get("content-type", "")
|
|
166
|
+
if "application/json" in content_type:
|
|
167
|
+
return response.json()
|
|
168
|
+
if response.content:
|
|
169
|
+
return response.text
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _coerce_api_error(response: httpx.Response, request_id: str | None) -> ApiError:
|
|
174
|
+
payload = _read_error_body(response)
|
|
175
|
+
message, code, details = _read_error_fields(payload)
|
|
176
|
+
|
|
177
|
+
if response.status_code in {401, 403}:
|
|
178
|
+
return AuthError(
|
|
179
|
+
message,
|
|
180
|
+
status=response.status_code,
|
|
181
|
+
code=code,
|
|
182
|
+
request_id=request_id,
|
|
183
|
+
details=details,
|
|
184
|
+
)
|
|
185
|
+
if response.status_code == 402:
|
|
186
|
+
required, available = _read_credit_details(details)
|
|
187
|
+
return InsufficientCreditsError(
|
|
188
|
+
message,
|
|
189
|
+
status=response.status_code,
|
|
190
|
+
code=code,
|
|
191
|
+
request_id=request_id,
|
|
192
|
+
details=details,
|
|
193
|
+
required=required,
|
|
194
|
+
available=available,
|
|
195
|
+
)
|
|
196
|
+
if response.status_code == 422:
|
|
197
|
+
return ValidationError(
|
|
198
|
+
message,
|
|
199
|
+
status=response.status_code,
|
|
200
|
+
code=code,
|
|
201
|
+
request_id=request_id,
|
|
202
|
+
details=details,
|
|
203
|
+
)
|
|
204
|
+
if response.status_code == 429:
|
|
205
|
+
return RateLimitError(
|
|
206
|
+
message,
|
|
207
|
+
status=response.status_code,
|
|
208
|
+
code=code,
|
|
209
|
+
request_id=request_id,
|
|
210
|
+
details=details,
|
|
211
|
+
retry_after_ms=_read_retry_after_ms(response.headers.get("retry-after")),
|
|
212
|
+
)
|
|
213
|
+
return ApiError(
|
|
214
|
+
message,
|
|
215
|
+
status=response.status_code,
|
|
216
|
+
code=code,
|
|
217
|
+
request_id=request_id,
|
|
218
|
+
details=details,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _read_error_body(response: httpx.Response) -> object | None:
|
|
223
|
+
try:
|
|
224
|
+
return response.json()
|
|
225
|
+
except ValueError:
|
|
226
|
+
if response.text:
|
|
227
|
+
return {"message": response.text}
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _read_error_fields(payload: object | None) -> tuple[str, str, object | None]:
|
|
232
|
+
default_message = "Request failed"
|
|
233
|
+
default_code = "api_error"
|
|
234
|
+
if not isinstance(payload, dict):
|
|
235
|
+
return default_message, default_code, payload
|
|
236
|
+
|
|
237
|
+
payload_map = cast("dict[str, object]", payload)
|
|
238
|
+
|
|
239
|
+
error = payload_map.get("error")
|
|
240
|
+
if isinstance(error, dict):
|
|
241
|
+
error_map = cast("dict[str, object]", error)
|
|
242
|
+
code = error_map.get("code")
|
|
243
|
+
message = error_map.get("message")
|
|
244
|
+
return (
|
|
245
|
+
message if isinstance(message, str) else default_message,
|
|
246
|
+
code if isinstance(code, str) else default_code,
|
|
247
|
+
error_map.get("details"),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
code = payload_map.get("code")
|
|
251
|
+
message = payload_map.get("message")
|
|
252
|
+
return (
|
|
253
|
+
message if isinstance(message, str) else default_message,
|
|
254
|
+
code if isinstance(code, str) else default_code,
|
|
255
|
+
payload_map,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _read_credit_details(details: object | None) -> tuple[int, int]:
|
|
260
|
+
if not isinstance(details, dict):
|
|
261
|
+
return 0, 0
|
|
262
|
+
details_map = cast("dict[str, object]", details)
|
|
263
|
+
required = details_map.get("required")
|
|
264
|
+
available = details_map.get("available")
|
|
265
|
+
return (
|
|
266
|
+
int(required) if isinstance(required, int | float) else 0,
|
|
267
|
+
int(available) if isinstance(available, int | float) else 0,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _read_retry_after_ms(value: str | None) -> int | None:
|
|
272
|
+
if value is None:
|
|
273
|
+
return None
|
|
274
|
+
if value.isdigit():
|
|
275
|
+
return int(value) * 1000
|
|
276
|
+
try:
|
|
277
|
+
retry_at = parsedate_to_datetime(value)
|
|
278
|
+
except (TypeError, ValueError):
|
|
279
|
+
return None
|
|
280
|
+
now = datetime.now(tz=UTC)
|
|
281
|
+
return max(0, int((retry_at - now).total_seconds() * 1000))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _should_retry_error(error: Exception) -> bool:
|
|
285
|
+
if isinstance(error, RateLimitError):
|
|
286
|
+
return True
|
|
287
|
+
if isinstance(error, ApiError):
|
|
288
|
+
return error.status >= 500
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _retry_after_ms(error: Exception, attempt: int) -> int:
|
|
293
|
+
if isinstance(error, RateLimitError) and error.retry_after_ms is not None:
|
|
294
|
+
return error.retry_after_ms
|
|
295
|
+
return _backoff_ms(attempt)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _backoff_ms(attempt: int) -> int:
|
|
299
|
+
base = min(500 * (2**attempt), 4000)
|
|
300
|
+
jitter = secrets.randbelow(max(base // 2, 1))
|
|
301
|
+
return base + jitter
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _sleep(duration_ms: int) -> None:
|
|
305
|
+
time.sleep(duration_ms / 1000)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
__all__ = ["Transport", "TransportResponse"]
|