typeless-sdk 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.
- typeless/__init__.py +58 -0
- typeless/_base_client.py +380 -0
- typeless/_constants.py +68 -0
- typeless/_retry.py +41 -0
- typeless/_transport/__init__.py +38 -0
- typeless/_transport/base.py +105 -0
- typeless/_transport/httpx_backend.py +146 -0
- typeless/_utils.py +53 -0
- typeless/_version.py +6 -0
- typeless/_ws.py +501 -0
- typeless/async_client.py +92 -0
- typeless/client.py +92 -0
- typeless/exceptions.py +251 -0
- typeless/models.py +218 -0
- typeless/py.typed +0 -0
- typeless_sdk-0.1.0.dist-info/METADATA +352 -0
- typeless_sdk-0.1.0.dist-info/RECORD +20 -0
- typeless_sdk-0.1.0.dist-info/WHEEL +4 -0
- typeless_sdk-0.1.0.dist-info/licenses/LICENSE +97 -0
- typeless_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +57 -0
typeless/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""official python sdk for the typeless transcript api"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from typeless._version import __version__
|
|
6
|
+
from typeless._ws import AsyncStreamConnection, StreamConnection
|
|
7
|
+
from typeless.async_client import AsyncTypelessClient
|
|
8
|
+
from typeless.client import TypelessClient
|
|
9
|
+
from typeless.exceptions import (
|
|
10
|
+
APIConnectionError,
|
|
11
|
+
APITimeoutError,
|
|
12
|
+
AudioError,
|
|
13
|
+
AudioFormatError,
|
|
14
|
+
AudioTooLargeError,
|
|
15
|
+
AudioTooLongError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
ConcurrentLimitError,
|
|
18
|
+
InsufficientBalanceError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
ServerError,
|
|
22
|
+
TypelessError,
|
|
23
|
+
)
|
|
24
|
+
from typeless.models import (
|
|
25
|
+
AudioReceived,
|
|
26
|
+
StreamMessage,
|
|
27
|
+
StreamResult,
|
|
28
|
+
TranscriptResult,
|
|
29
|
+
UsageInfo,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logging.getLogger("typeless").addHandler(logging.NullHandler())
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"__version__",
|
|
36
|
+
"AsyncTypelessClient",
|
|
37
|
+
"AsyncStreamConnection",
|
|
38
|
+
"TypelessClient",
|
|
39
|
+
"StreamConnection",
|
|
40
|
+
"TranscriptResult",
|
|
41
|
+
"UsageInfo",
|
|
42
|
+
"AudioReceived",
|
|
43
|
+
"StreamResult",
|
|
44
|
+
"StreamMessage",
|
|
45
|
+
"TypelessError",
|
|
46
|
+
"AuthenticationError",
|
|
47
|
+
"InvalidRequestError",
|
|
48
|
+
"RateLimitError",
|
|
49
|
+
"ConcurrentLimitError",
|
|
50
|
+
"InsufficientBalanceError",
|
|
51
|
+
"AudioError",
|
|
52
|
+
"AudioTooLongError",
|
|
53
|
+
"AudioFormatError",
|
|
54
|
+
"AudioTooLargeError",
|
|
55
|
+
"APIConnectionError",
|
|
56
|
+
"APITimeoutError",
|
|
57
|
+
"ServerError",
|
|
58
|
+
]
|
typeless/_base_client.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, BinaryIO
|
|
8
|
+
from urllib.parse import urlencode, urlsplit
|
|
9
|
+
|
|
10
|
+
from typeless._constants import (
|
|
11
|
+
DEFAULT_AUDIO_FILENAME,
|
|
12
|
+
DEFAULT_BASE_URL,
|
|
13
|
+
ENV_API_KEY,
|
|
14
|
+
ENV_BASE_URL,
|
|
15
|
+
STREAM_PATH,
|
|
16
|
+
TRANSCRIBE_PATH,
|
|
17
|
+
)
|
|
18
|
+
from typeless._retry import backoff_delay, is_retryable
|
|
19
|
+
from typeless._transport import (
|
|
20
|
+
AsyncTransport,
|
|
21
|
+
MultipartFile,
|
|
22
|
+
Request,
|
|
23
|
+
Response,
|
|
24
|
+
Transport,
|
|
25
|
+
TransportConnectionError,
|
|
26
|
+
TransportTimeoutError,
|
|
27
|
+
create_default_async_transport,
|
|
28
|
+
create_default_transport,
|
|
29
|
+
)
|
|
30
|
+
from typeless._utils import build_user_agent
|
|
31
|
+
from typeless.exceptions import (
|
|
32
|
+
APIConnectionError,
|
|
33
|
+
APITimeoutError,
|
|
34
|
+
AuthenticationError,
|
|
35
|
+
ServerError,
|
|
36
|
+
exception_from_response,
|
|
37
|
+
)
|
|
38
|
+
from typeless.models import TranscriptResult, parse_transcript_result
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("typeless")
|
|
41
|
+
|
|
42
|
+
AudioInput = str | Path | bytes | BinaryIO
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
46
|
+
"""ensure base_url has an http or https scheme for http and websocket endpoints"""
|
|
47
|
+
base_url = base_url.rstrip("/")
|
|
48
|
+
scheme = urlsplit(base_url).scheme
|
|
49
|
+
if not scheme:
|
|
50
|
+
return f"https://{base_url}"
|
|
51
|
+
if scheme in ("http", "https"):
|
|
52
|
+
return base_url
|
|
53
|
+
raise ValueError("base_url must use http:// or https://")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _filename_from_stream(stream: BinaryIO) -> str | None:
|
|
57
|
+
"""derive a base filename from a binary stream name attribute when available"""
|
|
58
|
+
name = getattr(stream, "name", None)
|
|
59
|
+
if isinstance(name, str) and name:
|
|
60
|
+
return Path(name).name
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_audio_part(audio: AudioInput) -> MultipartFile:
|
|
65
|
+
"""normalize an audio input into a multipart file part with a non empty filename"""
|
|
66
|
+
if isinstance(audio, str):
|
|
67
|
+
audio = Path(audio)
|
|
68
|
+
if isinstance(audio, Path):
|
|
69
|
+
return MultipartFile(
|
|
70
|
+
field_name="audio",
|
|
71
|
+
source=audio,
|
|
72
|
+
filename=audio.name or DEFAULT_AUDIO_FILENAME,
|
|
73
|
+
)
|
|
74
|
+
if isinstance(audio, bytes | bytearray):
|
|
75
|
+
return MultipartFile(
|
|
76
|
+
field_name="audio",
|
|
77
|
+
source=bytes(audio),
|
|
78
|
+
filename=DEFAULT_AUDIO_FILENAME,
|
|
79
|
+
)
|
|
80
|
+
return MultipartFile(
|
|
81
|
+
field_name="audio",
|
|
82
|
+
source=audio,
|
|
83
|
+
filename=_filename_from_stream(audio) or DEFAULT_AUDIO_FILENAME,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BaseClient:
|
|
88
|
+
"""shared configuration and pure request logic for the typeless clients"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
api_key: str | None,
|
|
94
|
+
base_url: str | None,
|
|
95
|
+
timeout: float,
|
|
96
|
+
max_retries: int,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""resolve the api key base url timeout and retry budget"""
|
|
99
|
+
resolved_key = api_key or os.environ.get(ENV_API_KEY)
|
|
100
|
+
if not resolved_key:
|
|
101
|
+
raise AuthenticationError(
|
|
102
|
+
"the api key must be set either by passing api_key "
|
|
103
|
+
"or by setting the TYPELESS_API_KEY environment variable"
|
|
104
|
+
)
|
|
105
|
+
self._api_key = resolved_key
|
|
106
|
+
self._base_url = _normalize_base_url(base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL)
|
|
107
|
+
self._timeout = timeout
|
|
108
|
+
self._max_retries = max_retries
|
|
109
|
+
|
|
110
|
+
def _transcribe_url(self) -> str:
|
|
111
|
+
"""build the absolute url for the transcription endpoint"""
|
|
112
|
+
return f"{self._base_url}{TRANSCRIBE_PATH}"
|
|
113
|
+
|
|
114
|
+
def _build_stream_url(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
model: str,
|
|
118
|
+
sample_rate: int,
|
|
119
|
+
encoding: str,
|
|
120
|
+
channels: int,
|
|
121
|
+
language: str | None,
|
|
122
|
+
) -> str:
|
|
123
|
+
"""build the websocket url with the connection query parameters"""
|
|
124
|
+
base = self._base_url
|
|
125
|
+
if base.startswith("https://"):
|
|
126
|
+
ws_base = "wss://" + base[len("https://") :]
|
|
127
|
+
elif base.startswith("http://"):
|
|
128
|
+
ws_base = "ws://" + base[len("http://") :]
|
|
129
|
+
else:
|
|
130
|
+
raise ValueError("base_url must use http:// or https://")
|
|
131
|
+
params = {
|
|
132
|
+
"token": self._api_key,
|
|
133
|
+
"model": model,
|
|
134
|
+
"sample_rate": str(sample_rate),
|
|
135
|
+
"encoding": encoding,
|
|
136
|
+
"channels": str(channels),
|
|
137
|
+
}
|
|
138
|
+
if language is not None:
|
|
139
|
+
params["language"] = language
|
|
140
|
+
return f"{ws_base}{STREAM_PATH}?{urlencode(params)}"
|
|
141
|
+
|
|
142
|
+
def _build_headers(self) -> dict[str, str]:
|
|
143
|
+
"""build the authorization and user agent headers"""
|
|
144
|
+
return {
|
|
145
|
+
"Authorization": f"Token {self._api_key}",
|
|
146
|
+
"User-Agent": build_user_agent(),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _build_transcribe_request(
|
|
150
|
+
self,
|
|
151
|
+
audio: AudioInput,
|
|
152
|
+
*,
|
|
153
|
+
model: str,
|
|
154
|
+
language: str | None,
|
|
155
|
+
) -> Request:
|
|
156
|
+
"""build the multipart request for a transcription call"""
|
|
157
|
+
data: dict[str, str] = {"model": model}
|
|
158
|
+
if language is not None:
|
|
159
|
+
data["language"] = language
|
|
160
|
+
return Request(
|
|
161
|
+
method="POST",
|
|
162
|
+
url=self._transcribe_url(),
|
|
163
|
+
headers=self._build_headers(),
|
|
164
|
+
data=data,
|
|
165
|
+
files=[_build_audio_part(audio)],
|
|
166
|
+
timeout=self._timeout,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _parse_success(self, response: Response[Any]) -> TranscriptResult:
|
|
170
|
+
"""parse a success response body into a transcript result"""
|
|
171
|
+
try:
|
|
172
|
+
body = response.json
|
|
173
|
+
except (ValueError, TypeError) as exc:
|
|
174
|
+
raise ServerError("the response body is not valid json") from exc
|
|
175
|
+
if not isinstance(body, dict):
|
|
176
|
+
raise ServerError("the response body is not a json object")
|
|
177
|
+
return parse_transcript_result(body)
|
|
178
|
+
|
|
179
|
+
def _retry_after_seconds(self, headers: Mapping[str, str]) -> int | None:
|
|
180
|
+
"""read the retry after header as a number of seconds"""
|
|
181
|
+
value = None
|
|
182
|
+
for key, header_value in headers.items():
|
|
183
|
+
if key.lower() == "retry-after":
|
|
184
|
+
value = header_value
|
|
185
|
+
break
|
|
186
|
+
if value is None:
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
return int(value)
|
|
190
|
+
except (ValueError, TypeError):
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
def _extract_error(
|
|
194
|
+
self,
|
|
195
|
+
response: Response[Any],
|
|
196
|
+
) -> tuple[str | None, str | None, dict[str, object] | None, str | None, int | None]:
|
|
197
|
+
"""extract the code message details request id and retry after from a non success response"""
|
|
198
|
+
try:
|
|
199
|
+
body = response.json
|
|
200
|
+
except (ValueError, TypeError):
|
|
201
|
+
body = {}
|
|
202
|
+
if not isinstance(body, dict):
|
|
203
|
+
body = {}
|
|
204
|
+
error = body.get("error")
|
|
205
|
+
error = error if isinstance(error, dict) else {}
|
|
206
|
+
code = error.get("code")
|
|
207
|
+
message = error.get("message")
|
|
208
|
+
details = error.get("details")
|
|
209
|
+
request_id = body.get("request_id")
|
|
210
|
+
return (
|
|
211
|
+
code if isinstance(code, str) else None,
|
|
212
|
+
message if isinstance(message, str) else None,
|
|
213
|
+
details if isinstance(details, dict) else None,
|
|
214
|
+
request_id if isinstance(request_id, str) else None,
|
|
215
|
+
self._retry_after_seconds(response.headers),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _can_retry(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
attempt: int,
|
|
222
|
+
request: Request,
|
|
223
|
+
code: str | None,
|
|
224
|
+
status_code: int,
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""decide whether a failed response can be retried"""
|
|
227
|
+
if attempt >= self._max_retries or not request.is_replayable:
|
|
228
|
+
return False
|
|
229
|
+
return is_retryable(code=code, status_code=status_code)
|
|
230
|
+
|
|
231
|
+
def _can_retry_connection(self, *, attempt: int, request: Request) -> bool:
|
|
232
|
+
"""decide whether a connection failure can be retried"""
|
|
233
|
+
return attempt < self._max_retries and request.is_replayable
|
|
234
|
+
|
|
235
|
+
def _log_send(self, request: Request) -> None:
|
|
236
|
+
"""log the outgoing request method url and form fields"""
|
|
237
|
+
logger.debug("sending request %s %s fields %s", request.method, request.url, request.data)
|
|
238
|
+
|
|
239
|
+
def _connection_retry_delay(self, exc: TransportConnectionError, *, attempt: int, request: Request) -> float:
|
|
240
|
+
"""return the backoff delay for a connection failure or raise when it cannot be retried"""
|
|
241
|
+
if not self._can_retry_connection(attempt=attempt, request=request):
|
|
242
|
+
raise APIConnectionError("the connection to the api failed") from exc
|
|
243
|
+
delay = backoff_delay(attempt)
|
|
244
|
+
logger.warning("retrying after connection error in %.2f seconds attempt %d", delay, attempt + 1)
|
|
245
|
+
return delay
|
|
246
|
+
|
|
247
|
+
def _response_retry_delay(self, response: Response[Any], *, attempt: int, request: Request) -> float | None:
|
|
248
|
+
"""return none for a final response or a backoff delay or raise the mapped error"""
|
|
249
|
+
logger.debug("received response status %d", response.status_code)
|
|
250
|
+
if response.status_code < 400:
|
|
251
|
+
return None
|
|
252
|
+
code, message, details, request_id, retry_after = self._extract_error(response)
|
|
253
|
+
if self._can_retry(
|
|
254
|
+
attempt=attempt,
|
|
255
|
+
request=request,
|
|
256
|
+
code=code,
|
|
257
|
+
status_code=response.status_code,
|
|
258
|
+
):
|
|
259
|
+
delay = backoff_delay(attempt, retry_after=retry_after)
|
|
260
|
+
logger.warning(
|
|
261
|
+
"retrying after status %d in %.2f seconds attempt %d",
|
|
262
|
+
response.status_code,
|
|
263
|
+
delay,
|
|
264
|
+
attempt + 1,
|
|
265
|
+
)
|
|
266
|
+
return delay
|
|
267
|
+
raise exception_from_response(
|
|
268
|
+
code=code,
|
|
269
|
+
message=message,
|
|
270
|
+
request_id=request_id,
|
|
271
|
+
details=details,
|
|
272
|
+
retry_after=retry_after,
|
|
273
|
+
status_code=response.status_code,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class SyncAPIClient(BaseClient):
|
|
278
|
+
"""synchronous client that owns a transport and runs the retry loop"""
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
*,
|
|
283
|
+
api_key: str | None,
|
|
284
|
+
base_url: str | None,
|
|
285
|
+
timeout: float,
|
|
286
|
+
max_retries: int,
|
|
287
|
+
transport: Transport | None = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""initialize the shared configuration and the synchronous transport"""
|
|
290
|
+
super().__init__(
|
|
291
|
+
api_key=api_key,
|
|
292
|
+
base_url=base_url,
|
|
293
|
+
timeout=timeout,
|
|
294
|
+
max_retries=max_retries,
|
|
295
|
+
)
|
|
296
|
+
self._transport = transport if transport is not None else create_default_transport(timeout=self._timeout)
|
|
297
|
+
|
|
298
|
+
def _request(self, request: Request) -> Response[Any]:
|
|
299
|
+
"""send a request and retry transient failures with exponential backoff"""
|
|
300
|
+
attempt = 0
|
|
301
|
+
while True:
|
|
302
|
+
self._log_send(request)
|
|
303
|
+
try:
|
|
304
|
+
response = self._transport.send(request)
|
|
305
|
+
except TransportTimeoutError as exc:
|
|
306
|
+
raise APITimeoutError("the request timed out") from exc
|
|
307
|
+
except TransportConnectionError as exc:
|
|
308
|
+
time.sleep(self._connection_retry_delay(exc, attempt=attempt, request=request))
|
|
309
|
+
attempt += 1
|
|
310
|
+
continue
|
|
311
|
+
delay = self._response_retry_delay(response, attempt=attempt, request=request)
|
|
312
|
+
if delay is None:
|
|
313
|
+
return response
|
|
314
|
+
time.sleep(delay)
|
|
315
|
+
attempt += 1
|
|
316
|
+
|
|
317
|
+
def close(self) -> None:
|
|
318
|
+
"""close the client and release the underlying connection pool"""
|
|
319
|
+
self._transport.close()
|
|
320
|
+
|
|
321
|
+
def __enter__(self) -> "SyncAPIClient":
|
|
322
|
+
"""enter the runtime context and return the client"""
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def __exit__(self, *args: object) -> None:
|
|
326
|
+
"""exit the runtime context and close the client"""
|
|
327
|
+
self.close()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class AsyncAPIClient(BaseClient):
|
|
331
|
+
"""asynchronous client that owns a transport and runs the retry loop"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
api_key: str | None,
|
|
337
|
+
base_url: str | None,
|
|
338
|
+
timeout: float,
|
|
339
|
+
max_retries: int,
|
|
340
|
+
transport: AsyncTransport | None = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""initialize the shared configuration and the asynchronous transport"""
|
|
343
|
+
super().__init__(
|
|
344
|
+
api_key=api_key,
|
|
345
|
+
base_url=base_url,
|
|
346
|
+
timeout=timeout,
|
|
347
|
+
max_retries=max_retries,
|
|
348
|
+
)
|
|
349
|
+
self._transport = transport if transport is not None else create_default_async_transport(timeout=self._timeout)
|
|
350
|
+
|
|
351
|
+
async def _request(self, request: Request) -> Response[Any]:
|
|
352
|
+
"""send a request and retry transient failures with exponential backoff"""
|
|
353
|
+
attempt = 0
|
|
354
|
+
while True:
|
|
355
|
+
self._log_send(request)
|
|
356
|
+
try:
|
|
357
|
+
response = await self._transport.send(request)
|
|
358
|
+
except TransportTimeoutError as exc:
|
|
359
|
+
raise APITimeoutError("the request timed out") from exc
|
|
360
|
+
except TransportConnectionError as exc:
|
|
361
|
+
await asyncio.sleep(self._connection_retry_delay(exc, attempt=attempt, request=request))
|
|
362
|
+
attempt += 1
|
|
363
|
+
continue
|
|
364
|
+
delay = self._response_retry_delay(response, attempt=attempt, request=request)
|
|
365
|
+
if delay is None:
|
|
366
|
+
return response
|
|
367
|
+
await asyncio.sleep(delay)
|
|
368
|
+
attempt += 1
|
|
369
|
+
|
|
370
|
+
async def close(self) -> None:
|
|
371
|
+
"""close the client and release the underlying connection pool"""
|
|
372
|
+
await self._transport.close()
|
|
373
|
+
|
|
374
|
+
async def __aenter__(self) -> "AsyncAPIClient":
|
|
375
|
+
"""enter the runtime context and return the client"""
|
|
376
|
+
return self
|
|
377
|
+
|
|
378
|
+
async def __aexit__(self, *args: object) -> None:
|
|
379
|
+
"""exit the runtime context and close the client"""
|
|
380
|
+
await self.close()
|
typeless/_constants.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# default configuration values and api error code constants
|
|
2
|
+
DEFAULT_BASE_URL = "https://api.typelessapi.com"
|
|
3
|
+
TRANSCRIBE_PATH = "/v1/transcribe"
|
|
4
|
+
STREAM_PATH = "/v1/transcribe/stream"
|
|
5
|
+
|
|
6
|
+
ENV_API_KEY = "TYPELESS_API_KEY"
|
|
7
|
+
ENV_BASE_URL = "TYPELESS_BASE_URL"
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
10
|
+
DEFAULT_MAX_RETRIES = 3
|
|
11
|
+
|
|
12
|
+
MAX_CONNECTIONS = 100
|
|
13
|
+
MAX_KEEPALIVE_CONNECTIONS = 20
|
|
14
|
+
|
|
15
|
+
DEFAULT_SAMPLE_RATE = 16000
|
|
16
|
+
DEFAULT_CHANNELS = 1
|
|
17
|
+
DEFAULT_ENCODING = "pcm16"
|
|
18
|
+
|
|
19
|
+
KEEP_ALIVE_INTERVAL_SECONDS = 20.0
|
|
20
|
+
WS_RECEIVE_POLL_SECONDS = 0.5
|
|
21
|
+
|
|
22
|
+
RETRY_BACKOFF_BASE_SECONDS = 1.0
|
|
23
|
+
RETRY_BACKOFF_FACTOR = 2.0
|
|
24
|
+
RETRY_MAX_DELAY_SECONDS = 60.0
|
|
25
|
+
RETRY_JITTER_RATIO = 0.2
|
|
26
|
+
|
|
27
|
+
RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
|
|
28
|
+
NON_RETRYABLE_STATUS_CODES = frozenset({400, 401, 402, 403, 413})
|
|
29
|
+
|
|
30
|
+
DEFAULT_AUDIO_FILENAME = "audio"
|
|
31
|
+
|
|
32
|
+
ERROR_INVALID_REQUEST = "INVALID_REQUEST"
|
|
33
|
+
ERROR_INVALID_API_KEY = "INVALID_API_KEY"
|
|
34
|
+
ERROR_MISSING_API_KEY = "MISSING_API_KEY"
|
|
35
|
+
ERROR_ACCOUNT_SUSPENDED = "ACCOUNT_SUSPENDED"
|
|
36
|
+
ERROR_RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
|
|
37
|
+
ERROR_CONCURRENT_LIMIT_EXCEEDED = "CONCURRENT_LIMIT_EXCEEDED"
|
|
38
|
+
ERROR_INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
|
|
39
|
+
ERROR_AUDIO_TOO_LONG = "AUDIO_TOO_LONG"
|
|
40
|
+
ERROR_AUDIO_FORMAT_UNSUPPORTED = "AUDIO_FORMAT_UNSUPPORTED"
|
|
41
|
+
ERROR_FILE_TOO_LARGE = "FILE_TOO_LARGE"
|
|
42
|
+
ERROR_TRANSCRIPTION_FAILED = "TRANSCRIPTION_FAILED"
|
|
43
|
+
ERROR_SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
|
|
44
|
+
ERROR_INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
45
|
+
ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
|
|
46
|
+
|
|
47
|
+
RETRYABLE_ERROR_CODES = frozenset(
|
|
48
|
+
{
|
|
49
|
+
ERROR_RATE_LIMIT_EXCEEDED,
|
|
50
|
+
ERROR_TRANSCRIPTION_FAILED,
|
|
51
|
+
ERROR_SERVICE_UNAVAILABLE,
|
|
52
|
+
ERROR_INTERNAL_ERROR,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
NON_RETRYABLE_ERROR_CODES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
ERROR_INVALID_REQUEST,
|
|
58
|
+
ERROR_INVALID_API_KEY,
|
|
59
|
+
ERROR_MISSING_API_KEY,
|
|
60
|
+
ERROR_ACCOUNT_SUSPENDED,
|
|
61
|
+
ERROR_CONCURRENT_LIMIT_EXCEEDED,
|
|
62
|
+
ERROR_INSUFFICIENT_BALANCE,
|
|
63
|
+
ERROR_AUDIO_TOO_LONG,
|
|
64
|
+
ERROR_AUDIO_FORMAT_UNSUPPORTED,
|
|
65
|
+
ERROR_FILE_TOO_LARGE,
|
|
66
|
+
ERROR_QUOTA_EXCEEDED,
|
|
67
|
+
}
|
|
68
|
+
)
|
typeless/_retry.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
from typeless._constants import (
|
|
5
|
+
NON_RETRYABLE_ERROR_CODES,
|
|
6
|
+
RETRY_BACKOFF_BASE_SECONDS,
|
|
7
|
+
RETRY_BACKOFF_FACTOR,
|
|
8
|
+
RETRY_JITTER_RATIO,
|
|
9
|
+
RETRY_MAX_DELAY_SECONDS,
|
|
10
|
+
RETRYABLE_ERROR_CODES,
|
|
11
|
+
RETRYABLE_STATUS_CODES,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_retryable(*, code: str | None, status_code: int | None) -> bool:
|
|
16
|
+
"""return whether a failed request should be retried"""
|
|
17
|
+
if code is not None:
|
|
18
|
+
if code in NON_RETRYABLE_ERROR_CODES:
|
|
19
|
+
return False
|
|
20
|
+
if code in RETRYABLE_ERROR_CODES:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
if status_code is not None:
|
|
24
|
+
return status_code in RETRYABLE_STATUS_CODES
|
|
25
|
+
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def backoff_delay(attempt: int, *, retry_after: float | None = None) -> float:
|
|
30
|
+
"""compute the delay before the next retry attempt"""
|
|
31
|
+
if retry_after is not None:
|
|
32
|
+
delay = float(retry_after)
|
|
33
|
+
if math.isfinite(delay) and delay >= 0:
|
|
34
|
+
return min(delay, RETRY_MAX_DELAY_SECONDS)
|
|
35
|
+
|
|
36
|
+
base = min(
|
|
37
|
+
RETRY_BACKOFF_BASE_SECONDS * (RETRY_BACKOFF_FACTOR**attempt),
|
|
38
|
+
RETRY_MAX_DELAY_SECONDS,
|
|
39
|
+
)
|
|
40
|
+
jitter = base * RETRY_JITTER_RATIO * random.random()
|
|
41
|
+
return min(base + jitter, RETRY_MAX_DELAY_SECONDS)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typeless._constants import DEFAULT_TIMEOUT_SECONDS
|
|
2
|
+
from typeless._transport.base import (
|
|
3
|
+
AsyncTransport,
|
|
4
|
+
MultipartFile,
|
|
5
|
+
Request,
|
|
6
|
+
Response,
|
|
7
|
+
Transport,
|
|
8
|
+
TransportConnectionError,
|
|
9
|
+
TransportError,
|
|
10
|
+
TransportTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
from typeless._transport.httpx_backend import HttpxAsyncTransport, HttpxTransport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_default_transport(*, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Transport:
|
|
16
|
+
"""create the default synchronous transport"""
|
|
17
|
+
return HttpxTransport(timeout=timeout)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_default_async_transport(*, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> AsyncTransport:
|
|
21
|
+
"""create the default asynchronous transport"""
|
|
22
|
+
return HttpxAsyncTransport(timeout=timeout)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AsyncTransport",
|
|
27
|
+
"HttpxAsyncTransport",
|
|
28
|
+
"HttpxTransport",
|
|
29
|
+
"MultipartFile",
|
|
30
|
+
"Request",
|
|
31
|
+
"Response",
|
|
32
|
+
"Transport",
|
|
33
|
+
"TransportConnectionError",
|
|
34
|
+
"TransportError",
|
|
35
|
+
"TransportTimeoutError",
|
|
36
|
+
"create_default_async_transport",
|
|
37
|
+
"create_default_transport",
|
|
38
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, BinaryIO, Generic, Protocol, TypeVar
|
|
9
|
+
|
|
10
|
+
RawResponseT = TypeVar("RawResponseT")
|
|
11
|
+
|
|
12
|
+
FileSource = bytes | Path | BinaryIO
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _source_is_replayable(source: FileSource) -> bool:
|
|
16
|
+
"""whether a multipart source can be sent again for a retry"""
|
|
17
|
+
if isinstance(source, bytes | Path):
|
|
18
|
+
return True
|
|
19
|
+
return source.seekable()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MultipartFile:
|
|
24
|
+
"""neutral description of a single multipart file part"""
|
|
25
|
+
|
|
26
|
+
field_name: str
|
|
27
|
+
source: FileSource
|
|
28
|
+
filename: str | None = None
|
|
29
|
+
content_type: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Request:
|
|
34
|
+
"""library agnostic http request"""
|
|
35
|
+
|
|
36
|
+
method: str
|
|
37
|
+
url: str
|
|
38
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
39
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
data: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
files: list[MultipartFile] = field(default_factory=list)
|
|
42
|
+
content: bytes | None = None
|
|
43
|
+
timeout: float | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_replayable(self) -> bool:
|
|
47
|
+
"""whether the request body can be resent for a retry"""
|
|
48
|
+
return all(_source_is_replayable(item.source) for item in self.files)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Response(Generic[RawResponseT]):
|
|
53
|
+
"""library agnostic http response"""
|
|
54
|
+
|
|
55
|
+
status_code: int
|
|
56
|
+
headers: Mapping[str, str]
|
|
57
|
+
content: bytes
|
|
58
|
+
raw_response: RawResponseT
|
|
59
|
+
|
|
60
|
+
@cached_property
|
|
61
|
+
def json(self) -> Any:
|
|
62
|
+
"""decode the response body as json"""
|
|
63
|
+
return json.loads(self.content)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Transport(Protocol):
|
|
67
|
+
"""synchronous transport interface"""
|
|
68
|
+
|
|
69
|
+
def send(self, request: Request) -> Response[Any]:
|
|
70
|
+
"""send a request and return a response"""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""release the underlying resources"""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AsyncTransport(Protocol):
|
|
79
|
+
"""asynchronous transport interface"""
|
|
80
|
+
|
|
81
|
+
async def send(self, request: Request) -> Response[Any]:
|
|
82
|
+
"""send a request and return a response"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
async def close(self) -> None:
|
|
86
|
+
"""release the underlying resources"""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TransportError(Exception):
|
|
91
|
+
"""base class for transport level failures"""
|
|
92
|
+
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TransportConnectionError(TransportError):
|
|
97
|
+
"""raised when the connection cannot be established"""
|
|
98
|
+
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TransportTimeoutError(TransportError):
|
|
103
|
+
"""raised when the request exceeds the configured timeout"""
|
|
104
|
+
|
|
105
|
+
pass
|