blackshield-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.
Potentially problematic release.
This version of blackshield-sdk might be problematic. Click here for more details.
- blackshield/__init__.py +47 -0
- blackshield/_async_client.py +130 -0
- blackshield/_base_client.py +420 -0
- blackshield/_client.py +156 -0
- blackshield/_exceptions.py +74 -0
- blackshield/_types.py +14 -0
- blackshield/_version.py +3 -0
- blackshield/resources/__init__.py +1 -0
- blackshield/resources/ai_gateway.py +64 -0
- blackshield/resources/alerts.py +127 -0
- blackshield/resources/api_keys.py +61 -0
- blackshield/resources/audit.py +95 -0
- blackshield/resources/compliance.py +112 -0
- blackshield/resources/connectors.py +40 -0
- blackshield/resources/dashboard.py +76 -0
- blackshield/resources/easm.py +108 -0
- blackshield/resources/findings.py +221 -0
- blackshield/resources/identity.py +87 -0
- blackshield/resources/ingestion.py +178 -0
- blackshield/resources/policies.py +242 -0
- blackshield/resources/remediation.py +175 -0
- blackshield/resources/reports.py +109 -0
- blackshield/resources/security_reviews.py +158 -0
- blackshield/resources/threat_intel.py +97 -0
- blackshield/types/__init__.py +86 -0
- blackshield/types/ai_gateway.py +47 -0
- blackshield/types/alerts.py +73 -0
- blackshield/types/api_keys.py +30 -0
- blackshield/types/audit.py +24 -0
- blackshield/types/compliance.py +50 -0
- blackshield/types/connectors.py +60 -0
- blackshield/types/dashboard.py +77 -0
- blackshield/types/easm.py +64 -0
- blackshield/types/findings.py +113 -0
- blackshield/types/identity.py +76 -0
- blackshield/types/ingestion.py +134 -0
- blackshield/types/policies.py +78 -0
- blackshield/types/remediation.py +87 -0
- blackshield/types/reports.py +42 -0
- blackshield/types/security_reviews.py +47 -0
- blackshield/types/threat_intel.py +62 -0
- blackshield_sdk-0.1.0.dist-info/METADATA +153 -0
- blackshield_sdk-0.1.0.dist-info/RECORD +44 -0
- blackshield_sdk-0.1.0.dist-info/WHEEL +4 -0
blackshield/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""BlackShield Python SDK — Official client for the BlackShield Security Platform.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import blackshield
|
|
6
|
+
|
|
7
|
+
client = blackshield.BlackShield(api_key="bsk_live_...")
|
|
8
|
+
page = client.findings.list(severity=["critical", "high"])
|
|
9
|
+
for finding in page.items:
|
|
10
|
+
print(f"{finding.severity.value} | {finding.title}")
|
|
11
|
+
|
|
12
|
+
For async usage::
|
|
13
|
+
|
|
14
|
+
from blackshield import AsyncBlackShield
|
|
15
|
+
|
|
16
|
+
async with AsyncBlackShield(api_key="bsk_live_...") as client:
|
|
17
|
+
page = await client.findings.list()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from blackshield._async_client import AsyncBlackShield
|
|
21
|
+
from blackshield._client import BlackShield
|
|
22
|
+
from blackshield._exceptions import (
|
|
23
|
+
APIError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
BlackShieldError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
PermissionDeniedError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from blackshield._version import __version__
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"APIError",
|
|
36
|
+
"AsyncBlackShield",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"BlackShield",
|
|
39
|
+
# Exceptions
|
|
40
|
+
"BlackShieldError",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
"NotFoundError",
|
|
43
|
+
"PermissionDeniedError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"ValidationError",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Asynchronous BlackShield client — async/await variant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from blackshield._base_client import AsyncHTTPClient
|
|
6
|
+
from blackshield.resources.ai_gateway import AsyncAiGatewayResource
|
|
7
|
+
from blackshield.resources.alerts import AsyncAlertsResource
|
|
8
|
+
from blackshield.resources.api_keys import AsyncApiKeysResource
|
|
9
|
+
from blackshield.resources.audit import AsyncAuditResource
|
|
10
|
+
from blackshield.resources.compliance import AsyncComplianceResource
|
|
11
|
+
from blackshield.resources.connectors import AsyncConnectorsResource
|
|
12
|
+
from blackshield.resources.dashboard import AsyncDashboardResource
|
|
13
|
+
from blackshield.resources.easm import AsyncEasmResource
|
|
14
|
+
from blackshield.resources.findings import AsyncFindingsResource
|
|
15
|
+
from blackshield.resources.identity import AsyncIdentityResource
|
|
16
|
+
from blackshield.resources.ingestion import AsyncIngestionResource
|
|
17
|
+
from blackshield.resources.policies import AsyncPoliciesResource
|
|
18
|
+
from blackshield.resources.remediation import AsyncRemediationResource
|
|
19
|
+
from blackshield.resources.reports import AsyncReportsResource
|
|
20
|
+
from blackshield.resources.security_reviews import AsyncSecurityReviewsResource
|
|
21
|
+
from blackshield.resources.threat_intel import AsyncThreatIntelResource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncBlackShield:
|
|
25
|
+
"""Asynchronous client for the BlackShield Security Platform API."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str,
|
|
30
|
+
*,
|
|
31
|
+
base_url: str = "https://api.blackshield.security",
|
|
32
|
+
timeout: float = 60.0,
|
|
33
|
+
max_retries: int = 3,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._http = AsyncHTTPClient(
|
|
36
|
+
base_url=base_url,
|
|
37
|
+
api_key=api_key,
|
|
38
|
+
timeout=timeout,
|
|
39
|
+
max_retries=max_retries,
|
|
40
|
+
)
|
|
41
|
+
self._findings = AsyncFindingsResource(self._http)
|
|
42
|
+
self._ingestion = AsyncIngestionResource(self._http)
|
|
43
|
+
self._dashboard = AsyncDashboardResource(self._http)
|
|
44
|
+
self._compliance = AsyncComplianceResource(self._http)
|
|
45
|
+
self._reports = AsyncReportsResource(self._http)
|
|
46
|
+
self._easm = AsyncEasmResource(self._http)
|
|
47
|
+
self._threat_intel = AsyncThreatIntelResource(self._http)
|
|
48
|
+
self._connectors = AsyncConnectorsResource(self._http)
|
|
49
|
+
self._api_keys = AsyncApiKeysResource(self._http)
|
|
50
|
+
self._policies = AsyncPoliciesResource(self._http)
|
|
51
|
+
self._security_reviews = AsyncSecurityReviewsResource(self._http)
|
|
52
|
+
self._audit = AsyncAuditResource(self._http)
|
|
53
|
+
self._alerts = AsyncAlertsResource(self._http)
|
|
54
|
+
self._identity = AsyncIdentityResource(self._http)
|
|
55
|
+
self._remediation = AsyncRemediationResource(self._http)
|
|
56
|
+
self._ai_gateway = AsyncAiGatewayResource(self._http)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def findings(self) -> AsyncFindingsResource:
|
|
60
|
+
return self._findings
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def ingestion(self) -> AsyncIngestionResource:
|
|
64
|
+
return self._ingestion
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def dashboard(self) -> AsyncDashboardResource:
|
|
68
|
+
return self._dashboard
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def compliance(self) -> AsyncComplianceResource:
|
|
72
|
+
return self._compliance
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def reports(self) -> AsyncReportsResource:
|
|
76
|
+
return self._reports
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def easm(self) -> AsyncEasmResource:
|
|
80
|
+
return self._easm
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def threat_intel(self) -> AsyncThreatIntelResource:
|
|
84
|
+
return self._threat_intel
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def connectors(self) -> AsyncConnectorsResource:
|
|
88
|
+
return self._connectors
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def api_keys(self) -> AsyncApiKeysResource:
|
|
92
|
+
return self._api_keys
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def policies(self) -> AsyncPoliciesResource:
|
|
96
|
+
return self._policies
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def security_reviews(self) -> AsyncSecurityReviewsResource:
|
|
100
|
+
return self._security_reviews
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def audit(self) -> AsyncAuditResource:
|
|
104
|
+
return self._audit
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def alerts(self) -> AsyncAlertsResource:
|
|
108
|
+
return self._alerts
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def identity(self) -> AsyncIdentityResource:
|
|
112
|
+
return self._identity
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def remediation(self) -> AsyncRemediationResource:
|
|
116
|
+
return self._remediation
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def ai_gateway(self) -> AsyncAiGatewayResource:
|
|
120
|
+
return self._ai_gateway
|
|
121
|
+
|
|
122
|
+
async def close(self) -> None:
|
|
123
|
+
"""Close the underlying async HTTP client."""
|
|
124
|
+
await self._http.close()
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> AsyncBlackShield:
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
130
|
+
await self.close()
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Shared HTTP transport — retry logic, auth, and error mapping.
|
|
2
|
+
|
|
3
|
+
Both the sync and async clients delegate to these helpers so retry
|
|
4
|
+
semantics and error classification live in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from blackshield._exceptions import (
|
|
17
|
+
APIError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
BlackShieldError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PermissionDeniedError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from blackshield._types import Headers, JSONDict, QueryParams
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("blackshield")
|
|
29
|
+
|
|
30
|
+
_RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
31
|
+
_DEFAULT_BASE_URL = "https://api.blackshield.security"
|
|
32
|
+
_DEFAULT_TIMEOUT = 60.0
|
|
33
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
34
|
+
_RETRY_BASE_DELAY = 0.5
|
|
35
|
+
_RETRY_MAX_DELAY = 8.0
|
|
36
|
+
_JITTER_FACTOR = 0.1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_auth_headers(api_key: str) -> Headers:
|
|
40
|
+
"""Construct default headers for every request."""
|
|
41
|
+
return {
|
|
42
|
+
"X-API-Key": api_key,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"User-Agent": _user_agent(),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _user_agent() -> str:
|
|
49
|
+
from blackshield._version import __version__
|
|
50
|
+
|
|
51
|
+
return f"blackshield-python/{__version__}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _retry_delay(attempt: int) -> float:
|
|
55
|
+
"""Exponential backoff with jitter, capped at ``_RETRY_MAX_DELAY``."""
|
|
56
|
+
base = _RETRY_BASE_DELAY * (2 ** max(0, attempt - 1))
|
|
57
|
+
capped = min(base, _RETRY_MAX_DELAY)
|
|
58
|
+
jitter = capped * _JITTER_FACTOR * random.random()
|
|
59
|
+
return capped + jitter
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
63
|
+
"""Map HTTP error status codes to typed SDK exceptions."""
|
|
64
|
+
if response.is_success:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
status_code = response.status_code
|
|
68
|
+
try:
|
|
69
|
+
body: dict[str, Any] = response.json()
|
|
70
|
+
except Exception:
|
|
71
|
+
body = {}
|
|
72
|
+
|
|
73
|
+
detail = body.get("detail", response.text or "Unknown error")
|
|
74
|
+
message = f"HTTP {status_code}: {detail}"
|
|
75
|
+
|
|
76
|
+
exc_map: dict[int, type[BlackShieldError]] = {
|
|
77
|
+
401: AuthenticationError,
|
|
78
|
+
403: PermissionDeniedError,
|
|
79
|
+
404: NotFoundError,
|
|
80
|
+
409: ConflictError,
|
|
81
|
+
422: ValidationError,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if status_code == 429:
|
|
85
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
86
|
+
retry_after = float(retry_after_raw) if retry_after_raw else None
|
|
87
|
+
raise RateLimitError(
|
|
88
|
+
message,
|
|
89
|
+
status_code=status_code,
|
|
90
|
+
response_body=body,
|
|
91
|
+
retry_after=retry_after,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
exc_cls = exc_map.get(status_code)
|
|
95
|
+
if exc_cls:
|
|
96
|
+
raise exc_cls(message, status_code=status_code, response_body=body)
|
|
97
|
+
|
|
98
|
+
if status_code >= 500:
|
|
99
|
+
raise APIError(message, status_code=status_code, response_body=body)
|
|
100
|
+
|
|
101
|
+
# Fallback for any other 4xx we didn't map.
|
|
102
|
+
raise BlackShieldError(message, status_code=status_code, response_body=body)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_retryable(status_code: int) -> bool:
|
|
106
|
+
return status_code in _RETRYABLE_STATUS_CODES
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Sync transport
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SyncHTTPClient:
|
|
115
|
+
"""Synchronous HTTP client with retry support."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
121
|
+
api_key: str,
|
|
122
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
123
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._base_url = base_url.rstrip("/")
|
|
126
|
+
self._max_retries = max(1, max_retries)
|
|
127
|
+
self._client = httpx.Client(
|
|
128
|
+
timeout=timeout,
|
|
129
|
+
headers=_build_auth_headers(api_key),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# -- public interface --------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def request(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
path: str,
|
|
138
|
+
*,
|
|
139
|
+
json: JSONDict | list[JSONDict] | None = None,
|
|
140
|
+
params: QueryParams | None = None,
|
|
141
|
+
) -> httpx.Response:
|
|
142
|
+
"""Execute an HTTP request with automatic retries."""
|
|
143
|
+
url = f"{self._base_url}{path}"
|
|
144
|
+
clean_params = _strip_none_params(params)
|
|
145
|
+
last_exc: Exception | None = None
|
|
146
|
+
|
|
147
|
+
for attempt in range(1, self._max_retries + 1):
|
|
148
|
+
try:
|
|
149
|
+
response = self._client.request(
|
|
150
|
+
method,
|
|
151
|
+
url,
|
|
152
|
+
json=json,
|
|
153
|
+
params=clean_params,
|
|
154
|
+
)
|
|
155
|
+
if response.is_success:
|
|
156
|
+
return response
|
|
157
|
+
if not _is_retryable(response.status_code):
|
|
158
|
+
_raise_for_status(response)
|
|
159
|
+
last_exc = None # will retry
|
|
160
|
+
if attempt < self._max_retries:
|
|
161
|
+
_sleep_for_retry(attempt, response)
|
|
162
|
+
continue
|
|
163
|
+
# Final attempt — raise
|
|
164
|
+
_raise_for_status(response)
|
|
165
|
+
except BlackShieldError:
|
|
166
|
+
raise
|
|
167
|
+
except (
|
|
168
|
+
httpx.TimeoutException,
|
|
169
|
+
httpx.ConnectError,
|
|
170
|
+
httpx.NetworkError,
|
|
171
|
+
httpx.TransportError,
|
|
172
|
+
) as exc:
|
|
173
|
+
last_exc = exc
|
|
174
|
+
if attempt == self._max_retries:
|
|
175
|
+
raise APIError(
|
|
176
|
+
f"Request failed after {self._max_retries} attempts: {exc}",
|
|
177
|
+
status_code=None,
|
|
178
|
+
) from exc
|
|
179
|
+
delay = _retry_delay(attempt)
|
|
180
|
+
logger.warning(
|
|
181
|
+
"Retrying %s %s (attempt %d/%d, sleep %.2fs): %s",
|
|
182
|
+
method,
|
|
183
|
+
path,
|
|
184
|
+
attempt,
|
|
185
|
+
self._max_retries,
|
|
186
|
+
delay,
|
|
187
|
+
exc,
|
|
188
|
+
)
|
|
189
|
+
time.sleep(delay)
|
|
190
|
+
|
|
191
|
+
# Unreachable in practice but satisfies the type checker.
|
|
192
|
+
assert last_exc is not None
|
|
193
|
+
raise APIError(f"Request failed: {last_exc}", status_code=None)
|
|
194
|
+
|
|
195
|
+
def get(
|
|
196
|
+
self,
|
|
197
|
+
path: str,
|
|
198
|
+
*,
|
|
199
|
+
params: QueryParams | None = None,
|
|
200
|
+
) -> httpx.Response:
|
|
201
|
+
return self.request("GET", path, params=params)
|
|
202
|
+
|
|
203
|
+
def post(
|
|
204
|
+
self,
|
|
205
|
+
path: str,
|
|
206
|
+
*,
|
|
207
|
+
json: JSONDict | list[JSONDict] | None = None,
|
|
208
|
+
params: QueryParams | None = None,
|
|
209
|
+
) -> httpx.Response:
|
|
210
|
+
return self.request("POST", path, json=json, params=params)
|
|
211
|
+
|
|
212
|
+
def put(
|
|
213
|
+
self,
|
|
214
|
+
path: str,
|
|
215
|
+
*,
|
|
216
|
+
json: JSONDict | None = None,
|
|
217
|
+
params: QueryParams | None = None,
|
|
218
|
+
) -> httpx.Response:
|
|
219
|
+
return self.request("PUT", path, json=json, params=params)
|
|
220
|
+
|
|
221
|
+
def patch(
|
|
222
|
+
self,
|
|
223
|
+
path: str,
|
|
224
|
+
*,
|
|
225
|
+
json: JSONDict | None = None,
|
|
226
|
+
params: QueryParams | None = None,
|
|
227
|
+
) -> httpx.Response:
|
|
228
|
+
return self.request("PATCH", path, json=json, params=params)
|
|
229
|
+
|
|
230
|
+
def delete(
|
|
231
|
+
self,
|
|
232
|
+
path: str,
|
|
233
|
+
*,
|
|
234
|
+
params: QueryParams | None = None,
|
|
235
|
+
) -> httpx.Response:
|
|
236
|
+
return self.request("DELETE", path, params=params)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
self._client.close()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Async transport
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class AsyncHTTPClient:
|
|
248
|
+
"""Asynchronous HTTP client with retry support."""
|
|
249
|
+
|
|
250
|
+
def __init__(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
254
|
+
api_key: str,
|
|
255
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
256
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
257
|
+
) -> None:
|
|
258
|
+
self._base_url = base_url.rstrip("/")
|
|
259
|
+
self._max_retries = max(1, max_retries)
|
|
260
|
+
self._client = httpx.AsyncClient(
|
|
261
|
+
timeout=timeout,
|
|
262
|
+
headers=_build_auth_headers(api_key),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
async def request(
|
|
266
|
+
self,
|
|
267
|
+
method: str,
|
|
268
|
+
path: str,
|
|
269
|
+
*,
|
|
270
|
+
json: JSONDict | list[JSONDict] | None = None,
|
|
271
|
+
params: QueryParams | None = None,
|
|
272
|
+
) -> httpx.Response:
|
|
273
|
+
"""Execute an async HTTP request with automatic retries."""
|
|
274
|
+
import asyncio
|
|
275
|
+
|
|
276
|
+
url = f"{self._base_url}{path}"
|
|
277
|
+
clean_params = _strip_none_params(params)
|
|
278
|
+
last_exc: Exception | None = None
|
|
279
|
+
|
|
280
|
+
for attempt in range(1, self._max_retries + 1):
|
|
281
|
+
try:
|
|
282
|
+
response = await self._client.request(
|
|
283
|
+
method,
|
|
284
|
+
url,
|
|
285
|
+
json=json,
|
|
286
|
+
params=clean_params,
|
|
287
|
+
)
|
|
288
|
+
if response.is_success:
|
|
289
|
+
return response
|
|
290
|
+
if not _is_retryable(response.status_code):
|
|
291
|
+
_raise_for_status(response)
|
|
292
|
+
last_exc = None
|
|
293
|
+
if attempt < self._max_retries:
|
|
294
|
+
await _async_sleep_for_retry(attempt, response)
|
|
295
|
+
continue
|
|
296
|
+
_raise_for_status(response)
|
|
297
|
+
except BlackShieldError:
|
|
298
|
+
raise
|
|
299
|
+
except (
|
|
300
|
+
httpx.TimeoutException,
|
|
301
|
+
httpx.ConnectError,
|
|
302
|
+
httpx.NetworkError,
|
|
303
|
+
httpx.TransportError,
|
|
304
|
+
) as exc:
|
|
305
|
+
last_exc = exc
|
|
306
|
+
if attempt == self._max_retries:
|
|
307
|
+
raise APIError(
|
|
308
|
+
f"Request failed after {self._max_retries} attempts: {exc}",
|
|
309
|
+
status_code=None,
|
|
310
|
+
) from exc
|
|
311
|
+
delay = _retry_delay(attempt)
|
|
312
|
+
logger.warning(
|
|
313
|
+
"Retrying %s %s (attempt %d/%d, sleep %.2fs): %s",
|
|
314
|
+
method,
|
|
315
|
+
path,
|
|
316
|
+
attempt,
|
|
317
|
+
self._max_retries,
|
|
318
|
+
delay,
|
|
319
|
+
exc,
|
|
320
|
+
)
|
|
321
|
+
await asyncio.sleep(delay)
|
|
322
|
+
|
|
323
|
+
assert last_exc is not None
|
|
324
|
+
raise APIError(f"Request failed: {last_exc}", status_code=None)
|
|
325
|
+
|
|
326
|
+
async def get(
|
|
327
|
+
self,
|
|
328
|
+
path: str,
|
|
329
|
+
*,
|
|
330
|
+
params: QueryParams | None = None,
|
|
331
|
+
) -> httpx.Response:
|
|
332
|
+
return await self.request("GET", path, params=params)
|
|
333
|
+
|
|
334
|
+
async def post(
|
|
335
|
+
self,
|
|
336
|
+
path: str,
|
|
337
|
+
*,
|
|
338
|
+
json: JSONDict | list[JSONDict] | None = None,
|
|
339
|
+
params: QueryParams | None = None,
|
|
340
|
+
) -> httpx.Response:
|
|
341
|
+
return await self.request("POST", path, json=json, params=params)
|
|
342
|
+
|
|
343
|
+
async def put(
|
|
344
|
+
self,
|
|
345
|
+
path: str,
|
|
346
|
+
*,
|
|
347
|
+
json: JSONDict | None = None,
|
|
348
|
+
params: QueryParams | None = None,
|
|
349
|
+
) -> httpx.Response:
|
|
350
|
+
return await self.request("PUT", path, json=json, params=params)
|
|
351
|
+
|
|
352
|
+
async def patch(
|
|
353
|
+
self,
|
|
354
|
+
path: str,
|
|
355
|
+
*,
|
|
356
|
+
json: JSONDict | None = None,
|
|
357
|
+
params: QueryParams | None = None,
|
|
358
|
+
) -> httpx.Response:
|
|
359
|
+
return await self.request("PATCH", path, json=json, params=params)
|
|
360
|
+
|
|
361
|
+
async def delete(
|
|
362
|
+
self,
|
|
363
|
+
path: str,
|
|
364
|
+
*,
|
|
365
|
+
params: QueryParams | None = None,
|
|
366
|
+
) -> httpx.Response:
|
|
367
|
+
return await self.request("DELETE", path, params=params)
|
|
368
|
+
|
|
369
|
+
async def close(self) -> None:
|
|
370
|
+
await self._client.aclose()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# Helpers
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _strip_none_params(params: QueryParams | None) -> dict[str, str | int | float | bool]:
|
|
379
|
+
"""Remove None-valued query params so httpx doesn't send ``key=None``."""
|
|
380
|
+
if not params:
|
|
381
|
+
return {}
|
|
382
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _sleep_for_retry(attempt: int, response: httpx.Response) -> None:
|
|
386
|
+
retry_after = response.headers.get("retry-after")
|
|
387
|
+
if retry_after:
|
|
388
|
+
try:
|
|
389
|
+
delay = float(retry_after)
|
|
390
|
+
except ValueError:
|
|
391
|
+
delay = _retry_delay(attempt)
|
|
392
|
+
else:
|
|
393
|
+
delay = _retry_delay(attempt)
|
|
394
|
+
logger.warning(
|
|
395
|
+
"Retrying after %d (attempt %d, sleep %.2fs)",
|
|
396
|
+
response.status_code,
|
|
397
|
+
attempt,
|
|
398
|
+
delay,
|
|
399
|
+
)
|
|
400
|
+
time.sleep(delay)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def _async_sleep_for_retry(attempt: int, response: httpx.Response) -> None:
|
|
404
|
+
import asyncio
|
|
405
|
+
|
|
406
|
+
retry_after = response.headers.get("retry-after")
|
|
407
|
+
if retry_after:
|
|
408
|
+
try:
|
|
409
|
+
delay = float(retry_after)
|
|
410
|
+
except ValueError:
|
|
411
|
+
delay = _retry_delay(attempt)
|
|
412
|
+
else:
|
|
413
|
+
delay = _retry_delay(attempt)
|
|
414
|
+
logger.warning(
|
|
415
|
+
"Retrying after %d (attempt %d, sleep %.2fs)",
|
|
416
|
+
response.status_code,
|
|
417
|
+
attempt,
|
|
418
|
+
delay,
|
|
419
|
+
)
|
|
420
|
+
await asyncio.sleep(delay)
|