contrastapi 1.22.3__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.
- contrastapi/__init__.py +39 -0
- contrastapi/_transport.py +170 -0
- contrastapi/_version.py +1 -0
- contrastapi/client.py +171 -0
- contrastapi/exceptions.py +176 -0
- contrastapi/models.py +661 -0
- contrastapi/namespaces/__init__.py +0 -0
- contrastapi/namespaces/atlas.py +134 -0
- contrastapi/namespaces/cve.py +125 -0
- contrastapi/namespaces/d3fend.py +124 -0
- contrastapi/namespaces/domain.py +129 -0
- contrastapi/namespaces/intel.py +151 -0
- contrastapi/namespaces/ioc.py +61 -0
- contrastapi/namespaces/security.py +94 -0
- contrastapi/shortcuts.py +182 -0
- contrastapi-1.22.3.dist-info/METADATA +194 -0
- contrastapi-1.22.3.dist-info/RECORD +18 -0
- contrastapi-1.22.3.dist-info/WHEEL +4 -0
contrastapi/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""ContrastAPI Python SDK.
|
|
2
|
+
|
|
3
|
+
>>> from contrastapi import ContrastAPI
|
|
4
|
+
>>> client = ContrastAPI()
|
|
5
|
+
>>> client.cve.lookup("CVE-2021-44228")
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .client import AsyncContrastAPI, ContrastAPI
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
AuthRequiredError,
|
|
12
|
+
ContrastAPIError,
|
|
13
|
+
InvalidArgumentError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
TierLimitError,
|
|
17
|
+
TransportError,
|
|
18
|
+
UpstreamError,
|
|
19
|
+
UpstreamTimeoutError,
|
|
20
|
+
)
|
|
21
|
+
from .shortcuts import audit_full, enrich_batch, triage_ioc
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AsyncContrastAPI",
|
|
25
|
+
"AuthRequiredError",
|
|
26
|
+
"ContrastAPI",
|
|
27
|
+
"ContrastAPIError",
|
|
28
|
+
"InvalidArgumentError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"RateLimitError",
|
|
31
|
+
"TierLimitError",
|
|
32
|
+
"TransportError",
|
|
33
|
+
"UpstreamError",
|
|
34
|
+
"UpstreamTimeoutError",
|
|
35
|
+
"__version__",
|
|
36
|
+
"audit_full",
|
|
37
|
+
"enrich_batch",
|
|
38
|
+
"triage_ioc",
|
|
39
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""HTTP transport for ContrastAPI SDK — sync (httpx.Client) and async (httpx.AsyncClient).
|
|
2
|
+
|
|
3
|
+
Both transports share:
|
|
4
|
+
* Base URL + API key handling (HTTPS-only unless allow_insecure=True).
|
|
5
|
+
* Error envelope parsing via exceptions._parse_error.
|
|
6
|
+
* Response size cap (10 MB) to mirror Node SDK behaviour.
|
|
7
|
+
* Identical User-Agent (`contrastapi-python/<version>`).
|
|
8
|
+
|
|
9
|
+
Design note: keeping sync and async classes side-by-side in one module avoids
|
|
10
|
+
the duplicated-stub problem and makes the parity guarantee obvious. Namespace
|
|
11
|
+
classes accept the transport instance and call `transport.get(...)` /
|
|
12
|
+
`transport.post(...)` (or `aget` / `apost` for the async variant) — the rest is
|
|
13
|
+
just URL construction.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .exceptions import ContrastAPIError, TransportError, _parse_error
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://api.contrastcyber.com"
|
|
28
|
+
DEFAULT_TIMEOUT = 30.0
|
|
29
|
+
MIN_TIMEOUT = 1.0
|
|
30
|
+
MAX_TIMEOUT = 120.0
|
|
31
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10 MB — parity with Node SDK
|
|
32
|
+
USER_AGENT = f"contrastapi-python/{__version__}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_base_url(base_url: str, *, allow_insecure: bool) -> str:
|
|
36
|
+
parts = urlsplit(base_url)
|
|
37
|
+
if parts.scheme not in ("https", "http"):
|
|
38
|
+
raise ValueError(f"Unsupported scheme: {parts.scheme!r}")
|
|
39
|
+
if parts.scheme == "http" and not allow_insecure:
|
|
40
|
+
raise ValueError("Only HTTPS is allowed. Pass allow_insecure=True to override.")
|
|
41
|
+
return base_url.rstrip("/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_headers(api_key: str | None, base_scheme: str, has_body: bool) -> dict[str, str]:
|
|
45
|
+
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
46
|
+
if api_key:
|
|
47
|
+
if base_scheme != "https":
|
|
48
|
+
raise ValueError("Refusing to send API key over insecure connection")
|
|
49
|
+
headers["X-API-Key"] = api_key
|
|
50
|
+
if has_body:
|
|
51
|
+
headers["Content-Type"] = "application/json"
|
|
52
|
+
return headers
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _clamp_timeout(value: float | None) -> float:
|
|
56
|
+
if value is None:
|
|
57
|
+
return DEFAULT_TIMEOUT
|
|
58
|
+
return max(MIN_TIMEOUT, min(float(value), MAX_TIMEOUT))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _decode_response(response: httpx.Response) -> Any:
|
|
62
|
+
"""Parse JSON body with size cap. Raises ContrastAPIError on bad JSON or oversize."""
|
|
63
|
+
content = response.content
|
|
64
|
+
if len(content) > MAX_RESPONSE_BYTES:
|
|
65
|
+
raise ContrastAPIError(
|
|
66
|
+
f"Response too large ({len(content)} bytes > {MAX_RESPONSE_BYTES})",
|
|
67
|
+
status_code=response.status_code,
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(content) if content else None
|
|
71
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
72
|
+
raise ContrastAPIError(
|
|
73
|
+
f"Invalid JSON response (HTTP {response.status_code}): {exc}",
|
|
74
|
+
status_code=response.status_code,
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _handle_response(response: httpx.Response) -> Any:
|
|
79
|
+
body = _decode_response(response)
|
|
80
|
+
if response.status_code >= 400:
|
|
81
|
+
raise _parse_error(response.status_code, body)
|
|
82
|
+
return body
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _SyncTransport:
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
90
|
+
api_key: str | None = None,
|
|
91
|
+
timeout: float | None = None,
|
|
92
|
+
allow_insecure: bool = False,
|
|
93
|
+
client: httpx.Client | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self.base_url = _normalize_base_url(base_url, allow_insecure=allow_insecure)
|
|
96
|
+
self._scheme = urlsplit(self.base_url).scheme
|
|
97
|
+
self.api_key = api_key
|
|
98
|
+
self.timeout = _clamp_timeout(timeout)
|
|
99
|
+
self._owns_client = client is None
|
|
100
|
+
self._client = client or httpx.Client(timeout=self.timeout)
|
|
101
|
+
|
|
102
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
103
|
+
headers = _build_headers(self.api_key, self._scheme, has_body=False)
|
|
104
|
+
try:
|
|
105
|
+
response = self._client.get(self.base_url + path, params=params, headers=headers)
|
|
106
|
+
except httpx.HTTPError as exc:
|
|
107
|
+
raise TransportError(str(exc)) from exc
|
|
108
|
+
return _handle_response(response)
|
|
109
|
+
|
|
110
|
+
def post(self, path: str, *, json_body: Any | None = None) -> Any:
|
|
111
|
+
headers = _build_headers(self.api_key, self._scheme, has_body=json_body is not None)
|
|
112
|
+
try:
|
|
113
|
+
response = self._client.post(self.base_url + path, json=json_body, headers=headers)
|
|
114
|
+
except httpx.HTTPError as exc:
|
|
115
|
+
raise TransportError(str(exc)) from exc
|
|
116
|
+
return _handle_response(response)
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
if self._owns_client:
|
|
120
|
+
self._client.close()
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> _SyncTransport:
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
126
|
+
self.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _AsyncTransport:
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
134
|
+
api_key: str | None = None,
|
|
135
|
+
timeout: float | None = None,
|
|
136
|
+
allow_insecure: bool = False,
|
|
137
|
+
client: httpx.AsyncClient | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
self.base_url = _normalize_base_url(base_url, allow_insecure=allow_insecure)
|
|
140
|
+
self._scheme = urlsplit(self.base_url).scheme
|
|
141
|
+
self.api_key = api_key
|
|
142
|
+
self.timeout = _clamp_timeout(timeout)
|
|
143
|
+
self._owns_client = client is None
|
|
144
|
+
self._client = client or httpx.AsyncClient(timeout=self.timeout)
|
|
145
|
+
|
|
146
|
+
async def aget(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
147
|
+
headers = _build_headers(self.api_key, self._scheme, has_body=False)
|
|
148
|
+
try:
|
|
149
|
+
response = await self._client.get(self.base_url + path, params=params, headers=headers)
|
|
150
|
+
except httpx.HTTPError as exc:
|
|
151
|
+
raise TransportError(str(exc)) from exc
|
|
152
|
+
return _handle_response(response)
|
|
153
|
+
|
|
154
|
+
async def apost(self, path: str, *, json_body: Any | None = None) -> Any:
|
|
155
|
+
headers = _build_headers(self.api_key, self._scheme, has_body=json_body is not None)
|
|
156
|
+
try:
|
|
157
|
+
response = await self._client.post(self.base_url + path, json=json_body, headers=headers)
|
|
158
|
+
except httpx.HTTPError as exc:
|
|
159
|
+
raise TransportError(str(exc)) from exc
|
|
160
|
+
return _handle_response(response)
|
|
161
|
+
|
|
162
|
+
async def aclose(self) -> None:
|
|
163
|
+
if self._owns_client:
|
|
164
|
+
await self._client.aclose()
|
|
165
|
+
|
|
166
|
+
async def __aenter__(self) -> _AsyncTransport:
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
170
|
+
await self.aclose()
|
contrastapi/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.22.3"
|
contrastapi/client.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""ContrastAPI / AsyncContrastAPI — top-level clients.
|
|
2
|
+
|
|
3
|
+
Two parallel surfaces:
|
|
4
|
+
* `ContrastAPI` (sync) → uses `httpx.Client`, namespace methods are plain `def`.
|
|
5
|
+
* `AsyncContrastAPI` (async) → uses `httpx.AsyncClient`, namespace methods are
|
|
6
|
+
`async def`.
|
|
7
|
+
|
|
8
|
+
Both classes accept the same options (`api_key`, `base_url`, `timeout`,
|
|
9
|
+
`allow_insecure`) and expose the same namespace property names. Choose whichever
|
|
10
|
+
matches your application's I/O model.
|
|
11
|
+
|
|
12
|
+
All 14 namespaces (cve, cwe, ioc, atlas, d3fend, domain, ip, asn, email, phone,
|
|
13
|
+
password, username, check, scan) are exposed as instance attributes. The async
|
|
14
|
+
client mirrors the sync surface 1:1 — same names, async methods.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ._transport import DEFAULT_BASE_URL, _AsyncTransport, _SyncTransport
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from .models import StatusResponse, UsageResponse
|
|
24
|
+
from .namespaces.atlas import AsyncAtlas, Atlas
|
|
25
|
+
from .namespaces.cve import AsyncCve, AsyncCwe, Cve, Cwe
|
|
26
|
+
from .namespaces.d3fend import AsyncD3fend, D3fend
|
|
27
|
+
from .namespaces.domain import AsyncDomain, Domain
|
|
28
|
+
from .namespaces.intel import (
|
|
29
|
+
Asn,
|
|
30
|
+
AsyncAsn,
|
|
31
|
+
AsyncEmail,
|
|
32
|
+
AsyncIp,
|
|
33
|
+
AsyncPassword,
|
|
34
|
+
AsyncPhone,
|
|
35
|
+
AsyncUsername,
|
|
36
|
+
Email,
|
|
37
|
+
Ip,
|
|
38
|
+
Password,
|
|
39
|
+
Phone,
|
|
40
|
+
Username,
|
|
41
|
+
)
|
|
42
|
+
from .namespaces.ioc import AsyncIoc, Ioc
|
|
43
|
+
from .namespaces.security import AsyncCheck, AsyncScan, Check, Scan
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ContrastAPI:
|
|
47
|
+
"""Synchronous ContrastAPI client.
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
>>> from contrastapi import ContrastAPI
|
|
51
|
+
>>> client = ContrastAPI() # keyless (free tier)
|
|
52
|
+
>>> cve = client.cve.lookup("CVE-2021-44228")
|
|
53
|
+
>>> assert cve["kev"]["in_kev"]
|
|
54
|
+
>>> client.close()
|
|
55
|
+
|
|
56
|
+
Or as context manager:
|
|
57
|
+
>>> with ContrastAPI(api_key="cc_...") as client:
|
|
58
|
+
... defenses = client.d3fend.defense_for_attack("T1059")
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
api_key: str | None = None,
|
|
64
|
+
*,
|
|
65
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
66
|
+
timeout: float | None = None,
|
|
67
|
+
allow_insecure: bool = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._transport = _SyncTransport(
|
|
70
|
+
base_url=base_url,
|
|
71
|
+
api_key=api_key,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
allow_insecure=allow_insecure,
|
|
74
|
+
)
|
|
75
|
+
self.cve = Cve(self._transport)
|
|
76
|
+
self.cwe = Cwe(self._transport)
|
|
77
|
+
self.ioc = Ioc(self._transport)
|
|
78
|
+
self.atlas = Atlas(self._transport)
|
|
79
|
+
self.d3fend = D3fend(self._transport)
|
|
80
|
+
self.domain = Domain(self._transport)
|
|
81
|
+
self.ip = Ip(self._transport)
|
|
82
|
+
self.asn = Asn(self._transport)
|
|
83
|
+
self.email = Email(self._transport)
|
|
84
|
+
self.phone = Phone(self._transport)
|
|
85
|
+
self.password = Password(self._transport)
|
|
86
|
+
self.username = Username(self._transport)
|
|
87
|
+
self.check = Check(self._transport)
|
|
88
|
+
self.scan = Scan(self._transport)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def version(self) -> str:
|
|
92
|
+
return __version__
|
|
93
|
+
|
|
94
|
+
def status(self) -> StatusResponse:
|
|
95
|
+
"""GET /v1/status — returns service health + version."""
|
|
96
|
+
return self._transport.get("/v1/status")
|
|
97
|
+
|
|
98
|
+
def usage(self) -> UsageResponse:
|
|
99
|
+
"""GET /v1/usage — returns rate-limit window for current API key (or anonymous IP)."""
|
|
100
|
+
return self._transport.get("/v1/usage")
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._transport.close()
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> ContrastAPI:
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
109
|
+
self.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AsyncContrastAPI:
|
|
113
|
+
"""Asynchronous ContrastAPI client. Mirrors ContrastAPI namespace surface.
|
|
114
|
+
|
|
115
|
+
Usage:
|
|
116
|
+
>>> import asyncio
|
|
117
|
+
>>> from contrastapi import AsyncContrastAPI
|
|
118
|
+
>>> async def main():
|
|
119
|
+
... async with AsyncContrastAPI() as client:
|
|
120
|
+
... tech = await client.atlas.technique("AML.T0051")
|
|
121
|
+
... print(tech["name"])
|
|
122
|
+
>>> asyncio.run(main())
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
api_key: str | None = None,
|
|
128
|
+
*,
|
|
129
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
130
|
+
timeout: float | None = None,
|
|
131
|
+
allow_insecure: bool = False,
|
|
132
|
+
) -> None:
|
|
133
|
+
self._transport = _AsyncTransport(
|
|
134
|
+
base_url=base_url,
|
|
135
|
+
api_key=api_key,
|
|
136
|
+
timeout=timeout,
|
|
137
|
+
allow_insecure=allow_insecure,
|
|
138
|
+
)
|
|
139
|
+
self.cve = AsyncCve(self._transport)
|
|
140
|
+
self.cwe = AsyncCwe(self._transport)
|
|
141
|
+
self.ioc = AsyncIoc(self._transport)
|
|
142
|
+
self.atlas = AsyncAtlas(self._transport)
|
|
143
|
+
self.d3fend = AsyncD3fend(self._transport)
|
|
144
|
+
self.domain = AsyncDomain(self._transport)
|
|
145
|
+
self.ip = AsyncIp(self._transport)
|
|
146
|
+
self.asn = AsyncAsn(self._transport)
|
|
147
|
+
self.email = AsyncEmail(self._transport)
|
|
148
|
+
self.phone = AsyncPhone(self._transport)
|
|
149
|
+
self.password = AsyncPassword(self._transport)
|
|
150
|
+
self.username = AsyncUsername(self._transport)
|
|
151
|
+
self.check = AsyncCheck(self._transport)
|
|
152
|
+
self.scan = AsyncScan(self._transport)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def version(self) -> str:
|
|
156
|
+
return __version__
|
|
157
|
+
|
|
158
|
+
async def status(self) -> StatusResponse:
|
|
159
|
+
return await self._transport.aget("/v1/status")
|
|
160
|
+
|
|
161
|
+
async def usage(self) -> UsageResponse:
|
|
162
|
+
return await self._transport.aget("/v1/usage")
|
|
163
|
+
|
|
164
|
+
async def aclose(self) -> None:
|
|
165
|
+
await self._transport.aclose()
|
|
166
|
+
|
|
167
|
+
async def __aenter__(self) -> AsyncContrastAPI:
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
171
|
+
await self.aclose()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Exception hierarchy for ContrastAPI SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the server-side `app.exceptions.AppException` tree so SDK users can
|
|
4
|
+
catch typed exceptions instead of inspecting status codes. Each instance carries
|
|
5
|
+
the parsed `ErrorDetail` envelope (dict) from the wire — `.code`, `.message`,
|
|
6
|
+
`.retry_after_seconds`, `.upgrade_url`, `.docs_url`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Wire-boundary defensive caps mirror server-side `_ERROR_MESSAGE_MAX_LEN` /
|
|
14
|
+
# `_RETRY_AFTER_MAX_SECONDS` (see app/main.py). Server already enforces these,
|
|
15
|
+
# but the SDK refuses to trust an untrusted response payload.
|
|
16
|
+
_MESSAGE_MAX_LEN = 500
|
|
17
|
+
_RETRY_AFTER_MAX_SECONDS = 3600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContrastAPIError(Exception):
|
|
21
|
+
"""Base exception for all ContrastAPI SDK errors."""
|
|
22
|
+
|
|
23
|
+
code: str = "internal_error"
|
|
24
|
+
status_code: int | None = None
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: Any,
|
|
29
|
+
*,
|
|
30
|
+
status_code: int | None = None,
|
|
31
|
+
code: str | None = None,
|
|
32
|
+
retry_after_seconds: int | None = None,
|
|
33
|
+
upgrade_url: str | None = None,
|
|
34
|
+
docs_url: str | None = None,
|
|
35
|
+
extras: dict[str, Any] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
# CRIT (review): coerce to str — server response could send None / int / dict.
|
|
38
|
+
coerced = "" if message is None else str(message)
|
|
39
|
+
if len(coerced) > _MESSAGE_MAX_LEN:
|
|
40
|
+
coerced = coerced[:_MESSAGE_MAX_LEN]
|
|
41
|
+
super().__init__(coerced)
|
|
42
|
+
self.message = coerced
|
|
43
|
+
if status_code is not None:
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
if code is not None:
|
|
46
|
+
self.code = code
|
|
47
|
+
self.retry_after_seconds = _coerce_retry_after(retry_after_seconds)
|
|
48
|
+
self.upgrade_url = upgrade_url
|
|
49
|
+
self.docs_url = docs_url
|
|
50
|
+
self.extras = extras or {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _coerce_retry_after(value: Any) -> int | None:
|
|
54
|
+
"""Validate + clamp retry_after_seconds to [0, 3600].
|
|
55
|
+
|
|
56
|
+
Server sends int per ErrorDetail schema, but a malformed/proxied response
|
|
57
|
+
could deliver str/float/list/bool/inf/NaN. Defensive: ignore garbage
|
|
58
|
+
rather than crash user code that does `time.sleep(exc.retry_after_seconds)`.
|
|
59
|
+
"""
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
# Reject bool early — `int(True) == 1` would silently produce 1-second
|
|
63
|
+
# sleeps from a `{"retry_after_seconds": true}` upstream response. bool
|
|
64
|
+
# is also a subclass of int, so the `isinstance(value, bool)` guard must
|
|
65
|
+
# run before the int() coercion below.
|
|
66
|
+
if isinstance(value, bool):
|
|
67
|
+
return None
|
|
68
|
+
# Reject NaN — `int(float('nan'))` raises ValueError but inf raises
|
|
69
|
+
# OverflowError; both must be silently dropped to None per defensive policy.
|
|
70
|
+
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
as_int = int(value)
|
|
74
|
+
except (TypeError, ValueError, OverflowError):
|
|
75
|
+
return None
|
|
76
|
+
return max(0, min(as_int, _RETRY_AFTER_MAX_SECONDS))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InvalidArgumentError(ContrastAPIError):
|
|
80
|
+
code = "invalid_argument"
|
|
81
|
+
status_code = 400
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AuthRequiredError(ContrastAPIError):
|
|
85
|
+
code = "auth_required"
|
|
86
|
+
status_code = 401
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TierLimitError(ContrastAPIError):
|
|
90
|
+
code = "tier_limit"
|
|
91
|
+
status_code = 403
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NotFoundError(ContrastAPIError):
|
|
95
|
+
code = "not_found"
|
|
96
|
+
status_code = 404
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RateLimitError(ContrastAPIError):
|
|
100
|
+
code = "rate_limit_exceeded"
|
|
101
|
+
status_code = 429
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class UpstreamError(ContrastAPIError):
|
|
105
|
+
code = "upstream_error"
|
|
106
|
+
status_code = 502
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UpstreamTimeoutError(ContrastAPIError):
|
|
110
|
+
code = "upstream_timeout"
|
|
111
|
+
status_code = 504
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TransportError(ContrastAPIError):
|
|
115
|
+
"""Raised when the HTTP layer fails before a response is received
|
|
116
|
+
(DNS error, connection refused, client timeout, TLS handshake fail).
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
code = "transport_error"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_CODE_TO_EXCEPTION: dict[str, type[ContrastAPIError]] = {
|
|
123
|
+
"invalid_argument": InvalidArgumentError,
|
|
124
|
+
"auth_required": AuthRequiredError,
|
|
125
|
+
"tier_limit": TierLimitError,
|
|
126
|
+
"not_found": NotFoundError,
|
|
127
|
+
"rate_limit_exceeded": RateLimitError,
|
|
128
|
+
"upstream_error": UpstreamError,
|
|
129
|
+
"upstream_timeout": UpstreamTimeoutError,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_STATUS_TO_EXCEPTION: dict[int, type[ContrastAPIError]] = {
|
|
134
|
+
400: InvalidArgumentError,
|
|
135
|
+
401: AuthRequiredError,
|
|
136
|
+
403: TierLimitError,
|
|
137
|
+
404: NotFoundError,
|
|
138
|
+
422: InvalidArgumentError,
|
|
139
|
+
429: RateLimitError,
|
|
140
|
+
502: UpstreamError,
|
|
141
|
+
504: UpstreamTimeoutError,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_error(status_code: int, body: Any) -> ContrastAPIError:
|
|
146
|
+
"""Convert a wire error envelope into the right typed exception.
|
|
147
|
+
|
|
148
|
+
Wire shape (v1.22.2+):
|
|
149
|
+
{"error": {"code": "...", "message": "...", "retry_after_seconds": 60,
|
|
150
|
+
"upgrade_url": "...", "docs_url": "..."}, ...top-level extensions}
|
|
151
|
+
|
|
152
|
+
Falls back to status-code mapping if the body lacks a parseable envelope.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(body, dict):
|
|
155
|
+
cls = _STATUS_TO_EXCEPTION.get(status_code, ContrastAPIError)
|
|
156
|
+
return cls(f"HTTP {status_code}", status_code=status_code)
|
|
157
|
+
|
|
158
|
+
err = body.get("error") if isinstance(body.get("error"), dict) else None
|
|
159
|
+
if err is None:
|
|
160
|
+
message = body.get("detail") or body.get("message") or f"HTTP {status_code}"
|
|
161
|
+
cls = _STATUS_TO_EXCEPTION.get(status_code, ContrastAPIError)
|
|
162
|
+
return cls(str(message), status_code=status_code, extras=body)
|
|
163
|
+
|
|
164
|
+
code = err.get("code") or "upstream_error"
|
|
165
|
+
message = err.get("message") or f"HTTP {status_code}"
|
|
166
|
+
cls = _CODE_TO_EXCEPTION.get(code, _STATUS_TO_EXCEPTION.get(status_code, ContrastAPIError))
|
|
167
|
+
extras = {k: v for k, v in body.items() if k != "error"}
|
|
168
|
+
return cls(
|
|
169
|
+
str(message),
|
|
170
|
+
status_code=status_code,
|
|
171
|
+
code=code,
|
|
172
|
+
retry_after_seconds=err.get("retry_after_seconds"),
|
|
173
|
+
upgrade_url=err.get("upgrade_url"),
|
|
174
|
+
docs_url=err.get("docs_url"),
|
|
175
|
+
extras=extras,
|
|
176
|
+
)
|