contextbase-shared-plugins 0.0.0a1__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.
- contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
- shared_plugins/__init__.py +8 -0
- shared_plugins/automation.py +11 -0
- shared_plugins/base_platform.py +105 -0
- shared_plugins/bindings.py +260 -0
- shared_plugins/dlt.py +89 -0
- shared_plugins/env.py +104 -0
- shared_plugins/exceptions.py +10 -0
- shared_plugins/google_client/__init__.py +1 -0
- shared_plugins/google_client/auth.py +108 -0
- shared_plugins/google_client/batch_retry.py +308 -0
- shared_plugins/google_client/http_errors.py +27 -0
- shared_plugins/machine_token.py +419 -0
- shared_plugins/microsoft_dataverse/__init__.py +27 -0
- shared_plugins/microsoft_dataverse/annotations.py +61 -0
- shared_plugins/microsoft_dataverse/auth.py +26 -0
- shared_plugins/microsoft_dataverse/binding_config.py +35 -0
- shared_plugins/microsoft_dataverse/client.py +468 -0
- shared_plugins/microsoft_dataverse/ctx.py +21 -0
- shared_plugins/microsoft_dataverse/identifiers.py +62 -0
- shared_plugins/microsoft_dataverse/ingress.py +53 -0
- shared_plugins/microsoft_dataverse/metadata.py +106 -0
- shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
- shared_plugins/microsoft_dataverse/source.py +299 -0
- shared_plugins/microsoft_dataverse/tables.py +34 -0
- shared_plugins/microsoft_dataverse/translators.py +133 -0
- shared_plugins/microsoft_dataverse/types.py +355 -0
- shared_plugins/microsoft_graph.py +250 -0
- shared_plugins/models.py +91 -0
- shared_plugins/naming.py +83 -0
- shared_plugins/pg_column_comments.py +59 -0
- shared_plugins/provider_token.py +238 -0
- shared_plugins/pyairbyte.py +485 -0
- shared_plugins/resources.py +179 -0
- shared_plugins/scratch.py +127 -0
- shared_plugins/sentry.py +117 -0
- shared_plugins/sqlalchemy_types.py +225 -0
- shared_plugins/sqlite.py +123 -0
- shared_plugins/values.py +117 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Collection
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from google.oauth2.credentials import Credentials
|
|
8
|
+
from googleapiclient.discovery import build
|
|
9
|
+
from base_client import BindingAuth_AuthenticatedAccount
|
|
10
|
+
from shared_plugins.provider_token import (
|
|
11
|
+
ProviderTokenClient,
|
|
12
|
+
ProviderTokenLease,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_google_expiry(expires_at: datetime | None) -> datetime:
|
|
17
|
+
# Google leases always carry an expiry; None means web returned a
|
|
18
|
+
# no-declared-expiry lease (a notion-shaped provider) for a google account —
|
|
19
|
+
# a contract violation, not a case to tolerate.
|
|
20
|
+
if expires_at is None:
|
|
21
|
+
raise ValueError("google lease must carry an expiry.")
|
|
22
|
+
if expires_at.tzinfo is None or expires_at.utcoffset() is None:
|
|
23
|
+
raise ValueError("expires_at must be timezone-aware.")
|
|
24
|
+
|
|
25
|
+
# google.oauth2.credentials.Credentials expects a naive UTC datetime for expiry.
|
|
26
|
+
return expires_at.astimezone(timezone.utc).replace(tzinfo=None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fetch_provider_access_token(
|
|
30
|
+
auth: BindingAuth_AuthenticatedAccount,
|
|
31
|
+
provider_token_client: ProviderTokenClient,
|
|
32
|
+
*,
|
|
33
|
+
refresh: bool = False,
|
|
34
|
+
) -> ProviderTokenLease:
|
|
35
|
+
# Redeem the account's provider (Google) access token server-side: web's
|
|
36
|
+
# /provider-token mints/refreshes it from the stored OAuth grant. This is the
|
|
37
|
+
# credentials keystone — it replaces the in-process control plane removed in the
|
|
38
|
+
# ctxb thin-client collapse (Chunk 5). `refresh=True` triggers real rotation
|
|
39
|
+
# (google-auth only invokes the refresh handler when the credential is already
|
|
40
|
+
# near expiry, so rotating there is always warranted). The client is the Dagster
|
|
41
|
+
# `provider_token` resource, injected at the asset boundary. Returns a
|
|
42
|
+
# ProviderTokenLease — `.access_token` (str) + `.access_token_expires_at`
|
|
43
|
+
# (tz-aware datetime | None; always set for google).
|
|
44
|
+
return provider_token_client.redeem(
|
|
45
|
+
provider_id=auth.provider_id,
|
|
46
|
+
account_id=auth.account_id,
|
|
47
|
+
refresh=refresh,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_refresh_handler(
|
|
52
|
+
*,
|
|
53
|
+
auth: BindingAuth_AuthenticatedAccount,
|
|
54
|
+
provider_token_client: ProviderTokenClient,
|
|
55
|
+
) -> Callable[..., tuple[str, datetime]]:
|
|
56
|
+
def _refresh_handler(
|
|
57
|
+
request: Any,
|
|
58
|
+
*,
|
|
59
|
+
scopes: Collection[str] | None = None,
|
|
60
|
+
) -> tuple[str, datetime]:
|
|
61
|
+
del request
|
|
62
|
+
del scopes
|
|
63
|
+
|
|
64
|
+
# google-auth only invokes this handler when it already considers the
|
|
65
|
+
# credential stale (within REFRESH_THRESHOLD = 3m45s of expiry).
|
|
66
|
+
# `refresh=True` maps to web's real rotation path (auth.api.refreshToken),
|
|
67
|
+
# so this first stale-threshold call rotates for real and the handler goes
|
|
68
|
+
# quiet until the fresh token nears ITS expiry.
|
|
69
|
+
lease = _fetch_provider_access_token(auth, provider_token_client, refresh=True)
|
|
70
|
+
return (
|
|
71
|
+
lease.access_token,
|
|
72
|
+
_normalize_google_expiry(lease.access_token_expires_at),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return _refresh_handler
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_google_credentials(
|
|
79
|
+
*,
|
|
80
|
+
auth: BindingAuth_AuthenticatedAccount,
|
|
81
|
+
provider_token_client: ProviderTokenClient,
|
|
82
|
+
) -> Credentials:
|
|
83
|
+
lease = _fetch_provider_access_token(auth, provider_token_client)
|
|
84
|
+
return Credentials(
|
|
85
|
+
token=lease.access_token,
|
|
86
|
+
expiry=_normalize_google_expiry(lease.access_token_expires_at),
|
|
87
|
+
refresh_handler=_build_refresh_handler(
|
|
88
|
+
auth=auth, provider_token_client=provider_token_client
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_google_service(
|
|
94
|
+
*,
|
|
95
|
+
api_name: str,
|
|
96
|
+
api_version: str,
|
|
97
|
+
auth: BindingAuth_AuthenticatedAccount,
|
|
98
|
+
provider_token_client: ProviderTokenClient,
|
|
99
|
+
) -> Any:
|
|
100
|
+
credentials = _build_google_credentials(
|
|
101
|
+
auth=auth, provider_token_client=provider_token_client
|
|
102
|
+
)
|
|
103
|
+
return build(
|
|
104
|
+
api_name,
|
|
105
|
+
api_version,
|
|
106
|
+
credentials=credentials,
|
|
107
|
+
cache_discovery=False,
|
|
108
|
+
)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from email.utils import parsedate_to_datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .http_errors import extract_http_status_code
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_DEFAULT_RETRYABLE_FORBIDDEN_REASONS = frozenset(
|
|
18
|
+
{"rateLimitExceeded", "userRateLimitExceeded"}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class BatchRetryPolicy:
|
|
24
|
+
max_attempts: int = 5
|
|
25
|
+
base_backoff_seconds: float = 1.0
|
|
26
|
+
max_backoff_seconds: float = 30.0
|
|
27
|
+
retryable_forbidden_reasons: frozenset[str] = _DEFAULT_RETRYABLE_FORBIDDEN_REASONS
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if self.max_attempts < 1:
|
|
31
|
+
raise ValueError("max_attempts must be >= 1")
|
|
32
|
+
if self.base_backoff_seconds < 0:
|
|
33
|
+
raise ValueError("base_backoff_seconds must be >= 0")
|
|
34
|
+
if self.max_backoff_seconds < 0:
|
|
35
|
+
raise ValueError("max_backoff_seconds must be >= 0")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class BatchSubrequestFailure:
|
|
40
|
+
request_id: str
|
|
41
|
+
exception: Exception
|
|
42
|
+
status_code: int | None
|
|
43
|
+
reasons: tuple[str, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BatchRetryExhaustedError(RuntimeError):
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
attempts: int,
|
|
51
|
+
failures: Mapping[str, BatchSubrequestFailure],
|
|
52
|
+
) -> None:
|
|
53
|
+
self.attempts = attempts
|
|
54
|
+
self.failures = dict(failures)
|
|
55
|
+
failed_ids = sorted(self.failures.keys())
|
|
56
|
+
sample = ", ".join(failed_ids[:5])
|
|
57
|
+
super().__init__(
|
|
58
|
+
"Batch subrequest retries exhausted after "
|
|
59
|
+
f"{attempts} attempts for {len(failed_ids)} request(s). "
|
|
60
|
+
f"Sample request IDs: {sample}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def failed_request_ids(self) -> tuple[str, ...]:
|
|
65
|
+
return tuple(sorted(self.failures.keys()))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BatchTerminalSubrequestError(RuntimeError):
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
request_id: str,
|
|
73
|
+
exception: Exception,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.request_id = request_id
|
|
76
|
+
self.exception = exception
|
|
77
|
+
super().__init__(
|
|
78
|
+
f"Batch subrequest {request_id} failed with a non-retryable error: {exception}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
RequestFactory = Callable[[], Any]
|
|
83
|
+
NewBatchFactory = Callable[[Callable[[str, Any, Exception | None], None]], Any]
|
|
84
|
+
SleepFn = Callable[[float], None]
|
|
85
|
+
RandomFn = Callable[[], float]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def execute_batch_with_failed_subset_retries(
|
|
89
|
+
*,
|
|
90
|
+
request_factories: Mapping[str, RequestFactory],
|
|
91
|
+
new_batch: NewBatchFactory,
|
|
92
|
+
policy: BatchRetryPolicy | None = None,
|
|
93
|
+
sleep_fn: SleepFn = time.sleep,
|
|
94
|
+
random_fn: RandomFn = random.random,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Execute a callback-based Google batch and retry only failed subrequests."""
|
|
97
|
+
if not request_factories:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
retry_policy = policy or BatchRetryPolicy()
|
|
101
|
+
pending_ids = list(request_factories.keys())
|
|
102
|
+
responses: dict[str, Any] = {}
|
|
103
|
+
last_retryable_failures: dict[str, BatchSubrequestFailure] = {}
|
|
104
|
+
|
|
105
|
+
for attempt in range(1, retry_policy.max_attempts + 1):
|
|
106
|
+
attempt_failures: dict[str, Exception] = {}
|
|
107
|
+
retry_after_values: list[float] = []
|
|
108
|
+
|
|
109
|
+
def _callback(
|
|
110
|
+
request_id: str,
|
|
111
|
+
response: Any,
|
|
112
|
+
exception: Exception | None,
|
|
113
|
+
) -> None:
|
|
114
|
+
if exception is not None:
|
|
115
|
+
attempt_failures[request_id] = exception
|
|
116
|
+
retry_after = _extract_retry_after_seconds(exception)
|
|
117
|
+
if retry_after is not None:
|
|
118
|
+
retry_after_values.append(retry_after)
|
|
119
|
+
return
|
|
120
|
+
responses[request_id] = response
|
|
121
|
+
|
|
122
|
+
batch = new_batch(_callback)
|
|
123
|
+
for request_id in pending_ids:
|
|
124
|
+
batch.add(request_factories[request_id](), request_id=request_id)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
batch.execute()
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
# Batch-level transport failures have no per-subrequest callback path.
|
|
130
|
+
# Treat as a transient failure for all pending requests when retryable.
|
|
131
|
+
if _is_retryable_exception(exc, retry_policy):
|
|
132
|
+
for request_id in pending_ids:
|
|
133
|
+
attempt_failures.setdefault(request_id, exc)
|
|
134
|
+
retry_after = _extract_retry_after_seconds(exc)
|
|
135
|
+
if retry_after is not None:
|
|
136
|
+
retry_after_values.append(retry_after)
|
|
137
|
+
else:
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
if not attempt_failures:
|
|
141
|
+
return responses
|
|
142
|
+
|
|
143
|
+
status_histogram: dict[int | None, int] = {}
|
|
144
|
+
reason_histogram: dict[str, int] = {}
|
|
145
|
+
retry_after_present = 0
|
|
146
|
+
retryable_ids: list[str] = []
|
|
147
|
+
for request_id, exception in attempt_failures.items():
|
|
148
|
+
status_code = extract_http_status_code(exception)
|
|
149
|
+
reasons = tuple(sorted(_extract_error_reasons(exception)))
|
|
150
|
+
status_histogram[status_code] = status_histogram.get(status_code, 0) + 1
|
|
151
|
+
for reason in reasons:
|
|
152
|
+
reason_histogram[reason] = reason_histogram.get(reason, 0) + 1
|
|
153
|
+
if _extract_retry_after_seconds(exception) is not None:
|
|
154
|
+
retry_after_present += 1
|
|
155
|
+
if _is_retryable_exception(exception, retry_policy):
|
|
156
|
+
retryable_ids.append(request_id)
|
|
157
|
+
last_retryable_failures[request_id] = BatchSubrequestFailure(
|
|
158
|
+
request_id=request_id,
|
|
159
|
+
exception=exception,
|
|
160
|
+
status_code=status_code,
|
|
161
|
+
reasons=reasons,
|
|
162
|
+
)
|
|
163
|
+
continue
|
|
164
|
+
raise BatchTerminalSubrequestError(
|
|
165
|
+
request_id=request_id,
|
|
166
|
+
exception=exception,
|
|
167
|
+
) from exception
|
|
168
|
+
|
|
169
|
+
if attempt >= retry_policy.max_attempts:
|
|
170
|
+
unresolved = {
|
|
171
|
+
request_id: last_retryable_failures[request_id]
|
|
172
|
+
for request_id in retryable_ids
|
|
173
|
+
if request_id in last_retryable_failures
|
|
174
|
+
}
|
|
175
|
+
raise BatchRetryExhaustedError(
|
|
176
|
+
attempts=retry_policy.max_attempts,
|
|
177
|
+
failures=unresolved,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
pending_ids = retryable_ids
|
|
181
|
+
sleep_seconds = _compute_backoff_seconds(
|
|
182
|
+
attempt=attempt,
|
|
183
|
+
policy=retry_policy,
|
|
184
|
+
retry_after_values=retry_after_values,
|
|
185
|
+
random_fn=random_fn,
|
|
186
|
+
)
|
|
187
|
+
if sleep_seconds > 0:
|
|
188
|
+
logger.info(
|
|
189
|
+
"Retrying %d failed batch subrequest(s) after %.2fs (attempt %d/%d). "
|
|
190
|
+
"status_codes=%s reasons=%s retry_after_present=%d/%d",
|
|
191
|
+
len(pending_ids),
|
|
192
|
+
sleep_seconds,
|
|
193
|
+
attempt + 1,
|
|
194
|
+
retry_policy.max_attempts,
|
|
195
|
+
status_histogram,
|
|
196
|
+
reason_histogram,
|
|
197
|
+
retry_after_present,
|
|
198
|
+
len(attempt_failures),
|
|
199
|
+
)
|
|
200
|
+
sleep_fn(sleep_seconds)
|
|
201
|
+
|
|
202
|
+
return responses
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _compute_backoff_seconds(
|
|
206
|
+
*,
|
|
207
|
+
attempt: int,
|
|
208
|
+
policy: BatchRetryPolicy,
|
|
209
|
+
retry_after_values: list[float],
|
|
210
|
+
random_fn: RandomFn,
|
|
211
|
+
) -> float:
|
|
212
|
+
# Exponential backoff with jitter. attempt=1 is the first retry wait.
|
|
213
|
+
base = min(
|
|
214
|
+
policy.max_backoff_seconds,
|
|
215
|
+
policy.base_backoff_seconds * (2 ** (attempt - 1)),
|
|
216
|
+
)
|
|
217
|
+
jittered = min(policy.max_backoff_seconds, base * (1 + random_fn()))
|
|
218
|
+
retry_after = max(retry_after_values) if retry_after_values else 0.0
|
|
219
|
+
return max(jittered, retry_after)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _extract_error_reasons(exc: Exception) -> set[str]:
|
|
223
|
+
reasons: set[str] = set()
|
|
224
|
+
|
|
225
|
+
error_details = getattr(exc, "error_details", None)
|
|
226
|
+
_collect_reasons_from_value(error_details, reasons)
|
|
227
|
+
|
|
228
|
+
content = getattr(exc, "content", None)
|
|
229
|
+
if isinstance(content, bytes):
|
|
230
|
+
try:
|
|
231
|
+
payload = json.loads(content.decode("utf-8"))
|
|
232
|
+
except (UnicodeDecodeError, ValueError):
|
|
233
|
+
payload = None
|
|
234
|
+
_collect_reasons_from_value(payload, reasons)
|
|
235
|
+
|
|
236
|
+
return reasons
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _collect_reasons_from_value(value: Any, out: set[str]) -> None:
|
|
240
|
+
if isinstance(value, dict):
|
|
241
|
+
reason = value.get("reason")
|
|
242
|
+
if isinstance(reason, str):
|
|
243
|
+
out.add(reason)
|
|
244
|
+
for nested in value.values():
|
|
245
|
+
_collect_reasons_from_value(nested, out)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
if isinstance(value, list):
|
|
249
|
+
for nested in value:
|
|
250
|
+
_collect_reasons_from_value(nested, out)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_retry_after_seconds(exc: Exception) -> float | None:
|
|
254
|
+
header_value = _extract_retry_after_header(exc)
|
|
255
|
+
if header_value is None:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
text = str(header_value).strip()
|
|
259
|
+
if not text:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
seconds = float(text)
|
|
264
|
+
except ValueError:
|
|
265
|
+
seconds = _retry_after_http_date_to_seconds(text)
|
|
266
|
+
if seconds is None:
|
|
267
|
+
return None
|
|
268
|
+
return max(0.0, seconds)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _extract_retry_after_header(exc: Exception) -> str | None:
|
|
272
|
+
for attr in ("resp", "response"):
|
|
273
|
+
candidate = getattr(exc, attr, None)
|
|
274
|
+
if candidate is None:
|
|
275
|
+
continue
|
|
276
|
+
if hasattr(candidate, "get"):
|
|
277
|
+
for key in ("retry-after", "Retry-After"):
|
|
278
|
+
value = candidate.get(key)
|
|
279
|
+
if value is not None:
|
|
280
|
+
return str(value)
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _retry_after_http_date_to_seconds(value: str) -> float | None:
|
|
285
|
+
try:
|
|
286
|
+
retry_dt = parsedate_to_datetime(value)
|
|
287
|
+
except (TypeError, ValueError, IndexError):
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
if retry_dt.tzinfo is None:
|
|
291
|
+
retry_dt = retry_dt.replace(tzinfo=timezone.utc)
|
|
292
|
+
now = datetime.now(timezone.utc)
|
|
293
|
+
return (retry_dt - now).total_seconds()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _is_retryable_exception(exc: Exception, policy: BatchRetryPolicy) -> bool:
|
|
297
|
+
status_code = extract_http_status_code(exc)
|
|
298
|
+
if status_code is None:
|
|
299
|
+
return isinstance(exc, (TimeoutError, ConnectionError, OSError))
|
|
300
|
+
|
|
301
|
+
if status_code >= 500:
|
|
302
|
+
return True
|
|
303
|
+
if status_code in {408, 429}:
|
|
304
|
+
return True
|
|
305
|
+
if status_code == 403:
|
|
306
|
+
reasons = _extract_error_reasons(exc)
|
|
307
|
+
return any(reason in policy.retryable_forbidden_reasons for reason in reasons)
|
|
308
|
+
return False
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def extract_http_status_code(exc: Exception) -> int | None:
|
|
5
|
+
status_code = _coerce_status_code(getattr(exc, "status_code", None))
|
|
6
|
+
if status_code is not None:
|
|
7
|
+
return status_code
|
|
8
|
+
|
|
9
|
+
response = getattr(exc, "response", None)
|
|
10
|
+
status_code = _coerce_status_code(getattr(response, "status_code", None))
|
|
11
|
+
if status_code is not None:
|
|
12
|
+
return status_code
|
|
13
|
+
|
|
14
|
+
resp = getattr(exc, "resp", None)
|
|
15
|
+
return _coerce_status_code(getattr(resp, "status", None))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _coerce_status_code(value: object) -> int | None:
|
|
19
|
+
if isinstance(value, bool):
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(value, int):
|
|
22
|
+
return value
|
|
23
|
+
if isinstance(value, str):
|
|
24
|
+
text = value.strip()
|
|
25
|
+
if text.isdigit():
|
|
26
|
+
return int(text)
|
|
27
|
+
return None
|