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/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
+ ]