fraudnova 1.0.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.
- fraudnova/__init__.py +44 -0
- fraudnova/_transport.py +112 -0
- fraudnova/_version.py +1 -0
- fraudnova/client.py +203 -0
- fraudnova/exceptions.py +158 -0
- fraudnova/py.typed +0 -0
- fraudnova/results.py +342 -0
- fraudnova/retry.py +65 -0
- fraudnova-1.0.0.dist-info/METADATA +293 -0
- fraudnova-1.0.0.dist-info/RECORD +12 -0
- fraudnova-1.0.0.dist-info/WHEEL +4 -0
- fraudnova-1.0.0.dist-info/licenses/LICENSE +21 -0
fraudnova/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Official Python SDK for the FraudNova fraud-detection API."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import BATCH_MAX, DEFAULT_BASE_URL, Client
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
AccountSuspendedError,
|
|
7
|
+
ApiError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
FraudNovaError,
|
|
10
|
+
InsufficientCreditsError,
|
|
11
|
+
InvalidArgumentError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
TransportError,
|
|
14
|
+
UnexpectedResponseError,
|
|
15
|
+
UpstreamError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .results import BatchItem, BatchResult, DateInfo, EmailResult, IpResult, PhoneResult
|
|
19
|
+
from .retry import RetryPolicy
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BATCH_MAX",
|
|
23
|
+
"DEFAULT_BASE_URL",
|
|
24
|
+
"AccountSuspendedError",
|
|
25
|
+
"ApiError",
|
|
26
|
+
"AuthenticationError",
|
|
27
|
+
"BatchItem",
|
|
28
|
+
"BatchResult",
|
|
29
|
+
"Client",
|
|
30
|
+
"DateInfo",
|
|
31
|
+
"EmailResult",
|
|
32
|
+
"FraudNovaError",
|
|
33
|
+
"InsufficientCreditsError",
|
|
34
|
+
"InvalidArgumentError",
|
|
35
|
+
"IpResult",
|
|
36
|
+
"PhoneResult",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"RetryPolicy",
|
|
39
|
+
"TransportError",
|
|
40
|
+
"UnexpectedResponseError",
|
|
41
|
+
"UpstreamError",
|
|
42
|
+
"ValidationError",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
fraudnova/_transport.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Internal HTTP layer: builds requests, decodes the envelope, maps errors, retries 429s."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from fraudnova._version import __version__
|
|
10
|
+
from fraudnova.exceptions import (
|
|
11
|
+
ApiError,
|
|
12
|
+
InvalidArgumentError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
TransportError,
|
|
15
|
+
UnexpectedResponseError,
|
|
16
|
+
mask_api_key,
|
|
17
|
+
)
|
|
18
|
+
from fraudnova.retry import RetryPolicy, parse_retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transport:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: str,
|
|
25
|
+
base_url: str,
|
|
26
|
+
timeout: float,
|
|
27
|
+
retry: Optional[RetryPolicy],
|
|
28
|
+
http_client: Optional[httpx.Client],
|
|
29
|
+
) -> None:
|
|
30
|
+
if api_key == "" or any(not 0x21 <= ord(ch) <= 0x7E for ch in api_key):
|
|
31
|
+
raise InvalidArgumentError(
|
|
32
|
+
"api_key must contain only printable ASCII characters, without whitespace."
|
|
33
|
+
)
|
|
34
|
+
self._api_key = api_key
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self._timeout = timeout
|
|
37
|
+
self._retry = retry
|
|
38
|
+
self._client = http_client
|
|
39
|
+
self.owns_client = http_client is None
|
|
40
|
+
|
|
41
|
+
def get(
|
|
42
|
+
self,
|
|
43
|
+
path: str,
|
|
44
|
+
params: Optional[Mapping[str, Union[str, int]]] = None,
|
|
45
|
+
authenticated: bool = True,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
return self._send("GET", self.base_url + path, authenticated, params=params)
|
|
48
|
+
|
|
49
|
+
def post(self, path: str, json: Mapping[str, Any]) -> dict[str, Any]:
|
|
50
|
+
return self._send("POST", self.base_url + path, True, json=json)
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
if self.owns_client and self._client is not None:
|
|
54
|
+
self._client.close()
|
|
55
|
+
|
|
56
|
+
def _http(self) -> httpx.Client:
|
|
57
|
+
if self._client is None:
|
|
58
|
+
self._client = httpx.Client(timeout=httpx.Timeout(self._timeout, connect=10.0))
|
|
59
|
+
return self._client
|
|
60
|
+
|
|
61
|
+
def _headers(self, authenticated: bool) -> dict[str, str]:
|
|
62
|
+
headers = {
|
|
63
|
+
"Accept": "application/json",
|
|
64
|
+
"User-Agent": f"fraudnova-python/{__version__} python/{platform.python_version()}",
|
|
65
|
+
}
|
|
66
|
+
if authenticated:
|
|
67
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
68
|
+
return headers
|
|
69
|
+
|
|
70
|
+
def _send(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
url: str,
|
|
74
|
+
authenticated: bool,
|
|
75
|
+
params: Optional[Mapping[str, Union[str, int]]] = None,
|
|
76
|
+
json: Optional[Mapping[str, Any]] = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
attempt = 1
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
response = self._http().request(
|
|
82
|
+
method, url, params=params, json=json, headers=self._headers(authenticated)
|
|
83
|
+
)
|
|
84
|
+
except (httpx.HTTPError, httpx.InvalidURL, httpx.StreamError) as exc:
|
|
85
|
+
message = str(exc).replace(self._api_key, mask_api_key(self._api_key))
|
|
86
|
+
raise TransportError(f"FraudNova request failed: {message}") from exc
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
return self._decode(response)
|
|
90
|
+
except RateLimitError as error:
|
|
91
|
+
delay = self._retry.delay_for(error, attempt) if self._retry else None
|
|
92
|
+
if self._retry is None or delay is None:
|
|
93
|
+
raise
|
|
94
|
+
self._retry.sleep(delay)
|
|
95
|
+
attempt += 1
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _decode(response: httpx.Response) -> dict[str, Any]:
|
|
99
|
+
status = response.status_code
|
|
100
|
+
if status < 200 or status >= 300:
|
|
101
|
+
raise ApiError.from_response(
|
|
102
|
+
status, response.text, parse_retry_after(response.headers.get("Retry-After"))
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
decoded: Any = response.json()
|
|
106
|
+
except (ValueError, RecursionError):
|
|
107
|
+
decoded = None
|
|
108
|
+
if not isinstance(decoded, dict) or decoded.get("success") is not True:
|
|
109
|
+
raise UnexpectedResponseError(
|
|
110
|
+
status, "Unexpected response body", decoded if isinstance(decoded, dict) else None
|
|
111
|
+
)
|
|
112
|
+
return decoded
|
fraudnova/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
fraudnova/client.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The public FraudNova client."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any, Literal, Optional, Union
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from fraudnova._transport import Transport
|
|
9
|
+
from fraudnova.exceptions import InvalidArgumentError, UnexpectedResponseError, mask_api_key
|
|
10
|
+
from fraudnova.results import BatchItem, BatchResult, EmailResult, IpResult, PhoneResult, _int
|
|
11
|
+
from fraudnova.retry import RetryPolicy
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://fraudnova.com/api/v1"
|
|
14
|
+
BATCH_MAX = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _subject(value: str, name: str) -> str:
|
|
18
|
+
if not isinstance(value, str) or not value.strip():
|
|
19
|
+
raise InvalidArgumentError(f"{name} must be a non-empty string")
|
|
20
|
+
return value.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _strictness(value: Optional[int]) -> Optional[int]:
|
|
24
|
+
if value is None:
|
|
25
|
+
return None
|
|
26
|
+
if not isinstance(value, int) or isinstance(value, bool) or value not in (0, 1, 2, 3):
|
|
27
|
+
raise InvalidArgumentError("strictness must be an integer from 0 to 3")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _country(value: Optional[str]) -> Optional[str]:
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
return value.strip().upper() or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _data(envelope: Mapping[str, Any]) -> dict[str, Any]:
|
|
38
|
+
data = envelope.get("data")
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
raise UnexpectedResponseError(200, "Missing data", dict(envelope))
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Client:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: float = 30.0,
|
|
51
|
+
retry: Optional[RetryPolicy] = None,
|
|
52
|
+
http_client: Optional[httpx.Client] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
key = api_key.strip() if isinstance(api_key, str) else ""
|
|
55
|
+
if not key:
|
|
56
|
+
raise InvalidArgumentError("api_key must be a non-empty string")
|
|
57
|
+
if not base_url.lower().startswith(("http://", "https://")):
|
|
58
|
+
raise InvalidArgumentError("base_url must start with http:// or https://")
|
|
59
|
+
self._api_key = key
|
|
60
|
+
self._transport = Transport(key, base_url, timeout, retry, http_client)
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
masked = mask_api_key(self._api_key)
|
|
64
|
+
return f"Client(api_key={masked!r}, base_url={self._transport.base_url!r})"
|
|
65
|
+
|
|
66
|
+
def __enter__(self) -> "Client":
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *exc: object) -> None:
|
|
70
|
+
self.close()
|
|
71
|
+
|
|
72
|
+
def close(self) -> None:
|
|
73
|
+
self._transport.close()
|
|
74
|
+
|
|
75
|
+
# Single checks
|
|
76
|
+
|
|
77
|
+
def check_ip(
|
|
78
|
+
self,
|
|
79
|
+
ip: str,
|
|
80
|
+
*,
|
|
81
|
+
strictness: Optional[int] = None,
|
|
82
|
+
mobile: Optional[bool] = None,
|
|
83
|
+
user_agent: Optional[str] = None,
|
|
84
|
+
user_language: Optional[str] = None,
|
|
85
|
+
) -> IpResult:
|
|
86
|
+
params: dict[str, Union[str, int]] = {"ip": _subject(ip, "ip")}
|
|
87
|
+
level = _strictness(strictness)
|
|
88
|
+
if level is not None:
|
|
89
|
+
params["strictness"] = level
|
|
90
|
+
if mobile is not None:
|
|
91
|
+
params["mobile"] = "true" if mobile else "false"
|
|
92
|
+
if user_agent is not None:
|
|
93
|
+
params["user_agent"] = user_agent
|
|
94
|
+
if user_language is not None:
|
|
95
|
+
params["user_language"] = user_language
|
|
96
|
+
envelope = self._transport.get("/check", params)
|
|
97
|
+
return IpResult.from_dict(_data(envelope), _int(envelope.get("credits_remaining")))
|
|
98
|
+
|
|
99
|
+
def check_email(self, email: str) -> EmailResult:
|
|
100
|
+
envelope = self._transport.get("/check/email", {"email": _subject(email, "email")})
|
|
101
|
+
return EmailResult.from_dict(_data(envelope), _int(envelope.get("credits_remaining")))
|
|
102
|
+
|
|
103
|
+
def check_phone(self, phone: str, country: Optional[str] = None) -> PhoneResult:
|
|
104
|
+
params: dict[str, Union[str, int]] = {"phone": _subject(phone, "phone")}
|
|
105
|
+
code = _country(country)
|
|
106
|
+
if code:
|
|
107
|
+
params["country"] = code
|
|
108
|
+
envelope = self._transport.get("/check/phone", params)
|
|
109
|
+
return PhoneResult.from_dict(_data(envelope), _int(envelope.get("credits_remaining")))
|
|
110
|
+
|
|
111
|
+
# Batch checks
|
|
112
|
+
|
|
113
|
+
def check_ips(
|
|
114
|
+
self,
|
|
115
|
+
ips: Sequence[Optional[str]],
|
|
116
|
+
*,
|
|
117
|
+
strictness: Optional[int] = None,
|
|
118
|
+
mobile: Optional[bool] = None,
|
|
119
|
+
user_agent: Optional[str] = None,
|
|
120
|
+
user_language: Optional[str] = None,
|
|
121
|
+
) -> BatchResult:
|
|
122
|
+
extra: dict[str, Any] = {}
|
|
123
|
+
level = _strictness(strictness)
|
|
124
|
+
if level is not None:
|
|
125
|
+
extra["strictness"] = level
|
|
126
|
+
if mobile is not None:
|
|
127
|
+
extra["mobile"] = mobile
|
|
128
|
+
if user_agent is not None:
|
|
129
|
+
extra["user_agent"] = user_agent
|
|
130
|
+
if user_language is not None:
|
|
131
|
+
extra["user_language"] = user_language
|
|
132
|
+
return self._batch("/check/batch", "ips", "ip", ips, extra)
|
|
133
|
+
|
|
134
|
+
def check_emails(self, emails: Sequence[Optional[str]]) -> BatchResult:
|
|
135
|
+
return self._batch("/check/email/batch", "emails", "email", emails, {})
|
|
136
|
+
|
|
137
|
+
def check_phones(
|
|
138
|
+
self, phones: Sequence[Optional[str]], country: Optional[str] = None
|
|
139
|
+
) -> BatchResult:
|
|
140
|
+
extra: dict[str, Any] = {}
|
|
141
|
+
code = _country(country)
|
|
142
|
+
if code:
|
|
143
|
+
extra["country"] = code
|
|
144
|
+
return self._batch("/check/phone/batch", "phones", "phone", phones, extra)
|
|
145
|
+
|
|
146
|
+
def _batch(
|
|
147
|
+
self,
|
|
148
|
+
path: str,
|
|
149
|
+
field: str,
|
|
150
|
+
kind: Literal["ip", "email", "phone"],
|
|
151
|
+
subjects: Sequence[Optional[str]],
|
|
152
|
+
extra: Mapping[str, Any],
|
|
153
|
+
) -> BatchResult:
|
|
154
|
+
if isinstance(subjects, (str, bytes)):
|
|
155
|
+
raise InvalidArgumentError(f"{field} must be a list of values, not a single string")
|
|
156
|
+
if len(subjects) == 0:
|
|
157
|
+
raise InvalidArgumentError(f"{field} must not be empty")
|
|
158
|
+
cleaned: list[Optional[str]] = []
|
|
159
|
+
for index, item in enumerate(subjects):
|
|
160
|
+
if item is not None and not isinstance(item, str):
|
|
161
|
+
raise InvalidArgumentError(f"{field}[{index}] must be a string or None")
|
|
162
|
+
cleaned.append(item.strip() if item is not None else None)
|
|
163
|
+
items: list[BatchItem] = []
|
|
164
|
+
credits = 0
|
|
165
|
+
for start in range(0, len(cleaned), BATCH_MAX):
|
|
166
|
+
body: dict[str, Any] = {field: cleaned[start : start + BATCH_MAX], **extra}
|
|
167
|
+
envelope = self._transport.post(path, body)
|
|
168
|
+
if not isinstance(envelope.get("results"), list):
|
|
169
|
+
raise UnexpectedResponseError(200, "Missing results", dict(envelope))
|
|
170
|
+
part = BatchResult.from_dict(envelope, kind)
|
|
171
|
+
items.extend(part.items)
|
|
172
|
+
credits = part.credits_remaining
|
|
173
|
+
rest = cleaned[start + BATCH_MAX :]
|
|
174
|
+
if rest and _int(envelope.get("credits_remaining")) == 0:
|
|
175
|
+
# The API answers 402 to a request sent with a zero balance, which would
|
|
176
|
+
# discard the results above; mark the unsent items the way the API does.
|
|
177
|
+
items.extend(
|
|
178
|
+
BatchItem(
|
|
179
|
+
subject=item or "",
|
|
180
|
+
success=False,
|
|
181
|
+
result=None,
|
|
182
|
+
error="insufficient_credits",
|
|
183
|
+
)
|
|
184
|
+
for item in rest
|
|
185
|
+
)
|
|
186
|
+
break
|
|
187
|
+
return BatchResult(items=items, credits_remaining=credits)
|
|
188
|
+
|
|
189
|
+
# Account
|
|
190
|
+
|
|
191
|
+
def balance(self) -> int:
|
|
192
|
+
envelope = self._transport.get("/balance")
|
|
193
|
+
credits = _int(envelope.get("credits_remaining"))
|
|
194
|
+
if credits is None:
|
|
195
|
+
raise UnexpectedResponseError(200, "Missing credits_remaining", dict(envelope))
|
|
196
|
+
return credits
|
|
197
|
+
|
|
198
|
+
def my_ip(self) -> str:
|
|
199
|
+
envelope = self._transport.get("/my-ip", authenticated=False)
|
|
200
|
+
ip = envelope.get("ip")
|
|
201
|
+
if not isinstance(ip, str) or not ip:
|
|
202
|
+
raise UnexpectedResponseError(200, "Missing ip", dict(envelope))
|
|
203
|
+
return ip
|
fraudnova/exceptions.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Exception hierarchy for the FraudNova SDK."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def mask_api_key(api_key: str) -> str:
|
|
8
|
+
"""Return a safe, shortened form of an API key for display."""
|
|
9
|
+
if len(api_key) > 12:
|
|
10
|
+
return "fn_…" + api_key[-4:]
|
|
11
|
+
return "fn_…"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FraudNovaError(Exception):
|
|
15
|
+
"""Base class for every error raised by this SDK."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidArgumentError(FraudNovaError, ValueError):
|
|
19
|
+
"""A client-side input problem; no request was sent."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransportError(FraudNovaError):
|
|
23
|
+
"""The HTTP request itself failed (timeout, DNS, TLS)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiError(FraudNovaError):
|
|
27
|
+
"""The API answered with an error status."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
status_code: int,
|
|
32
|
+
api_message: str,
|
|
33
|
+
body: Optional[dict[str, Any]] = None,
|
|
34
|
+
message: Optional[str] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.api_message = api_message
|
|
38
|
+
self.body: dict[str, Any] = body if body is not None else {}
|
|
39
|
+
super().__init__(
|
|
40
|
+
message
|
|
41
|
+
if message is not None
|
|
42
|
+
else f"FraudNova API error (HTTP {status_code}): {api_message}"
|
|
43
|
+
)
|
|
44
|
+
self._init_args: tuple[Any, ...] = (status_code, api_message, body, message)
|
|
45
|
+
|
|
46
|
+
def __reduce__(self) -> "tuple[Any, tuple[Any, ...], dict[str, Any]]":
|
|
47
|
+
# BaseException rebuilds from self.args (the message only), which does not match
|
|
48
|
+
# this constructor; rebuild from the arguments the instance was created with.
|
|
49
|
+
return (type(self), self._init_args, dict(self.__dict__))
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_response(
|
|
53
|
+
cls, status_code: int, text: str, retry_after: Optional[int] = None
|
|
54
|
+
) -> "ApiError":
|
|
55
|
+
try:
|
|
56
|
+
decoded: Any = json.loads(text)
|
|
57
|
+
except (ValueError, RecursionError):
|
|
58
|
+
decoded = None
|
|
59
|
+
is_json = isinstance(decoded, dict)
|
|
60
|
+
body: dict[str, Any] = decoded if isinstance(decoded, dict) else {}
|
|
61
|
+
envelope = is_json and ("success" in body or "message" in body)
|
|
62
|
+
|
|
63
|
+
api_message = ""
|
|
64
|
+
for key in ("message", "error"):
|
|
65
|
+
value = body.get(key)
|
|
66
|
+
if isinstance(value, str) and value != "":
|
|
67
|
+
api_message = value
|
|
68
|
+
break
|
|
69
|
+
if api_message == "":
|
|
70
|
+
stripped = text.strip()
|
|
71
|
+
api_message = stripped[:200] if stripped else f"HTTP {status_code}"
|
|
72
|
+
|
|
73
|
+
full: Optional[str] = (
|
|
74
|
+
None if is_json else f"FraudNova API error (HTTP {status_code}): non-JSON response"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if envelope and status_code == 401:
|
|
78
|
+
return AuthenticationError(status_code, api_message, body, full)
|
|
79
|
+
if envelope and status_code == 402:
|
|
80
|
+
return InsufficientCreditsError(status_code, api_message, body, full)
|
|
81
|
+
if envelope and status_code == 403:
|
|
82
|
+
return AccountSuspendedError(status_code, api_message, body, full)
|
|
83
|
+
if envelope and status_code == 422:
|
|
84
|
+
return ValidationError(status_code, api_message, body, full)
|
|
85
|
+
if status_code == 429:
|
|
86
|
+
return RateLimitError(status_code, api_message, body, retry_after, full)
|
|
87
|
+
if status_code in (502, 503, 504):
|
|
88
|
+
return UpstreamError(
|
|
89
|
+
status_code,
|
|
90
|
+
api_message,
|
|
91
|
+
body,
|
|
92
|
+
status_code == 503 and envelope,
|
|
93
|
+
full,
|
|
94
|
+
)
|
|
95
|
+
return ApiError(status_code, api_message, body, full)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AuthenticationError(ApiError):
|
|
99
|
+
"""401: missing or invalid API key."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InsufficientCreditsError(ApiError):
|
|
103
|
+
"""402: the account has no credits left."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AccountSuspendedError(ApiError):
|
|
107
|
+
"""403: the account is suspended."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ValidationError(ApiError):
|
|
111
|
+
"""422: the request failed validation."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class RateLimitError(ApiError):
|
|
115
|
+
"""429: rate or concurrency limit hit."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
status_code: int,
|
|
120
|
+
api_message: str,
|
|
121
|
+
body: Optional[dict[str, Any]] = None,
|
|
122
|
+
retry_after: Optional[int] = None,
|
|
123
|
+
message: Optional[str] = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().__init__(status_code, api_message, body, message)
|
|
126
|
+
self.retry_after = retry_after
|
|
127
|
+
self._init_args = (status_code, api_message, body, retry_after, message)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def is_concurrency_limit(self) -> bool:
|
|
131
|
+
return "Too many concurrent requests" in self.api_message
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class UpstreamError(ApiError):
|
|
135
|
+
"""502/503/504: the lookup could not be completed."""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
status_code: int,
|
|
140
|
+
api_message: str,
|
|
141
|
+
body: Optional[dict[str, Any]] = None,
|
|
142
|
+
credit_refunded: bool = False,
|
|
143
|
+
message: Optional[str] = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
text = (
|
|
146
|
+
message
|
|
147
|
+
if message is not None
|
|
148
|
+
else f"FraudNova API error (HTTP {status_code}): {api_message}"
|
|
149
|
+
)
|
|
150
|
+
if credit_refunded:
|
|
151
|
+
text += " The credit for this check was refunded."
|
|
152
|
+
super().__init__(status_code, api_message, body, text)
|
|
153
|
+
self.credit_refunded = credit_refunded
|
|
154
|
+
self._init_args = (status_code, api_message, body, credit_refunded, message)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class UnexpectedResponseError(ApiError):
|
|
158
|
+
"""A 2xx response whose body is not the success envelope."""
|
fraudnova/py.typed
ADDED
|
File without changes
|
fraudnova/results.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Typed, leniently parsed results for IP, email and phone checks."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, ClassVar, Literal, Optional, Union
|
|
8
|
+
|
|
9
|
+
from fraudnova.exceptions import InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
_WHOLE = re.compile(r"-?[0-9]+")
|
|
12
|
+
_DECIMAL = re.compile(r"-?[0-9]+\.[0-9]+")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bool(value: Any) -> Optional[bool]:
|
|
16
|
+
return value if isinstance(value, bool) else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _str(value: Any) -> Optional[str]:
|
|
20
|
+
return value if isinstance(value, str) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _int(value: Any) -> Optional[int]:
|
|
24
|
+
"""Ints and whole-number strings as-is; floats and decimal strings round half away from zero."""
|
|
25
|
+
if isinstance(value, bool):
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(value, int):
|
|
28
|
+
return value
|
|
29
|
+
number: Optional[float] = None
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
if _WHOLE.fullmatch(value):
|
|
32
|
+
if len(value) > 20:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
return int(value)
|
|
36
|
+
except ValueError: # beyond Python's int-string digit limit
|
|
37
|
+
return None
|
|
38
|
+
if _DECIMAL.fullmatch(value):
|
|
39
|
+
number = float(value)
|
|
40
|
+
elif isinstance(value, float):
|
|
41
|
+
number = value
|
|
42
|
+
if number is None or not math.isfinite(number):
|
|
43
|
+
return None
|
|
44
|
+
rounded = math.floor(abs(number) + 0.5)
|
|
45
|
+
return -rounded if number < 0 else rounded
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_coerce_int = _int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DateInfo:
|
|
53
|
+
human: Optional[str]
|
|
54
|
+
timestamp: Optional[int]
|
|
55
|
+
iso: Optional[str]
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_value(cls, value: Any) -> Optional["DateInfo"]:
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
return cls(human=value, timestamp=None, iso=None)
|
|
61
|
+
if isinstance(value, Mapping):
|
|
62
|
+
return cls(
|
|
63
|
+
human=_str(value.get("human")),
|
|
64
|
+
timestamp=_int(value.get("timestamp")),
|
|
65
|
+
iso=_str(value.get("iso")),
|
|
66
|
+
)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _FlaggableResult:
|
|
71
|
+
DEFAULT_FLAG_ON: ClassVar[tuple[str, ...]] = ()
|
|
72
|
+
raw: dict[str, Any]
|
|
73
|
+
|
|
74
|
+
def flag_reasons(
|
|
75
|
+
self, risk_threshold: int = 75, flag_on: Optional[Sequence[str]] = None
|
|
76
|
+
) -> list[str]:
|
|
77
|
+
if isinstance(flag_on, (str, bytes)):
|
|
78
|
+
raise InvalidArgumentError("flag_on must be a list of field names, not a single string")
|
|
79
|
+
reasons: list[str] = []
|
|
80
|
+
if (_int(self.raw.get("fraud_score")) or 0) >= risk_threshold:
|
|
81
|
+
reasons.append("fraud_score")
|
|
82
|
+
names = self.DEFAULT_FLAG_ON if flag_on is None else flag_on
|
|
83
|
+
reasons.extend(name for name in names if self.raw.get(name) is True)
|
|
84
|
+
return reasons
|
|
85
|
+
|
|
86
|
+
def flagged(self, risk_threshold: int = 75, flag_on: Optional[Sequence[str]] = None) -> bool:
|
|
87
|
+
return bool(self.flag_reasons(risk_threshold, flag_on))
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
return dict(self.raw)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class IpResult(_FlaggableResult):
|
|
95
|
+
DEFAULT_FLAG_ON: ClassVar[tuple[str, ...]] = ("proxy", "vpn", "tor")
|
|
96
|
+
|
|
97
|
+
fraud_score: int
|
|
98
|
+
proxy: Optional[bool]
|
|
99
|
+
vpn: Optional[bool]
|
|
100
|
+
tor: Optional[bool]
|
|
101
|
+
active_vpn: Optional[bool]
|
|
102
|
+
active_tor: Optional[bool]
|
|
103
|
+
recent_abuse: Optional[bool]
|
|
104
|
+
bot_status: Optional[bool]
|
|
105
|
+
country_code: Optional[str]
|
|
106
|
+
region: Optional[str]
|
|
107
|
+
city: Optional[str]
|
|
108
|
+
isp: Optional[str]
|
|
109
|
+
organization: Optional[str]
|
|
110
|
+
asn: Optional[int]
|
|
111
|
+
operating_system: Optional[str]
|
|
112
|
+
browser: Optional[str]
|
|
113
|
+
device_brand: Optional[str]
|
|
114
|
+
device_model: Optional[str]
|
|
115
|
+
credits_remaining: Optional[int]
|
|
116
|
+
raw: dict[str, Any] = field(repr=False)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_dict(
|
|
120
|
+
cls, data: Mapping[str, Any], credits_remaining: Optional[int] = None
|
|
121
|
+
) -> "IpResult":
|
|
122
|
+
raw = data if isinstance(data, dict) else dict(data)
|
|
123
|
+
return cls(
|
|
124
|
+
fraud_score=_int(raw.get("fraud_score")) or 0,
|
|
125
|
+
proxy=_bool(raw.get("proxy")),
|
|
126
|
+
vpn=_bool(raw.get("vpn")),
|
|
127
|
+
tor=_bool(raw.get("tor")),
|
|
128
|
+
active_vpn=_bool(raw.get("active_vpn")),
|
|
129
|
+
active_tor=_bool(raw.get("active_tor")),
|
|
130
|
+
recent_abuse=_bool(raw.get("recent_abuse")),
|
|
131
|
+
bot_status=_bool(raw.get("bot_status")),
|
|
132
|
+
country_code=_str(raw.get("country_code")),
|
|
133
|
+
region=_str(raw.get("region")),
|
|
134
|
+
city=_str(raw.get("city")),
|
|
135
|
+
isp=_str(raw.get("ISP")),
|
|
136
|
+
organization=_str(raw.get("organization")),
|
|
137
|
+
asn=_int(raw.get("ASN")),
|
|
138
|
+
operating_system=_str(raw.get("operating_system")),
|
|
139
|
+
browser=_str(raw.get("browser")),
|
|
140
|
+
device_brand=_str(raw.get("device_brand")),
|
|
141
|
+
device_model=_str(raw.get("device_model")),
|
|
142
|
+
credits_remaining=credits_remaining,
|
|
143
|
+
raw=raw,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class EmailResult(_FlaggableResult):
|
|
149
|
+
DEFAULT_FLAG_ON: ClassVar[tuple[str, ...]] = ("disposable",)
|
|
150
|
+
|
|
151
|
+
fraud_score: int
|
|
152
|
+
valid: Optional[bool]
|
|
153
|
+
disposable: Optional[bool]
|
|
154
|
+
catch_all: Optional[bool]
|
|
155
|
+
generic: Optional[bool]
|
|
156
|
+
common: Optional[bool]
|
|
157
|
+
dns_valid: Optional[bool]
|
|
158
|
+
honeypot: Optional[bool]
|
|
159
|
+
frequent_complainer: Optional[bool]
|
|
160
|
+
suspect: Optional[bool]
|
|
161
|
+
recent_abuse: Optional[bool]
|
|
162
|
+
leaked: Optional[bool]
|
|
163
|
+
risky_tld: Optional[bool]
|
|
164
|
+
spf_record: Optional[bool]
|
|
165
|
+
dmarc_record: Optional[bool]
|
|
166
|
+
smtp_score: Optional[int]
|
|
167
|
+
overall_score: Optional[int]
|
|
168
|
+
deliverability: Optional[str]
|
|
169
|
+
spam_trap_score: Optional[str]
|
|
170
|
+
result_type: Optional[str]
|
|
171
|
+
sanitized_email: Optional[str]
|
|
172
|
+
first_seen: Optional[DateInfo]
|
|
173
|
+
domain_age: Optional[DateInfo]
|
|
174
|
+
credits_remaining: Optional[int]
|
|
175
|
+
raw: dict[str, Any] = field(repr=False)
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_dict(
|
|
179
|
+
cls, data: Mapping[str, Any], credits_remaining: Optional[int] = None
|
|
180
|
+
) -> "EmailResult":
|
|
181
|
+
raw = data if isinstance(data, dict) else dict(data)
|
|
182
|
+
return cls(
|
|
183
|
+
fraud_score=_int(raw.get("fraud_score")) or 0,
|
|
184
|
+
valid=_bool(raw.get("valid")),
|
|
185
|
+
disposable=_bool(raw.get("disposable")),
|
|
186
|
+
catch_all=_bool(raw.get("catch_all")),
|
|
187
|
+
generic=_bool(raw.get("generic")),
|
|
188
|
+
common=_bool(raw.get("common")),
|
|
189
|
+
dns_valid=_bool(raw.get("dns_valid")),
|
|
190
|
+
honeypot=_bool(raw.get("honeypot")),
|
|
191
|
+
frequent_complainer=_bool(raw.get("frequent_complainer")),
|
|
192
|
+
suspect=_bool(raw.get("suspect")),
|
|
193
|
+
recent_abuse=_bool(raw.get("recent_abuse")),
|
|
194
|
+
leaked=_bool(raw.get("leaked")),
|
|
195
|
+
risky_tld=_bool(raw.get("risky_tld")),
|
|
196
|
+
spf_record=_bool(raw.get("spf_record")),
|
|
197
|
+
dmarc_record=_bool(raw.get("dmarc_record")),
|
|
198
|
+
smtp_score=_int(raw.get("smtp_score")),
|
|
199
|
+
overall_score=_int(raw.get("overall_score")),
|
|
200
|
+
deliverability=_str(raw.get("deliverability")),
|
|
201
|
+
spam_trap_score=_str(raw.get("spam_trap_score")),
|
|
202
|
+
result_type=_str(raw.get("result_type")),
|
|
203
|
+
sanitized_email=_str(raw.get("sanitized_email")),
|
|
204
|
+
first_seen=DateInfo.from_value(raw.get("first_seen")),
|
|
205
|
+
domain_age=DateInfo.from_value(raw.get("domain_age")),
|
|
206
|
+
credits_remaining=credits_remaining,
|
|
207
|
+
raw=raw,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class PhoneResult(_FlaggableResult):
|
|
213
|
+
DEFAULT_FLAG_ON: ClassVar[tuple[str, ...]] = ("VOIP", "risky")
|
|
214
|
+
|
|
215
|
+
fraud_score: int
|
|
216
|
+
valid: Optional[bool]
|
|
217
|
+
recent_abuse: Optional[bool]
|
|
218
|
+
voip: Optional[bool]
|
|
219
|
+
prepaid: Optional[bool]
|
|
220
|
+
risky: Optional[bool]
|
|
221
|
+
active: Optional[bool]
|
|
222
|
+
do_not_call: Optional[bool]
|
|
223
|
+
leaked: Optional[bool]
|
|
224
|
+
spammer: Optional[bool]
|
|
225
|
+
dialing_code: Optional[int]
|
|
226
|
+
carrier: Optional[str]
|
|
227
|
+
line_type: Optional[str]
|
|
228
|
+
country: Optional[str]
|
|
229
|
+
region: Optional[str]
|
|
230
|
+
city: Optional[str]
|
|
231
|
+
timezone: Optional[str]
|
|
232
|
+
zip_code: Optional[str]
|
|
233
|
+
active_status: Optional[str]
|
|
234
|
+
formatted: Optional[str]
|
|
235
|
+
local_format: Optional[str]
|
|
236
|
+
credits_remaining: Optional[int]
|
|
237
|
+
raw: dict[str, Any] = field(repr=False)
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def from_dict(
|
|
241
|
+
cls, data: Mapping[str, Any], credits_remaining: Optional[int] = None
|
|
242
|
+
) -> "PhoneResult":
|
|
243
|
+
raw = data if isinstance(data, dict) else dict(data)
|
|
244
|
+
return cls(
|
|
245
|
+
fraud_score=_int(raw.get("fraud_score")) or 0,
|
|
246
|
+
valid=_bool(raw.get("valid")),
|
|
247
|
+
recent_abuse=_bool(raw.get("recent_abuse")),
|
|
248
|
+
voip=_bool(raw.get("VOIP")),
|
|
249
|
+
prepaid=_bool(raw.get("prepaid")),
|
|
250
|
+
risky=_bool(raw.get("risky")),
|
|
251
|
+
active=_bool(raw.get("active")),
|
|
252
|
+
do_not_call=_bool(raw.get("do_not_call")),
|
|
253
|
+
leaked=_bool(raw.get("leaked")),
|
|
254
|
+
spammer=_bool(raw.get("spammer")),
|
|
255
|
+
dialing_code=_int(raw.get("dialing_code")),
|
|
256
|
+
carrier=_str(raw.get("carrier")),
|
|
257
|
+
line_type=_str(raw.get("line_type")),
|
|
258
|
+
country=_str(raw.get("country")),
|
|
259
|
+
region=_str(raw.get("region")),
|
|
260
|
+
city=_str(raw.get("city")),
|
|
261
|
+
timezone=_str(raw.get("timezone")),
|
|
262
|
+
zip_code=_str(raw.get("zip_code")),
|
|
263
|
+
active_status=_str(raw.get("active_status")),
|
|
264
|
+
formatted=_str(raw.get("formatted")),
|
|
265
|
+
local_format=_str(raw.get("local_format")),
|
|
266
|
+
credits_remaining=credits_remaining,
|
|
267
|
+
raw=raw,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
Result = Union[IpResult, EmailResult, PhoneResult]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class BatchItem:
|
|
276
|
+
subject: str
|
|
277
|
+
success: bool
|
|
278
|
+
result: Optional[Result]
|
|
279
|
+
error: Optional[str]
|
|
280
|
+
|
|
281
|
+
def is_invalid(self) -> bool:
|
|
282
|
+
return self.error in ("invalid_ip", "invalid_email", "invalid_phone")
|
|
283
|
+
|
|
284
|
+
def is_insufficient_credits(self) -> bool:
|
|
285
|
+
return self.error == "insufficient_credits"
|
|
286
|
+
|
|
287
|
+
def is_upstream_failed(self) -> bool:
|
|
288
|
+
return self.error == "upstream_failed"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class BatchResult:
|
|
293
|
+
items: list[BatchItem]
|
|
294
|
+
credits_remaining: int
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def from_dict(
|
|
298
|
+
cls, envelope: Mapping[str, Any], kind: Literal["ip", "email", "phone"]
|
|
299
|
+
) -> "BatchResult":
|
|
300
|
+
raw_items = envelope.get("results")
|
|
301
|
+
items: list[BatchItem] = []
|
|
302
|
+
if isinstance(raw_items, list):
|
|
303
|
+
for item in raw_items:
|
|
304
|
+
if not isinstance(item, dict):
|
|
305
|
+
continue
|
|
306
|
+
success = _bool(item.get("success")) or False
|
|
307
|
+
data = item.get("data")
|
|
308
|
+
result: Optional[Result] = None
|
|
309
|
+
if success and isinstance(data, dict):
|
|
310
|
+
if kind == "ip":
|
|
311
|
+
result = IpResult.from_dict(data)
|
|
312
|
+
elif kind == "email":
|
|
313
|
+
result = EmailResult.from_dict(data)
|
|
314
|
+
else:
|
|
315
|
+
result = PhoneResult.from_dict(data)
|
|
316
|
+
items.append(
|
|
317
|
+
BatchItem(
|
|
318
|
+
subject=_str(item.get(kind)) or "",
|
|
319
|
+
success=success,
|
|
320
|
+
result=result,
|
|
321
|
+
error=_str(item.get("error")),
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
return cls(items=items, credits_remaining=_int(envelope.get("credits_remaining")) or 0)
|
|
325
|
+
|
|
326
|
+
def successful(self) -> list[BatchItem]:
|
|
327
|
+
return [i for i in self.items if i.success]
|
|
328
|
+
|
|
329
|
+
def failed(self) -> list[BatchItem]:
|
|
330
|
+
return [i for i in self.items if not i.success]
|
|
331
|
+
|
|
332
|
+
def has_failures(self) -> bool:
|
|
333
|
+
return any(not i.success for i in self.items)
|
|
334
|
+
|
|
335
|
+
def __len__(self) -> int:
|
|
336
|
+
return len(self.items)
|
|
337
|
+
|
|
338
|
+
def __iter__(self) -> Iterator[BatchItem]:
|
|
339
|
+
return iter(self.items)
|
|
340
|
+
|
|
341
|
+
def __getitem__(self, index: int) -> BatchItem:
|
|
342
|
+
return self.items[index]
|
fraudnova/retry.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Opt-in retry policy for rate-limited (429) requests."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
from fraudnova.exceptions import InvalidArgumentError, RateLimitError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_retry_after(value: Optional[str], now: Optional[datetime] = None) -> Optional[int]:
|
|
14
|
+
"""Parse a Retry-After header (delta-seconds or HTTP-date) into whole seconds."""
|
|
15
|
+
if value is None:
|
|
16
|
+
return None
|
|
17
|
+
text = value.strip()
|
|
18
|
+
if text == "":
|
|
19
|
+
return None
|
|
20
|
+
if text.isascii() and text.isdigit():
|
|
21
|
+
try:
|
|
22
|
+
return int(text)
|
|
23
|
+
except ValueError:
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
when = parsedate_to_datetime(text)
|
|
27
|
+
except (TypeError, ValueError, IndexError):
|
|
28
|
+
return None
|
|
29
|
+
if when.tzinfo is None:
|
|
30
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
31
|
+
current = now if now is not None else datetime.now(timezone.utc)
|
|
32
|
+
return max(0, int((when - current).total_seconds()))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class RetryPolicy:
|
|
37
|
+
max_attempts: int = 3
|
|
38
|
+
concurrency_delays: Sequence[float] = (1, 2, 4)
|
|
39
|
+
rate_limit_delays: Sequence[float] = (5, 15, 40)
|
|
40
|
+
honour_retry_after: bool = True
|
|
41
|
+
max_retry_after: float = 60
|
|
42
|
+
sleeper: Callable[[float], None] = time.sleep
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if self.max_attempts < 1:
|
|
46
|
+
raise InvalidArgumentError("max_attempts must be at least 1.")
|
|
47
|
+
if self.max_retry_after < 0:
|
|
48
|
+
raise InvalidArgumentError("max_retry_after must not be negative.")
|
|
49
|
+
|
|
50
|
+
def delay_for(self, error: RateLimitError, attempt: int) -> Optional[float]:
|
|
51
|
+
"""Seconds to wait before retrying after `attempt` (1-based) failed, or None to stop."""
|
|
52
|
+
if attempt >= self.max_attempts:
|
|
53
|
+
return None
|
|
54
|
+
if self.honour_retry_after and error.retry_after is not None:
|
|
55
|
+
if error.retry_after > self.max_retry_after:
|
|
56
|
+
return None
|
|
57
|
+
return float(error.retry_after)
|
|
58
|
+
schedule = self.concurrency_delays if error.is_concurrency_limit else self.rate_limit_delays
|
|
59
|
+
index = attempt - 1
|
|
60
|
+
if index >= len(schedule):
|
|
61
|
+
return None
|
|
62
|
+
return float(schedule[index])
|
|
63
|
+
|
|
64
|
+
def sleep(self, seconds: float) -> None:
|
|
65
|
+
self.sleeper(seconds)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fraudnova
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the FraudNova fraud-detection API (IP, email and phone checks)
|
|
5
|
+
Project-URL: Homepage, https://fraudnova.com
|
|
6
|
+
Project-URL: Documentation, https://fraudnova.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/dahleedam/fraudnova-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/dahleedam/fraudnova-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: FraudNova <support@fraudnova.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-client,email-validation,fraud,fraud-detection,ip,phone-validation,proxy,vpn
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx<1.0,>=0.26
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# fraudnova
|
|
29
|
+
|
|
30
|
+
[FraudNova](https://fraudnova.com) is a fraud-detection API. It returns a fraud score and risk flags for any IP address, email address or phone number, so your application can decide whether to accept, review or block a signup, lead or order. This is the official Python client.
|
|
31
|
+
|
|
32
|
+
[](https://github.com/dahleedam/fraudnova-python/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/fraudnova/)
|
|
34
|
+
[](https://pypi.org/project/fraudnova/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Python 3.9 or later
|
|
40
|
+
- [httpx](https://www.python-httpx.org/) 0.26 or later, installed automatically
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install fraudnova
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
You need an API key. [Register](https://fraudnova.com/register) to get free trial credits, then create a key at [fraudnova.com/dashboard/api-keys](https://fraudnova.com/dashboard/api-keys). The key starts with `fn_` and is shown once, so copy it right away.
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import fraudnova
|
|
54
|
+
|
|
55
|
+
client = fraudnova.Client("fn_your_api_key")
|
|
56
|
+
result = client.check_ip("8.8.8.8")
|
|
57
|
+
if result.flagged():
|
|
58
|
+
... # review or block
|
|
59
|
+
print(result.fraud_score)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Checking IP addresses, emails and phone numbers
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import fraudnova
|
|
66
|
+
|
|
67
|
+
client = fraudnova.Client("fn_your_api_key")
|
|
68
|
+
|
|
69
|
+
ip = client.check_ip("8.8.8.8")
|
|
70
|
+
email = client.check_email("someone@example.com")
|
|
71
|
+
phone = client.check_phone("+1 817 679 1808")
|
|
72
|
+
phone = client.check_phone("8176791808", country="US") # optional two-letter country hint
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`check_ip()` takes optional keyword arguments. Leave one out to use the API default.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
result = client.check_ip(
|
|
79
|
+
visitor_ip,
|
|
80
|
+
strictness=1, # 0 = lenient, 3 = strict
|
|
81
|
+
mobile=False, # the request comes from a mobile device
|
|
82
|
+
user_agent=request.headers.get("User-Agent"), # enables the device and browser fields
|
|
83
|
+
user_language=request.headers.get("Accept-Language", "").split(",")[0].strip() or None,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`user_language` takes a short language tag such as `en-US`, not the whole `Accept-Language` header, so the example sends only the first tag.
|
|
88
|
+
|
|
89
|
+
An empty value, or a `strictness` that is not an integer from 0 to 3, raises `fraudnova.InvalidArgumentError` before any request is sent.
|
|
90
|
+
|
|
91
|
+
## Batch checks
|
|
92
|
+
|
|
93
|
+
`check_ips()`, `check_emails()` and `check_phones()` score a list of values. The list can be any length: the client splits it into requests of 100 (`fraudnova.BATCH_MAX`) and joins the answers in input order.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
batch = client.check_ips(ips)
|
|
97
|
+
|
|
98
|
+
for item in batch.successful():
|
|
99
|
+
print(item.subject, item.result.fraud_score)
|
|
100
|
+
|
|
101
|
+
for item in batch.failed():
|
|
102
|
+
print(item.subject, "failed:", item.error)
|
|
103
|
+
|
|
104
|
+
print("Credits left:", batch.credits_remaining)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`check_ips()` takes the same keyword options as `check_ip()`, and `check_phones()` takes one `country` hint for the whole batch. `BatchResult` is iterable and supports `len()` and indexing; each `BatchItem` has `subject`, `success`, `result` (an `IpResult`, `EmailResult` or `PhoneResult`, or `None` on failure) and `error`. `has_failures()` tells you whether any item failed.
|
|
108
|
+
|
|
109
|
+
Batches are split into requests of 100. When a request reports zero credits remaining, the client stops and marks the remaining items `insufficient_credits` instead of sending a request that would fail. Any other error on a later request (a 429 with no retry policy or with the retries used up, a 503, or a `TransportError`) propagates as an exception, and the results of the earlier requests are discarded even though they were billed. If you need control over each request, slice the list to 100 yourself:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
for start in range(0, len(ips), fraudnova.BATCH_MAX):
|
|
113
|
+
batch = client.check_ips(ips[start : start + fraudnova.BATCH_MAX])
|
|
114
|
+
... # handle this request's result before sending the next
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Items must be strings or `None`; any other type raises `InvalidArgumentError` before the request is sent. One bad value never fails the batch, so an empty string or `None` comes back as a failed item. A failed item carries one of these error codes:
|
|
118
|
+
|
|
119
|
+
| `error` | Helper | Meaning |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| `invalid_ip`, `invalid_email`, `invalid_phone` | `is_invalid()` | The value is not a valid IP address, email address or phone number. |
|
|
122
|
+
| `insufficient_credits` | `is_insufficient_credits()` | Your balance ran out partway through the batch. |
|
|
123
|
+
| `upstream_failed` | `is_upstream_failed()` | The lookup failed. The item was not charged. |
|
|
124
|
+
|
|
125
|
+
## Results
|
|
126
|
+
|
|
127
|
+
`check_ip()`, `check_email()` and `check_phone()` return an `IpResult`, `EmailResult` or `PhoneResult`. Each has the API's fields as snake_case attributes, for example `fraud_score`, `recent_abuse` and `country_code`. `fraud_score` is always an integer (0 when the API does not return one); every other field is optional (`None`), because the API does not return every field for every check.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
ip = client.check_ip("8.8.8.8")
|
|
131
|
+
ip.proxy # Optional[bool]
|
|
132
|
+
ip.vpn # Optional[bool]
|
|
133
|
+
ip.country_code # Optional[str], for example "US"
|
|
134
|
+
ip.isp # Optional[str] (API field ISP)
|
|
135
|
+
ip.asn # Optional[int] (API field ASN)
|
|
136
|
+
|
|
137
|
+
email = client.check_email("someone@example.com")
|
|
138
|
+
email.disposable # Optional[bool]
|
|
139
|
+
email.deliverability # Optional[str]
|
|
140
|
+
email.first_seen # Optional[DateInfo], also domain_age
|
|
141
|
+
|
|
142
|
+
phone = client.check_phone("+18176791808")
|
|
143
|
+
phone.voip # Optional[bool] (API field VOIP)
|
|
144
|
+
phone.line_type # Optional[str]
|
|
145
|
+
phone.carrier # Optional[str]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The email dates `first_seen` and `domain_age` are `DateInfo` objects with `human`, `timestamp` and `iso`.
|
|
149
|
+
|
|
150
|
+
`to_dict()` returns the full API response for that check, with the original field names, including any field the client does not map to an attribute:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
fields = client.check_ip("8.8.8.8").to_dict()
|
|
154
|
+
# {"fraud_score": 20, "proxy": False, "ISP": "Google", ...}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`credits_remaining` holds your balance after the check. It is `None` on results inside a batch; read `BatchResult.credits_remaining` there instead.
|
|
158
|
+
|
|
159
|
+
## The `flagged()` helper
|
|
160
|
+
|
|
161
|
+
`flagged()` returns `True` when the `fraud_score` is at or above a risk threshold (default 75), or when any of a set of fields is `True`. `flag_reasons()` lists which of those fired, for example `["fraud_score", "proxy"]`. Both take the same two optional arguments, `risk_threshold` and `flag_on`.
|
|
162
|
+
|
|
163
|
+
| Check | Fields checked by default |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| IP address | `proxy`, `vpn`, `tor` |
|
|
166
|
+
| Email address | `disposable` |
|
|
167
|
+
| Phone number | `VOIP`, `risky` |
|
|
168
|
+
|
|
169
|
+
Pass your own threshold and fields with the raw API field names (`VOIP`, not `voip`):
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
result = client.check_phone("+18176791808")
|
|
173
|
+
|
|
174
|
+
if result.flagged(risk_threshold=85, flag_on=["VOIP", "risky", "recent_abuse"]):
|
|
175
|
+
reasons = result.flag_reasons(85, ["VOIP", "risky", "recent_abuse"])
|
|
176
|
+
...
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The defaults are available as `IpResult.DEFAULT_FLAG_ON`, `EmailResult.DEFAULT_FLAG_ON` and `PhoneResult.DEFAULT_FLAG_ON`.
|
|
180
|
+
|
|
181
|
+
## Balance and your IP
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
credits = client.balance() # int, costs no credits
|
|
185
|
+
ip = client.my_ip() # str, the public IP your server calls from; no API key needed
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Errors
|
|
189
|
+
|
|
190
|
+
Every exception the client raises is a `fraudnova.FraudNovaError`, so one `except` handles them all:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from fraudnova import FraudNovaError, InsufficientCreditsError
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
result = client.check_ip(visitor_ip)
|
|
197
|
+
except InsufficientCreditsError:
|
|
198
|
+
... # buy a credit pack in the FraudNova dashboard
|
|
199
|
+
except FraudNovaError as error:
|
|
200
|
+
... # log it and let the request through, or hold it for review
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Exception | Status | When |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| `AuthenticationError` | 401 | The API key is missing, invalid or revoked. |
|
|
206
|
+
| `InsufficientCreditsError` | 402 | Your balance is too low for the check. |
|
|
207
|
+
| `AccountSuspendedError` | 403 | The account is suspended. |
|
|
208
|
+
| `ValidationError` | 422 | The API rejected the request parameters. |
|
|
209
|
+
| `RateLimitError` | 429 | Too many requests, or too many checks in flight at once. `retry_after` and `is_concurrency_limit` tell you which. |
|
|
210
|
+
| `UpstreamError` | 502, 503, 504 | The lookup failed. On a 503 the credit is refunded automatically and `credit_refunded` is `True`. |
|
|
211
|
+
| `UnexpectedResponseError` | 2xx | The response could not be read as a FraudNova response. |
|
|
212
|
+
| `ApiError` | any other error status | Base class of every exception with a status. `status_code`, `api_message` and `body` are on all of them. A 401, 402, 403 or 422 whose body is not a FraudNova JSON response (for example a proxy error page) is a plain `ApiError`. |
|
|
213
|
+
| `TransportError` | none | The HTTP client failed, for example a timeout or DNS error. The original exception is `__cause__`. |
|
|
214
|
+
| `InvalidArgumentError` | none | A value was empty or out of range; no request was sent. It is also a `ValueError`. |
|
|
215
|
+
|
|
216
|
+
## Retries
|
|
217
|
+
|
|
218
|
+
The client does not retry by default. To retry HTTP 429 responses, pass a `RetryPolicy`:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from fraudnova import Client, RetryPolicy
|
|
222
|
+
|
|
223
|
+
client = Client("fn_your_api_key", retry=RetryPolicy(max_attempts=3))
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`max_attempts` counts the first attempt, so `3` means up to two retries. Only 429 is retried; every other error is raised right away. The wait is the `Retry-After` header when the response has one, otherwise a short schedule for the concurrency limit and a longer one for the rate limit. When the attempts run out, the `RateLimitError` is raised. A `Retry-After` longer than `max_retry_after` (60 seconds by default) is not waited for; the `RateLimitError` is raised right away. Batch requests are safe to retry, because a 429 is returned before any item is checked.
|
|
227
|
+
|
|
228
|
+
Retries can make one call slow. With `RetryPolicy(max_attempts=3)` and the default `max_retry_after=60`, one call can sleep for up to about two minutes, plus three timeouts. Inside a web request, use a small `max_retry_after` (for example `2`) and a short `timeout`, and keep the full policy for background jobs.
|
|
229
|
+
|
|
230
|
+
## Bring your own HTTP client
|
|
231
|
+
|
|
232
|
+
Inject your own `httpx.Client`. Timeouts, proxies and TLS settings belong to the HTTP client, so set them there:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
import httpx
|
|
236
|
+
from fraudnova import Client
|
|
237
|
+
|
|
238
|
+
http = httpx.Client(timeout=5.0, proxy="http://proxy.internal:3128")
|
|
239
|
+
client = Client("fn_your_api_key", http_client=http)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Without an injected client, the SDK creates its own `httpx.Client` with a 30 second timeout (change it with `Client(..., timeout=10.0)`).
|
|
243
|
+
|
|
244
|
+
Use the client as a context manager, or call `close()`, to release connections when you are done. `close()` only closes an `httpx.Client` the SDK created; one you inject stays yours to close.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
with Client("fn_your_api_key") as client:
|
|
248
|
+
client.check_email("someone@example.com")
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Testing
|
|
252
|
+
|
|
253
|
+
Inject an `httpx.Client` backed by `httpx.MockTransport`, so your tests send no real request:
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import httpx
|
|
257
|
+
from fraudnova import Client
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
261
|
+
return httpx.Response(
|
|
262
|
+
200,
|
|
263
|
+
json={
|
|
264
|
+
"success": True,
|
|
265
|
+
"data": {"fraud_score": 12, "proxy": False},
|
|
266
|
+
"credits_remaining": 99,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
http = httpx.Client(transport=httpx.MockTransport(handler))
|
|
272
|
+
client = Client("fn_test", http_client=http)
|
|
273
|
+
|
|
274
|
+
assert client.check_ip("8.8.8.8").fraud_score == 12
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Credits and limits
|
|
278
|
+
|
|
279
|
+
One check costs one credit, whichever type it is. Failed checks are refunded. You get free trial credits when you verify your email address, and you buy more as credit packs, with no subscription. A batch costs one credit per successful item.
|
|
280
|
+
|
|
281
|
+
The API limits requests per minute for each key and checks in flight for each account. The current limits are in the [API documentation](https://fraudnova.com/docs).
|
|
282
|
+
|
|
283
|
+
## Resources
|
|
284
|
+
|
|
285
|
+
* [FraudNova API documentation](https://fraudnova.com/docs)
|
|
286
|
+
* [Examples repository](https://github.com/dahleedam/fraudnova-examples)
|
|
287
|
+
* [Status](https://fraudnova.com/status)
|
|
288
|
+
* [Changelog](https://fraudnova.com/changelog)
|
|
289
|
+
* Support: [support@fraudnova.com](mailto:support@fraudnova.com)
|
|
290
|
+
|
|
291
|
+
## Licence
|
|
292
|
+
|
|
293
|
+
Released under the MIT license. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fraudnova/__init__.py,sha256=9B1-ldMYIXs5GASxQw89X2lGNatTaERiRHTSXc6J9YQ,1043
|
|
2
|
+
fraudnova/_transport.py,sha256=SKvRjeJhZ9Fo7jCHI0fGHMl9shjfcTLplqw6-aS86Fc,3950
|
|
3
|
+
fraudnova/_version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
4
|
+
fraudnova/client.py,sha256=MCLQgiBEmuWf4-zl7l9zIdUTh46pOzfi2N8ycRpDjiE,7697
|
|
5
|
+
fraudnova/exceptions.py,sha256=9xCcDxhkvS288tN1pVxUHKhz_wVUD3rg-oadTIqJZ_Y,5276
|
|
6
|
+
fraudnova/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
fraudnova/results.py,sha256=LN1J5NBJlxtO0rVzHHTAg7PRfNbsxVPnbkonZfidhC0,11780
|
|
8
|
+
fraudnova/retry.py,sha256=MXKpX9Zf70A6iZbD69PPoHXDQuQEcBwp6brvQF_gbYI,2387
|
|
9
|
+
fraudnova-1.0.0.dist-info/METADATA,sha256=91525Ewa1hLjGEETfrhyX3yqDOhs9K1kT_jCNssiHk8,13308
|
|
10
|
+
fraudnova-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
fraudnova-1.0.0.dist-info/licenses/LICENSE,sha256=iBwexUBqLwwXvEiXfBlWhMzIQjbTcQqjPQGFf6uRkZI,1066
|
|
12
|
+
fraudnova-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FraudNova
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|