redblack-python-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.
- redblack/__init__.py +105 -0
- redblack/_generated/__init__.py +39 -0
- redblack/_generated/contract.py +212 -0
- redblack/_generated/operation_specs.py +5115 -0
- redblack/_version.py +4 -0
- redblack/auth.py +352 -0
- redblack/client.py +512 -0
- redblack/config.py +576 -0
- redblack/downloads.py +410 -0
- redblack/errors.py +483 -0
- redblack/models/__init__.py +105 -0
- redblack/models/base.py +150 -0
- redblack/models/generated.py +11736 -0
- redblack/odata.py +75 -0
- redblack/pagination.py +184 -0
- redblack/polling.py +163 -0
- redblack/py.typed +1 -0
- redblack/raw.py +102 -0
- redblack/resources/__init__.py +65 -0
- redblack/resources/_base.py +212 -0
- redblack/resources/_collections.py +80 -0
- redblack/resources/_exports.py +215 -0
- redblack/resources/accounts.py +132 -0
- redblack/resources/cash_actions.py +260 -0
- redblack/resources/equivalents.py +144 -0
- redblack/resources/households.py +138 -0
- redblack/resources/orders.py +118 -0
- redblack/resources/restrictions.py +146 -0
- redblack/resources/review_sets.py +889 -0
- redblack/resources/securities.py +175 -0
- redblack/resources/tasks.py +291 -0
- redblack/resources/users.py +88 -0
- redblack/response.py +118 -0
- redblack/transport.py +606 -0
- redblack_python_sdk-0.1.0.dist-info/METADATA +151 -0
- redblack_python_sdk-0.1.0.dist-info/RECORD +38 -0
- redblack_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- redblack_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
redblack/transport.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""Synchronous HTTPX transport with conservative auth and retry behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import email.utils
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from math import isfinite
|
|
12
|
+
from typing import Any, Protocol, TypeAlias, runtime_checkable
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .auth import TokenProvider
|
|
19
|
+
from .config import RedBlackConfig
|
|
20
|
+
from .errors import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
RequestTimeoutError,
|
|
24
|
+
TransportError,
|
|
25
|
+
api_error_from_response,
|
|
26
|
+
extract_request_id,
|
|
27
|
+
safe_response_headers,
|
|
28
|
+
)
|
|
29
|
+
from .response import ApiResponse, HttpMetadata
|
|
30
|
+
|
|
31
|
+
QueryParams: TypeAlias = Any
|
|
32
|
+
RequestHeaders: TypeAlias = Mapping[str, str] | None
|
|
33
|
+
|
|
34
|
+
_SAFE_READ_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
35
|
+
_PROTECTED_HEADERS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"accept-version",
|
|
38
|
+
"authorization",
|
|
39
|
+
"client-id",
|
|
40
|
+
"cookie",
|
|
41
|
+
"host",
|
|
42
|
+
"proxy-authorization",
|
|
43
|
+
"x-api-key",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class HttpTransport(Protocol):
|
|
50
|
+
"""Transport interface consumed by the hand-written client layer."""
|
|
51
|
+
|
|
52
|
+
def request(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
path: str,
|
|
56
|
+
*,
|
|
57
|
+
params: QueryParams = None,
|
|
58
|
+
headers: RequestHeaders = None,
|
|
59
|
+
json: Any = None,
|
|
60
|
+
content: bytes | str | None = None,
|
|
61
|
+
data: Any = None,
|
|
62
|
+
expected_statuses: IterableStatus | None = None,
|
|
63
|
+
authenticated: bool = True,
|
|
64
|
+
allow_retry: bool | None = None,
|
|
65
|
+
operation_id: str | None = None,
|
|
66
|
+
api_version: str | None = None,
|
|
67
|
+
timeout_budget: float | None = None,
|
|
68
|
+
) -> ApiResponse[bytes]:
|
|
69
|
+
"""Send one same-origin API request."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
"""Release owned resources."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
IterableStatus: TypeAlias = Sequence[int] | set[int] | frozenset[int]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HttpxTransport:
|
|
80
|
+
"""HTTPX-backed, thread-shareable RedBlack request transport."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
config: RedBlackConfig,
|
|
85
|
+
token_provider: TokenProvider | None,
|
|
86
|
+
*,
|
|
87
|
+
client: httpx.Client | None = None,
|
|
88
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
89
|
+
random_value: Callable[[], float] = random.random,
|
|
90
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
91
|
+
) -> None:
|
|
92
|
+
if token_provider is not None and not callable(getattr(token_provider, "get_token", None)):
|
|
93
|
+
raise ConfigurationError("token_provider must define get_token()")
|
|
94
|
+
provider_client_id = (
|
|
95
|
+
getattr(token_provider, "client_id", None) if token_provider is not None else None
|
|
96
|
+
)
|
|
97
|
+
if provider_client_id is not None and provider_client_id != config.client_id:
|
|
98
|
+
raise ConfigurationError(
|
|
99
|
+
"token provider client_id does not match the authoritative client_id"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self._config = config
|
|
103
|
+
self._token_provider = token_provider
|
|
104
|
+
self._sleep = sleep
|
|
105
|
+
self._random_value = random_value
|
|
106
|
+
self._monotonic = monotonic
|
|
107
|
+
self._timeout = httpx.Timeout(
|
|
108
|
+
connect=config.timeout.connect,
|
|
109
|
+
read=config.timeout.read,
|
|
110
|
+
write=config.timeout.write,
|
|
111
|
+
pool=config.timeout.pool,
|
|
112
|
+
)
|
|
113
|
+
self._owns_client = client is None
|
|
114
|
+
if client is None:
|
|
115
|
+
client = httpx.Client(
|
|
116
|
+
timeout=self._timeout,
|
|
117
|
+
verify=config.verify,
|
|
118
|
+
proxy=config.proxy,
|
|
119
|
+
trust_env=config.trust_env,
|
|
120
|
+
follow_redirects=False,
|
|
121
|
+
headers={"User-Agent": f"redblack-python-sdk/{__version__}"},
|
|
122
|
+
)
|
|
123
|
+
self._client = client
|
|
124
|
+
self._state_lock = threading.Lock()
|
|
125
|
+
self._closed = False
|
|
126
|
+
|
|
127
|
+
def request(
|
|
128
|
+
self,
|
|
129
|
+
method: str,
|
|
130
|
+
path: str,
|
|
131
|
+
*,
|
|
132
|
+
params: QueryParams = None,
|
|
133
|
+
headers: RequestHeaders = None,
|
|
134
|
+
json: Any = None,
|
|
135
|
+
content: bytes | str | None = None,
|
|
136
|
+
data: Any = None,
|
|
137
|
+
expected_statuses: IterableStatus | None = None,
|
|
138
|
+
authenticated: bool = True,
|
|
139
|
+
allow_retry: bool | None = None,
|
|
140
|
+
operation_id: str | None = None,
|
|
141
|
+
api_version: str | None = None,
|
|
142
|
+
timeout_budget: float | None = None,
|
|
143
|
+
) -> ApiResponse[bytes]:
|
|
144
|
+
"""Send a RedBlack API request.
|
|
145
|
+
|
|
146
|
+
Retries and authentication replay are restricted to safe reads. Passing
|
|
147
|
+
``allow_retry=True`` never turns a mutation into a replayable request;
|
|
148
|
+
it only enables the normal read policy.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
normalized_method = _validate_method(method)
|
|
152
|
+
normalized_path = _validate_relative_path(path)
|
|
153
|
+
normalized_budget = _validate_timeout_budget(timeout_budget)
|
|
154
|
+
context_path = normalized_path
|
|
155
|
+
if operation_id is not None:
|
|
156
|
+
if (
|
|
157
|
+
not isinstance(operation_id, str)
|
|
158
|
+
or not operation_id.strip()
|
|
159
|
+
or any(ord(character) < 32 for character in operation_id)
|
|
160
|
+
):
|
|
161
|
+
raise ConfigurationError("operation_id must be a safe non-empty string")
|
|
162
|
+
context_path = operation_id
|
|
163
|
+
supplied_headers = _validate_headers(headers)
|
|
164
|
+
if sum(value is not None for value in (json, content, data)) > 1:
|
|
165
|
+
raise ConfigurationError("pass only one of json, content, or data")
|
|
166
|
+
if not isinstance(authenticated, bool):
|
|
167
|
+
raise ConfigurationError("authenticated must be a bool")
|
|
168
|
+
if allow_retry is not None and not isinstance(allow_retry, bool):
|
|
169
|
+
raise ConfigurationError("allow_retry must be a bool or None")
|
|
170
|
+
if api_version is not None and (
|
|
171
|
+
not isinstance(api_version, str)
|
|
172
|
+
or not api_version.strip()
|
|
173
|
+
or "\r" in api_version
|
|
174
|
+
or "\n" in api_version
|
|
175
|
+
):
|
|
176
|
+
raise ConfigurationError("api_version override must be a non-empty header value")
|
|
177
|
+
|
|
178
|
+
expected = frozenset(expected_statuses) if expected_statuses is not None else None
|
|
179
|
+
if expected is not None and (
|
|
180
|
+
not expected or any(not isinstance(item, int) for item in expected)
|
|
181
|
+
):
|
|
182
|
+
raise ConfigurationError("expected_statuses must contain HTTP status integers")
|
|
183
|
+
|
|
184
|
+
with self._state_lock:
|
|
185
|
+
if self._closed:
|
|
186
|
+
raise TransportError(
|
|
187
|
+
"transport is closed",
|
|
188
|
+
method=normalized_method,
|
|
189
|
+
path=context_path,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
retry_reads = normalized_method in _SAFE_READ_METHODS and allow_retry is not False
|
|
193
|
+
retry_attempt = 1
|
|
194
|
+
sends = 0
|
|
195
|
+
auth_replayed = False
|
|
196
|
+
started = self._monotonic()
|
|
197
|
+
deadline = None if normalized_budget is None else started + normalized_budget
|
|
198
|
+
token: str | None = None
|
|
199
|
+
prepared_token: str | None = None
|
|
200
|
+
|
|
201
|
+
while True:
|
|
202
|
+
self._remaining_budget(
|
|
203
|
+
deadline,
|
|
204
|
+
method=normalized_method,
|
|
205
|
+
path=context_path,
|
|
206
|
+
)
|
|
207
|
+
if authenticated:
|
|
208
|
+
if prepared_token is not None:
|
|
209
|
+
token = prepared_token
|
|
210
|
+
prepared_token = None
|
|
211
|
+
else:
|
|
212
|
+
try:
|
|
213
|
+
token = self._get_token()
|
|
214
|
+
except Exception:
|
|
215
|
+
self._remaining_budget(
|
|
216
|
+
deadline,
|
|
217
|
+
method=normalized_method,
|
|
218
|
+
path=context_path,
|
|
219
|
+
)
|
|
220
|
+
raise
|
|
221
|
+
self._remaining_budget(
|
|
222
|
+
deadline,
|
|
223
|
+
method=normalized_method,
|
|
224
|
+
path=context_path,
|
|
225
|
+
)
|
|
226
|
+
request_headers = self._common_headers(
|
|
227
|
+
token=token,
|
|
228
|
+
authenticated=authenticated,
|
|
229
|
+
api_version=api_version,
|
|
230
|
+
)
|
|
231
|
+
request_headers.update(supplied_headers)
|
|
232
|
+
request_timeout = self._timeout_for_budget(
|
|
233
|
+
deadline,
|
|
234
|
+
method=normalized_method,
|
|
235
|
+
path=context_path,
|
|
236
|
+
)
|
|
237
|
+
sends += 1
|
|
238
|
+
try:
|
|
239
|
+
response = self._client.request(
|
|
240
|
+
normalized_method,
|
|
241
|
+
f"{self._config.base_url}{normalized_path}",
|
|
242
|
+
params=params,
|
|
243
|
+
headers=request_headers,
|
|
244
|
+
json=json,
|
|
245
|
+
content=content,
|
|
246
|
+
data=data,
|
|
247
|
+
timeout=request_timeout,
|
|
248
|
+
follow_redirects=False,
|
|
249
|
+
)
|
|
250
|
+
except httpx.TimeoutException as exc:
|
|
251
|
+
if self._budget_expired(deadline):
|
|
252
|
+
raise self._timeout_error(
|
|
253
|
+
method=normalized_method,
|
|
254
|
+
path=context_path,
|
|
255
|
+
) from exc
|
|
256
|
+
if retry_reads and retry_attempt < self._config.retry.max_attempts:
|
|
257
|
+
self._sleep_for_retry(
|
|
258
|
+
self._backoff_delay(retry_attempt, None),
|
|
259
|
+
deadline,
|
|
260
|
+
method=normalized_method,
|
|
261
|
+
path=context_path,
|
|
262
|
+
)
|
|
263
|
+
retry_attempt += 1
|
|
264
|
+
continue
|
|
265
|
+
raise RequestTimeoutError(
|
|
266
|
+
"RedBlack API request timed out",
|
|
267
|
+
method=normalized_method,
|
|
268
|
+
path=context_path,
|
|
269
|
+
) from exc
|
|
270
|
+
except httpx.TransportError as exc:
|
|
271
|
+
if self._budget_expired(deadline):
|
|
272
|
+
raise self._timeout_error(
|
|
273
|
+
method=normalized_method,
|
|
274
|
+
path=context_path,
|
|
275
|
+
) from exc
|
|
276
|
+
if retry_reads and retry_attempt < self._config.retry.max_attempts:
|
|
277
|
+
self._sleep_for_retry(
|
|
278
|
+
self._backoff_delay(retry_attempt, None),
|
|
279
|
+
deadline,
|
|
280
|
+
method=normalized_method,
|
|
281
|
+
path=context_path,
|
|
282
|
+
)
|
|
283
|
+
retry_attempt += 1
|
|
284
|
+
continue
|
|
285
|
+
raise TransportError(
|
|
286
|
+
"RedBlack API request failed before receiving a response",
|
|
287
|
+
method=normalized_method,
|
|
288
|
+
path=context_path,
|
|
289
|
+
) from exc
|
|
290
|
+
|
|
291
|
+
self._remaining_budget(
|
|
292
|
+
deadline,
|
|
293
|
+
method=normalized_method,
|
|
294
|
+
path=context_path,
|
|
295
|
+
)
|
|
296
|
+
if authenticated and response.status_code == 401:
|
|
297
|
+
if retry_reads and not auth_replayed:
|
|
298
|
+
try:
|
|
299
|
+
refreshed = self._refresh_token(token)
|
|
300
|
+
except Exception:
|
|
301
|
+
self._remaining_budget(
|
|
302
|
+
deadline,
|
|
303
|
+
method=normalized_method,
|
|
304
|
+
path=context_path,
|
|
305
|
+
)
|
|
306
|
+
raise
|
|
307
|
+
self._remaining_budget(
|
|
308
|
+
deadline,
|
|
309
|
+
method=normalized_method,
|
|
310
|
+
path=context_path,
|
|
311
|
+
)
|
|
312
|
+
if refreshed is not None:
|
|
313
|
+
auth_replayed = True
|
|
314
|
+
prepared_token = refreshed
|
|
315
|
+
continue
|
|
316
|
+
else:
|
|
317
|
+
self._invalidate_token(token)
|
|
318
|
+
raise api_error_from_response(response, method=normalized_method, path=context_path)
|
|
319
|
+
|
|
320
|
+
if authenticated and response.status_code == 403:
|
|
321
|
+
known_expired = self._token_is_expired(token)
|
|
322
|
+
if known_expired:
|
|
323
|
+
if retry_reads and not auth_replayed:
|
|
324
|
+
try:
|
|
325
|
+
refreshed = self._refresh_token(token)
|
|
326
|
+
except Exception:
|
|
327
|
+
self._remaining_budget(
|
|
328
|
+
deadline,
|
|
329
|
+
method=normalized_method,
|
|
330
|
+
path=context_path,
|
|
331
|
+
)
|
|
332
|
+
raise
|
|
333
|
+
self._remaining_budget(
|
|
334
|
+
deadline,
|
|
335
|
+
method=normalized_method,
|
|
336
|
+
path=context_path,
|
|
337
|
+
)
|
|
338
|
+
if refreshed is not None:
|
|
339
|
+
auth_replayed = True
|
|
340
|
+
prepared_token = refreshed
|
|
341
|
+
continue
|
|
342
|
+
else:
|
|
343
|
+
self._invalidate_token(token)
|
|
344
|
+
# A bare 403 is a permission error. It is never proof of expiry.
|
|
345
|
+
raise api_error_from_response(response, method=normalized_method, path=context_path)
|
|
346
|
+
|
|
347
|
+
if (
|
|
348
|
+
retry_reads
|
|
349
|
+
and response.status_code in self._config.retry.retry_statuses
|
|
350
|
+
and retry_attempt < self._config.retry.max_attempts
|
|
351
|
+
):
|
|
352
|
+
self._sleep_for_retry(
|
|
353
|
+
self._backoff_delay(retry_attempt, response.headers.get("Retry-After")),
|
|
354
|
+
deadline,
|
|
355
|
+
method=normalized_method,
|
|
356
|
+
path=context_path,
|
|
357
|
+
)
|
|
358
|
+
retry_attempt += 1
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
if response.status_code >= 400 or not _is_expected(response.status_code, expected):
|
|
362
|
+
raise api_error_from_response(response, method=normalized_method, path=context_path)
|
|
363
|
+
|
|
364
|
+
raw = response.content
|
|
365
|
+
metadata = HttpMetadata(
|
|
366
|
+
status_code=response.status_code,
|
|
367
|
+
method=normalized_method,
|
|
368
|
+
url=str(response.url),
|
|
369
|
+
headers=safe_response_headers(response.headers),
|
|
370
|
+
request_id=extract_request_id(response.headers),
|
|
371
|
+
etag=response.headers.get("ETag"),
|
|
372
|
+
location=response.headers.get("Location"),
|
|
373
|
+
attempts=sends,
|
|
374
|
+
elapsed=self._monotonic() - started,
|
|
375
|
+
)
|
|
376
|
+
return ApiResponse(data=raw, http=metadata, raw=raw)
|
|
377
|
+
|
|
378
|
+
def close(self) -> None:
|
|
379
|
+
with self._state_lock:
|
|
380
|
+
if self._closed:
|
|
381
|
+
return
|
|
382
|
+
self._closed = True
|
|
383
|
+
if self._owns_client:
|
|
384
|
+
self._client.close()
|
|
385
|
+
|
|
386
|
+
def __enter__(self) -> HttpxTransport:
|
|
387
|
+
return self
|
|
388
|
+
|
|
389
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
390
|
+
self.close()
|
|
391
|
+
|
|
392
|
+
def __repr__(self) -> str:
|
|
393
|
+
return (
|
|
394
|
+
"HttpxTransport("
|
|
395
|
+
f"base_url={self._config.base_url!r}, "
|
|
396
|
+
f"authenticated={self._token_provider is not None}, "
|
|
397
|
+
f"owns_client={self._owns_client!r}, closed={self._closed!r})"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def _common_headers(
|
|
401
|
+
self,
|
|
402
|
+
*,
|
|
403
|
+
token: str | None,
|
|
404
|
+
authenticated: bool,
|
|
405
|
+
api_version: str | None,
|
|
406
|
+
) -> dict[str, str]:
|
|
407
|
+
headers = {
|
|
408
|
+
"Accept": "application/json",
|
|
409
|
+
"Accept-Version": api_version or self._config.api_version,
|
|
410
|
+
"Client-ID": self._config.client_id,
|
|
411
|
+
"X-Api-Key": self._config.api_key,
|
|
412
|
+
"User-Agent": f"redblack-python-sdk/{__version__}",
|
|
413
|
+
}
|
|
414
|
+
if authenticated:
|
|
415
|
+
if token is None:
|
|
416
|
+
raise AuthenticationError("token provider returned no access token")
|
|
417
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
418
|
+
return headers
|
|
419
|
+
|
|
420
|
+
def _get_token(self, *, force_refresh: bool = False) -> str:
|
|
421
|
+
if self._token_provider is None:
|
|
422
|
+
raise AuthenticationError("this request requires a configured token provider")
|
|
423
|
+
token = self._token_provider.get_token(force_refresh=force_refresh)
|
|
424
|
+
return self._validate_token(token)
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _validate_token(token: object) -> str:
|
|
428
|
+
if not isinstance(token, str) or not token.strip():
|
|
429
|
+
raise AuthenticationError("token provider returned an invalid access token")
|
|
430
|
+
if "\r" in token or "\n" in token:
|
|
431
|
+
raise AuthenticationError("token provider returned an invalid access token")
|
|
432
|
+
return token
|
|
433
|
+
|
|
434
|
+
def _refresh_token(self, failed_token: str | None) -> str | None:
|
|
435
|
+
"""Refresh explicitly and decline a futile same-token replay."""
|
|
436
|
+
|
|
437
|
+
refresh_after_failure = getattr(self._token_provider, "refresh_after_failure", None)
|
|
438
|
+
if callable(refresh_after_failure):
|
|
439
|
+
refreshed = self._validate_token(refresh_after_failure(failed_token))
|
|
440
|
+
else:
|
|
441
|
+
self._invalidate_token(failed_token)
|
|
442
|
+
refreshed = self._get_token(force_refresh=True)
|
|
443
|
+
return refreshed if refreshed != failed_token else None
|
|
444
|
+
|
|
445
|
+
def _invalidate_token(self, token: str | None) -> None:
|
|
446
|
+
invalidate = getattr(self._token_provider, "invalidate", None)
|
|
447
|
+
if callable(invalidate):
|
|
448
|
+
invalidate(token)
|
|
449
|
+
|
|
450
|
+
def _token_is_expired(self, token: str | None) -> bool:
|
|
451
|
+
checker = getattr(self._token_provider, "is_token_expired", None)
|
|
452
|
+
return bool(checker(token)) if callable(checker) else False
|
|
453
|
+
|
|
454
|
+
def _backoff_delay(self, retry_attempt: int, retry_after: str | None) -> float:
|
|
455
|
+
if retry_after is not None:
|
|
456
|
+
parsed = _parse_retry_after(retry_after)
|
|
457
|
+
if parsed is not None:
|
|
458
|
+
return parsed
|
|
459
|
+
ceiling = min(
|
|
460
|
+
float(self._config.retry.max_delay),
|
|
461
|
+
float(self._config.retry.base_delay) * (2 ** (retry_attempt - 1)),
|
|
462
|
+
)
|
|
463
|
+
random_fraction = max(0.0, min(1.0, float(self._random_value())))
|
|
464
|
+
return ceiling * random_fraction
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _timeout_error(*, method: str, path: str) -> RequestTimeoutError:
|
|
468
|
+
return RequestTimeoutError(
|
|
469
|
+
"RedBlack API request exhausted its timeout budget",
|
|
470
|
+
method=method,
|
|
471
|
+
path=path,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def _budget_expired(self, deadline: float | None) -> bool:
|
|
475
|
+
return deadline is not None and self._monotonic() >= deadline
|
|
476
|
+
|
|
477
|
+
def _remaining_budget(
|
|
478
|
+
self,
|
|
479
|
+
deadline: float | None,
|
|
480
|
+
*,
|
|
481
|
+
method: str,
|
|
482
|
+
path: str,
|
|
483
|
+
) -> float | None:
|
|
484
|
+
if deadline is None:
|
|
485
|
+
return None
|
|
486
|
+
remaining = deadline - self._monotonic()
|
|
487
|
+
if remaining <= 0:
|
|
488
|
+
raise self._timeout_error(method=method, path=path)
|
|
489
|
+
return remaining
|
|
490
|
+
|
|
491
|
+
def _timeout_for_budget(
|
|
492
|
+
self,
|
|
493
|
+
deadline: float | None,
|
|
494
|
+
*,
|
|
495
|
+
method: str,
|
|
496
|
+
path: str,
|
|
497
|
+
) -> httpx.Timeout:
|
|
498
|
+
remaining = self._remaining_budget(deadline, method=method, path=path)
|
|
499
|
+
if remaining is None:
|
|
500
|
+
return self._timeout
|
|
501
|
+
return httpx.Timeout(
|
|
502
|
+
connect=min(float(self._config.timeout.connect), remaining),
|
|
503
|
+
read=min(float(self._config.timeout.read), remaining),
|
|
504
|
+
write=min(float(self._config.timeout.write), remaining),
|
|
505
|
+
pool=min(float(self._config.timeout.pool), remaining),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _sleep_for_retry(
|
|
509
|
+
self,
|
|
510
|
+
delay: float,
|
|
511
|
+
deadline: float | None,
|
|
512
|
+
*,
|
|
513
|
+
method: str,
|
|
514
|
+
path: str,
|
|
515
|
+
) -> None:
|
|
516
|
+
remaining = self._remaining_budget(deadline, method=method, path=path)
|
|
517
|
+
if remaining is None:
|
|
518
|
+
self._sleep(delay)
|
|
519
|
+
return
|
|
520
|
+
bounded_delay = min(delay, remaining)
|
|
521
|
+
self._sleep(bounded_delay)
|
|
522
|
+
if delay >= remaining:
|
|
523
|
+
raise self._timeout_error(method=method, path=path)
|
|
524
|
+
self._remaining_budget(deadline, method=method, path=path)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _validate_method(method: object) -> str:
|
|
528
|
+
if not isinstance(method, str) or not method.strip():
|
|
529
|
+
raise ConfigurationError("HTTP method must be a non-empty string")
|
|
530
|
+
normalized = method.strip().upper()
|
|
531
|
+
if not normalized.isalpha():
|
|
532
|
+
raise ConfigurationError("HTTP method contains invalid characters")
|
|
533
|
+
return normalized
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _validate_timeout_budget(timeout_budget: object) -> float | None:
|
|
537
|
+
if timeout_budget is None:
|
|
538
|
+
return None
|
|
539
|
+
if (
|
|
540
|
+
isinstance(timeout_budget, bool)
|
|
541
|
+
or not isinstance(timeout_budget, int | float)
|
|
542
|
+
or not isfinite(timeout_budget)
|
|
543
|
+
or timeout_budget < 0
|
|
544
|
+
):
|
|
545
|
+
raise ConfigurationError("timeout_budget must be a finite non-negative number or None")
|
|
546
|
+
return float(timeout_budget)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _validate_relative_path(path: object) -> str:
|
|
550
|
+
if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
|
|
551
|
+
raise ConfigurationError("API path must be an absolute-path reference beginning with '/'")
|
|
552
|
+
if "\\" in path or any(ord(character) < 32 for character in path):
|
|
553
|
+
raise ConfigurationError("API path contains unsafe characters")
|
|
554
|
+
split = urlsplit(path)
|
|
555
|
+
if split.scheme or split.netloc or split.query or split.fragment:
|
|
556
|
+
raise ConfigurationError("API path must be same-origin and contain no query or fragment")
|
|
557
|
+
if any(segment in {".", ".."} for segment in split.path.split("/")):
|
|
558
|
+
raise ConfigurationError("API path must not contain dot segments")
|
|
559
|
+
return path
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _validate_headers(headers: RequestHeaders) -> dict[str, str]:
|
|
563
|
+
if headers is None:
|
|
564
|
+
return {}
|
|
565
|
+
result: dict[str, str] = {}
|
|
566
|
+
for name, value in headers.items():
|
|
567
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
568
|
+
raise ConfigurationError("request header names and values must be strings")
|
|
569
|
+
if name.lower() in _PROTECTED_HEADERS:
|
|
570
|
+
raise ConfigurationError(f"request header {name!r} is managed by the SDK")
|
|
571
|
+
if "\r" in name or "\n" in name or "\r" in value or "\n" in value:
|
|
572
|
+
raise ConfigurationError("request headers must not contain newlines")
|
|
573
|
+
result[name] = value
|
|
574
|
+
return result
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _is_expected(status_code: int, expected: frozenset[int] | None) -> bool:
|
|
578
|
+
if expected is None:
|
|
579
|
+
return 200 <= status_code < 300
|
|
580
|
+
return status_code in expected
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _parse_retry_after(value: str) -> float | None:
|
|
584
|
+
stripped = value.strip()
|
|
585
|
+
try:
|
|
586
|
+
seconds = float(stripped)
|
|
587
|
+
except ValueError:
|
|
588
|
+
seconds = -1
|
|
589
|
+
if seconds >= 0:
|
|
590
|
+
return seconds
|
|
591
|
+
try:
|
|
592
|
+
when = email.utils.parsedate_to_datetime(stripped)
|
|
593
|
+
except (TypeError, ValueError, OverflowError):
|
|
594
|
+
return None
|
|
595
|
+
if when.tzinfo is None:
|
|
596
|
+
when = when.replace(tzinfo=UTC)
|
|
597
|
+
return max(0.0, (when - datetime.now(UTC)).total_seconds())
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
__all__ = [
|
|
601
|
+
"HttpTransport",
|
|
602
|
+
"HttpxTransport",
|
|
603
|
+
"IterableStatus",
|
|
604
|
+
"QueryParams",
|
|
605
|
+
"RequestHeaders",
|
|
606
|
+
]
|