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 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
+ ]
@@ -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
@@ -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
+ [![CI](https://github.com/dahleedam/fraudnova-python/actions/workflows/ci.yml/badge.svg)](https://github.com/dahleedam/fraudnova-python/actions/workflows/ci.yml)
33
+ [![PyPI version](https://img.shields.io/pypi/v/fraudnova)](https://pypi.org/project/fraudnova/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/fraudnova)](https://pypi.org/project/fraudnova/)
35
+ [![License](https://img.shields.io/badge/license-MIT-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.