skalaio 0.2.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.
- skala/__init__.py +38 -0
- skala/_client.py +140 -0
- skala/_errors.py +64 -0
- skala/_types.py +90 -0
- skalaio-0.2.0.dist-info/METADATA +107 -0
- skalaio-0.2.0.dist-info/RECORD +7 -0
- skalaio-0.2.0.dist-info/WHEEL +4 -0
skala/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from skala._client import Skala, DEFAULT_BASE_URL
|
|
2
|
+
from skala._errors import (
|
|
3
|
+
SkalaApiError,
|
|
4
|
+
SkalaConfigError,
|
|
5
|
+
SkalaError,
|
|
6
|
+
SkalaNetworkError,
|
|
7
|
+
SkalaTimeoutError,
|
|
8
|
+
)
|
|
9
|
+
from skala._types import (
|
|
10
|
+
OutcomeRequest,
|
|
11
|
+
OutcomeResponse,
|
|
12
|
+
ScoreFallbackResponse,
|
|
13
|
+
ScoreRequest,
|
|
14
|
+
ScoreResponse,
|
|
15
|
+
SkalaDecision,
|
|
16
|
+
SkalaEventType,
|
|
17
|
+
SkalaOptions,
|
|
18
|
+
SkalaOutcome,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Skala",
|
|
23
|
+
"DEFAULT_BASE_URL",
|
|
24
|
+
"SkalaApiError",
|
|
25
|
+
"SkalaConfigError",
|
|
26
|
+
"SkalaError",
|
|
27
|
+
"SkalaNetworkError",
|
|
28
|
+
"SkalaTimeoutError",
|
|
29
|
+
"OutcomeRequest",
|
|
30
|
+
"OutcomeResponse",
|
|
31
|
+
"ScoreFallbackResponse",
|
|
32
|
+
"ScoreRequest",
|
|
33
|
+
"ScoreResponse",
|
|
34
|
+
"SkalaDecision",
|
|
35
|
+
"SkalaEventType",
|
|
36
|
+
"SkalaOptions",
|
|
37
|
+
"SkalaOutcome",
|
|
38
|
+
]
|
skala/_client.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from skala._errors import (
|
|
11
|
+
SkalaApiError,
|
|
12
|
+
SkalaConfigError,
|
|
13
|
+
SkalaNetworkError,
|
|
14
|
+
SkalaTimeoutError,
|
|
15
|
+
)
|
|
16
|
+
from skala._types import (
|
|
17
|
+
OutcomeRequest,
|
|
18
|
+
OutcomeResponse,
|
|
19
|
+
ScoreFallbackResponse,
|
|
20
|
+
ScoreRequest,
|
|
21
|
+
ScoreResponse,
|
|
22
|
+
SkalaOptions,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://apiskala.varityweb.com"
|
|
26
|
+
_DEFAULT_RETRIES = 2
|
|
27
|
+
_DEFAULT_TIMEOUT_MS = 5_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha256_hex(value: str) -> str:
|
|
31
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Skala:
|
|
35
|
+
def __init__(self, options: SkalaOptions) -> None:
|
|
36
|
+
if not options.api_key or not options.api_key.strip():
|
|
37
|
+
raise SkalaConfigError("Skala apiKey is required")
|
|
38
|
+
if options.retries is not None and options.retries < 0:
|
|
39
|
+
raise SkalaConfigError(
|
|
40
|
+
"Skala retries must be greater than or equal to 0"
|
|
41
|
+
)
|
|
42
|
+
if options.timeout_ms is not None and options.timeout_ms <= 0:
|
|
43
|
+
raise SkalaConfigError("Skala timeoutMs must be greater than 0")
|
|
44
|
+
|
|
45
|
+
self._api_key = options.api_key
|
|
46
|
+
self._base_url = (options.base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
47
|
+
self._retries = (
|
|
48
|
+
options.retries if options.retries is not None else _DEFAULT_RETRIES
|
|
49
|
+
)
|
|
50
|
+
self._timeout = (
|
|
51
|
+
(options.timeout_ms or _DEFAULT_TIMEOUT_MS) / 1000
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
|
|
55
|
+
request_id = str(uuid.uuid4())
|
|
56
|
+
attempt = 0
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
response = httpx.post(
|
|
61
|
+
f"{self._base_url}{path}",
|
|
62
|
+
json=payload,
|
|
63
|
+
headers={
|
|
64
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
"X-Request-ID": request_id,
|
|
68
|
+
},
|
|
69
|
+
timeout=self._timeout,
|
|
70
|
+
)
|
|
71
|
+
except httpx.TimeoutException as exc:
|
|
72
|
+
raise SkalaTimeoutError(
|
|
73
|
+
int(self._timeout * 1000), request_id=request_id, cause=exc
|
|
74
|
+
) from exc
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise SkalaNetworkError(
|
|
77
|
+
request_id=request_id, cause=exc
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
if 500 <= response.status_code < 600 and attempt < self._retries:
|
|
81
|
+
attempt += 1
|
|
82
|
+
time.sleep(0.05 * attempt)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
86
|
+
body: Any = response.text
|
|
87
|
+
if body:
|
|
88
|
+
try:
|
|
89
|
+
body = response.json()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
raise SkalaApiError(
|
|
93
|
+
response.status_code, body, request_id=request_id
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return response.json()
|
|
97
|
+
|
|
98
|
+
def score(self, payload: ScoreRequest) -> ScoreResponse | ScoreFallbackResponse:
|
|
99
|
+
started_at = time.monotonic()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
data = payload.to_dict()
|
|
103
|
+
email = data.pop("email")
|
|
104
|
+
data["email_hash"] = _sha256_hex(email.strip().lower())
|
|
105
|
+
|
|
106
|
+
email_domain = None
|
|
107
|
+
if email and "@" in email:
|
|
108
|
+
email_domain = email.split("@")[-1].strip().lower()
|
|
109
|
+
|
|
110
|
+
if email_domain:
|
|
111
|
+
if "metadata" not in data or data["metadata"] is None:
|
|
112
|
+
data["metadata"] = {}
|
|
113
|
+
data["metadata"]["email_domain"] = email_domain
|
|
114
|
+
|
|
115
|
+
result = self._post_json("/v1/score", data)
|
|
116
|
+
return ScoreResponse.from_dict(result)
|
|
117
|
+
except SkalaTimeoutError as exc:
|
|
118
|
+
elapsed_ms = int((time.monotonic() - started_at) * 1000)
|
|
119
|
+
return ScoreFallbackResponse(
|
|
120
|
+
request_id=exc.request_id or str(uuid.uuid4()),
|
|
121
|
+
risk_score=0,
|
|
122
|
+
decision="allow",
|
|
123
|
+
reason_codes=["SDK_TIMEOUT_FALLBACK"],
|
|
124
|
+
latency_ms=max(0, round(elapsed_ms)),
|
|
125
|
+
fallback=True,
|
|
126
|
+
)
|
|
127
|
+
except SkalaNetworkError as exc:
|
|
128
|
+
elapsed_ms = int((time.monotonic() - started_at) * 1000)
|
|
129
|
+
return ScoreFallbackResponse(
|
|
130
|
+
request_id=exc.request_id or str(uuid.uuid4()),
|
|
131
|
+
risk_score=0,
|
|
132
|
+
decision="allow",
|
|
133
|
+
reason_codes=["SDK_NETWORK_FALLBACK"],
|
|
134
|
+
latency_ms=max(0, round(elapsed_ms)),
|
|
135
|
+
fallback=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def outcome(self, payload: OutcomeRequest) -> OutcomeResponse:
|
|
139
|
+
result = self._post_json("/v1/outcome", payload.to_dict())
|
|
140
|
+
return OutcomeResponse.from_dict(result)
|
skala/_errors.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SkalaError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
request_id: str | None = None,
|
|
12
|
+
cause: BaseException | None = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.request_id = request_id
|
|
16
|
+
if cause is not None:
|
|
17
|
+
self.__cause__ = cause
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SkalaConfigError(SkalaError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SkalaTimeoutError(SkalaError):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
timeout_ms: int,
|
|
28
|
+
request_id: str | None = None,
|
|
29
|
+
cause: BaseException | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"Skala request timed out after {timeout_ms}ms",
|
|
33
|
+
request_id=request_id,
|
|
34
|
+
cause=cause,
|
|
35
|
+
)
|
|
36
|
+
self.timeout_ms = timeout_ms
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SkalaNetworkError(SkalaError):
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
request_id: str | None = None,
|
|
43
|
+
cause: BaseException | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
super().__init__(
|
|
46
|
+
"Skala request failed because the API is unreachable",
|
|
47
|
+
request_id=request_id,
|
|
48
|
+
cause=cause,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SkalaApiError(SkalaError):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
status: int,
|
|
56
|
+
body: Any,
|
|
57
|
+
request_id: str | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(
|
|
60
|
+
f"Skala request failed with status {status}",
|
|
61
|
+
request_id=request_id,
|
|
62
|
+
)
|
|
63
|
+
self.status = status
|
|
64
|
+
self.body = body
|
skala/_types.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
SkalaEventType = Literal[
|
|
7
|
+
"signup", "login", "trial_start", "checkout", "api_key_create"
|
|
8
|
+
]
|
|
9
|
+
SkalaDecision = Literal["allow", "step_up", "block"]
|
|
10
|
+
SkalaOutcome = Literal["confirmed_fraud", "false_positive", "converted"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ScoreRequest:
|
|
15
|
+
event_type: SkalaEventType
|
|
16
|
+
ip: str
|
|
17
|
+
email: str
|
|
18
|
+
device_id: str | None = None
|
|
19
|
+
user_agent: str | None = None
|
|
20
|
+
form_fill_ms: int | None = None
|
|
21
|
+
metadata: dict[str, Any] | None = None
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
d: dict[str, Any] = {
|
|
25
|
+
"event_type": self.event_type,
|
|
26
|
+
"ip": self.ip,
|
|
27
|
+
"email": self.email,
|
|
28
|
+
}
|
|
29
|
+
if self.device_id is not None:
|
|
30
|
+
d["device_id"] = self.device_id
|
|
31
|
+
if self.user_agent is not None:
|
|
32
|
+
d["user_agent"] = self.user_agent
|
|
33
|
+
if self.form_fill_ms is not None:
|
|
34
|
+
d["form_fill_ms"] = self.form_fill_ms
|
|
35
|
+
if self.metadata is not None:
|
|
36
|
+
d["metadata"] = self.metadata
|
|
37
|
+
return d
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ScoreResponse:
|
|
42
|
+
request_id: str
|
|
43
|
+
risk_score: float
|
|
44
|
+
decision: SkalaDecision
|
|
45
|
+
reason_codes: list[str]
|
|
46
|
+
latency_ms: int
|
|
47
|
+
signal_breakdown: dict[str, float] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: dict[str, Any]) -> ScoreResponse:
|
|
51
|
+
return cls(
|
|
52
|
+
request_id=data["request_id"],
|
|
53
|
+
risk_score=data["risk_score"],
|
|
54
|
+
decision=data["decision"],
|
|
55
|
+
reason_codes=data["reason_codes"],
|
|
56
|
+
latency_ms=data["latency_ms"],
|
|
57
|
+
signal_breakdown=data.get("signal_breakdown") or {},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ScoreFallbackResponse(ScoreResponse):
|
|
63
|
+
fallback: bool = True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class OutcomeRequest:
|
|
68
|
+
request_id: str
|
|
69
|
+
outcome: SkalaOutcome
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, str]:
|
|
72
|
+
return {"request_id": self.request_id, "outcome": self.outcome}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class OutcomeResponse:
|
|
77
|
+
ok: bool
|
|
78
|
+
identifiers_updated: int
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: dict[str, Any]) -> OutcomeResponse:
|
|
82
|
+
return cls(ok=data["ok"], identifiers_updated=data["identifiers_updated"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class SkalaOptions:
|
|
87
|
+
api_key: str
|
|
88
|
+
base_url: str | None = None
|
|
89
|
+
retries: int | None = None
|
|
90
|
+
timeout_ms: int | None = None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skalaio
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for the Skala fraud detection API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Skala Python SDK
|
|
11
|
+
|
|
12
|
+
Python SDK for the Skala fraud detection API.
|
|
13
|
+
|
|
14
|
+
Requires Python 3.11+.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install skalaio
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from skala import Skala, SkalaOptions, ScoreRequest
|
|
26
|
+
|
|
27
|
+
skala = Skala(SkalaOptions(api_key="sk_live_..."))
|
|
28
|
+
|
|
29
|
+
result = skala.score(ScoreRequest(
|
|
30
|
+
event_type="signup",
|
|
31
|
+
ip=req.ip,
|
|
32
|
+
email=body.email,
|
|
33
|
+
user_agent=req.headers["user-agent"],
|
|
34
|
+
))
|
|
35
|
+
|
|
36
|
+
if result.decision == "block":
|
|
37
|
+
return {"error": "Request blocked"}, 403
|
|
38
|
+
|
|
39
|
+
if result.decision == "step_up":
|
|
40
|
+
return {"requires_verification": True}, 200
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Report an Outcome
|
|
44
|
+
|
|
45
|
+
Feed back fraud signals to improve future scoring:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from skala import OutcomeRequest
|
|
49
|
+
|
|
50
|
+
skala.outcome(OutcomeRequest(
|
|
51
|
+
request_id=result.request_id,
|
|
52
|
+
outcome="confirmed_fraud",
|
|
53
|
+
))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Timeout Fallback
|
|
57
|
+
|
|
58
|
+
`score()` auto-allows when the API is unreachable (timeout or network failure), so scoring does not block user traffic.
|
|
59
|
+
|
|
60
|
+
HTTP API responses (for example 4xx/5xx) still raise errors.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
result = skala.score(...)
|
|
64
|
+
|
|
65
|
+
if hasattr(result, "fallback") and result.fallback:
|
|
66
|
+
# request was auto-allowed by SDK fallback
|
|
67
|
+
# result.reason_codes is SDK_TIMEOUT_FALLBACK or SDK_NETWORK_FALLBACK
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Error Handling
|
|
71
|
+
|
|
72
|
+
Use structured SDK errors for predictable handling:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from skala import (
|
|
76
|
+
SkalaApiError,
|
|
77
|
+
SkalaNetworkError,
|
|
78
|
+
SkalaTimeoutError,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
skala.outcome(OutcomeRequest(request_id="req_123", outcome="confirmed_fraud"))
|
|
83
|
+
except SkalaTimeoutError:
|
|
84
|
+
# request timed out
|
|
85
|
+
except SkalaNetworkError:
|
|
86
|
+
# API unreachable (DNS/TLS/connectivity)
|
|
87
|
+
except SkalaApiError as e:
|
|
88
|
+
# API returned non-2xx
|
|
89
|
+
print(e.status, e.body, e.request_id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from skala import Skala, SkalaOptions
|
|
96
|
+
|
|
97
|
+
skala = Skala(SkalaOptions(
|
|
98
|
+
api_key="sk_live_...",
|
|
99
|
+
base_url="https://apiskala.varityweb.com", # default
|
|
100
|
+
timeout_ms=5000, # default
|
|
101
|
+
retries=2, # default, only retries 5xx
|
|
102
|
+
))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
skala/__init__.py,sha256=_eoMkrKFicliDegSOu18kz_Jax0i0gl8AnJTV_0l69Q,755
|
|
2
|
+
skala/_client.py,sha256=dPFufJHtcaje1CUwVbfpp9OvMxmeOcyHrsyFZVF1aVE,4838
|
|
3
|
+
skala/_errors.py,sha256=6i19YPazwhvfSSF6hID4coGo4ab4YmafHNJpS4LhwV0,1500
|
|
4
|
+
skala/_types.py,sha256=WZZhTJMPtWzinjNQOu4_s_3P3YNq4HRiKSAWDd-swwA,2392
|
|
5
|
+
skalaio-0.2.0.dist-info/METADATA,sha256=TNMiSb9Ia7CutrCkZfl-klvjgSN5Dyur5th-8yu95qo,2254
|
|
6
|
+
skalaio-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
skalaio-0.2.0.dist-info/RECORD,,
|