skalaio 0.2.0__tar.gz
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.
- skalaio-0.2.0/.gitignore +8 -0
- skalaio-0.2.0/.python-version +1 -0
- skalaio-0.2.0/PKG-INFO +107 -0
- skalaio-0.2.0/README.md +98 -0
- skalaio-0.2.0/publish.sh +38 -0
- skalaio-0.2.0/pyproject.toml +15 -0
- skalaio-0.2.0/skala/__init__.py +38 -0
- skalaio-0.2.0/skala/_client.py +140 -0
- skalaio-0.2.0/skala/_errors.py +64 -0
- skalaio-0.2.0/skala/_types.py +90 -0
- skalaio-0.2.0/tests/test_config.py +88 -0
- skalaio-0.2.0/tests/test_errors.py +87 -0
- skalaio-0.2.0/tests/test_outcome.py +76 -0
- skalaio-0.2.0/tests/test_score.py +152 -0
skalaio-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
skalaio-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
skalaio-0.2.0/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Skala Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the Skala fraud detection API.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.11+.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install skalaio
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from skala import Skala, SkalaOptions, ScoreRequest
|
|
17
|
+
|
|
18
|
+
skala = Skala(SkalaOptions(api_key="sk_live_..."))
|
|
19
|
+
|
|
20
|
+
result = skala.score(ScoreRequest(
|
|
21
|
+
event_type="signup",
|
|
22
|
+
ip=req.ip,
|
|
23
|
+
email=body.email,
|
|
24
|
+
user_agent=req.headers["user-agent"],
|
|
25
|
+
))
|
|
26
|
+
|
|
27
|
+
if result.decision == "block":
|
|
28
|
+
return {"error": "Request blocked"}, 403
|
|
29
|
+
|
|
30
|
+
if result.decision == "step_up":
|
|
31
|
+
return {"requires_verification": True}, 200
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Report an Outcome
|
|
35
|
+
|
|
36
|
+
Feed back fraud signals to improve future scoring:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from skala import OutcomeRequest
|
|
40
|
+
|
|
41
|
+
skala.outcome(OutcomeRequest(
|
|
42
|
+
request_id=result.request_id,
|
|
43
|
+
outcome="confirmed_fraud",
|
|
44
|
+
))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Timeout Fallback
|
|
48
|
+
|
|
49
|
+
`score()` auto-allows when the API is unreachable (timeout or network failure), so scoring does not block user traffic.
|
|
50
|
+
|
|
51
|
+
HTTP API responses (for example 4xx/5xx) still raise errors.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
result = skala.score(...)
|
|
55
|
+
|
|
56
|
+
if hasattr(result, "fallback") and result.fallback:
|
|
57
|
+
# request was auto-allowed by SDK fallback
|
|
58
|
+
# result.reason_codes is SDK_TIMEOUT_FALLBACK or SDK_NETWORK_FALLBACK
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Error Handling
|
|
62
|
+
|
|
63
|
+
Use structured SDK errors for predictable handling:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from skala import (
|
|
67
|
+
SkalaApiError,
|
|
68
|
+
SkalaNetworkError,
|
|
69
|
+
SkalaTimeoutError,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
skala.outcome(OutcomeRequest(request_id="req_123", outcome="confirmed_fraud"))
|
|
74
|
+
except SkalaTimeoutError:
|
|
75
|
+
# request timed out
|
|
76
|
+
except SkalaNetworkError:
|
|
77
|
+
# API unreachable (DNS/TLS/connectivity)
|
|
78
|
+
except SkalaApiError as e:
|
|
79
|
+
# API returned non-2xx
|
|
80
|
+
print(e.status, e.body, e.request_id)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from skala import Skala, SkalaOptions
|
|
87
|
+
|
|
88
|
+
skala = Skala(SkalaOptions(
|
|
89
|
+
api_key="sk_live_...",
|
|
90
|
+
base_url="https://apiskala.varityweb.com", # default
|
|
91
|
+
timeout_ms=5000, # default
|
|
92
|
+
retries=2, # default, only retries 5xx
|
|
93
|
+
))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
skalaio-0.2.0/publish.sh
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Exit immediately if any command fails
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
# Change directory to the script's directory
|
|
7
|
+
cd "$(dirname "$0")"
|
|
8
|
+
|
|
9
|
+
# Load environment variables from .env
|
|
10
|
+
if [ -f .env ]; then
|
|
11
|
+
echo "Loading credentials from .env..."
|
|
12
|
+
export $(grep -v '^#' .env | xargs)
|
|
13
|
+
else
|
|
14
|
+
echo "Error: .env file not found. Create one with TWINE_USERNAME and TWINE_PASSWORD."
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Ensure Twine environment variables are set and map them to UV variables
|
|
19
|
+
if [ -z "$TWINE_PASSWORD" ]; then
|
|
20
|
+
echo "Error: TWINE_PASSWORD is not set in .env."
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
export UV_PUBLISH_TOKEN="$TWINE_PASSWORD"
|
|
25
|
+
|
|
26
|
+
# 1. Clean previous build artifacts
|
|
27
|
+
echo "Cleaning old dist/ and build/ directories..."
|
|
28
|
+
rm -rf dist/ build/ *.egg-info/
|
|
29
|
+
|
|
30
|
+
# 2. Build the package using uv
|
|
31
|
+
echo "Building the Python package with uv..."
|
|
32
|
+
uv build
|
|
33
|
+
|
|
34
|
+
# 3. Upload to PyPI using uv
|
|
35
|
+
echo "Uploading package to PyPI with uv..."
|
|
36
|
+
uv publish
|
|
37
|
+
|
|
38
|
+
echo "Success! Package version $(grep -m 1 'version =' pyproject.toml | cut -d '"' -f 2) published successfully."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skalaio"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Python SDK for the Skala fraud detection API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = ["httpx>=0.27"]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["skala"]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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,88 @@
|
|
|
1
|
+
from unittest.mock import patch, MagicMock
|
|
2
|
+
import pytest
|
|
3
|
+
from skala import Skala, SkalaOptions, ScoreRequest
|
|
4
|
+
from skala._client import DEFAULT_BASE_URL
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ok_score_response():
|
|
8
|
+
resp = MagicMock()
|
|
9
|
+
resp.status_code = 200
|
|
10
|
+
resp.json.return_value = {
|
|
11
|
+
"request_id": "req_1",
|
|
12
|
+
"risk_score": 0,
|
|
13
|
+
"decision": "allow",
|
|
14
|
+
"reason_codes": [],
|
|
15
|
+
"latency_ms": 5,
|
|
16
|
+
}
|
|
17
|
+
return resp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_defaults_to_default_base_url():
|
|
21
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
22
|
+
mock_post.return_value = ok_score_response()
|
|
23
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
24
|
+
client.score(ScoreRequest(event_type="signup", ip="1.2.3.4", email="a@b.com"))
|
|
25
|
+
assert mock_post.call_count == 1
|
|
26
|
+
url = mock_post.call_args[0][0]
|
|
27
|
+
assert url == f"{DEFAULT_BASE_URL}/v1/score"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_strips_trailing_slashes_from_base_url():
|
|
31
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
32
|
+
mock_post.return_value = ok_score_response()
|
|
33
|
+
client = Skala(SkalaOptions(api_key="sk_test", base_url="https://api.test///"))
|
|
34
|
+
client.score(ScoreRequest(event_type="signup", ip="1.2.3.4", email="a@b.com"))
|
|
35
|
+
url = mock_post.call_args[0][0]
|
|
36
|
+
assert url == "https://api.test/v1/score"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_sets_authorization_header():
|
|
40
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
41
|
+
mock_post.return_value = ok_score_response()
|
|
42
|
+
client = Skala(SkalaOptions(api_key="sk_my_key_123"))
|
|
43
|
+
client.score(ScoreRequest(event_type="checkout", ip="10.0.0.1", email="x@y.com"))
|
|
44
|
+
headers = mock_post.call_args[1]["headers"]
|
|
45
|
+
assert headers["Authorization"] == "Bearer sk_my_key_123"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_sends_json_content_type():
|
|
49
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
50
|
+
mock_post.return_value = ok_score_response()
|
|
51
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
52
|
+
client.score(ScoreRequest(event_type="login", ip="10.0.0.1", email="a@b.com"))
|
|
53
|
+
headers = mock_post.call_args[1]["headers"]
|
|
54
|
+
assert headers["Content-Type"] == "application/json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_sends_accept_header():
|
|
58
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
59
|
+
mock_post.return_value = ok_score_response()
|
|
60
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
61
|
+
client.score(ScoreRequest(event_type="login", ip="10.0.0.1", email="a@b.com"))
|
|
62
|
+
headers = mock_post.call_args[1]["headers"]
|
|
63
|
+
assert headers["Accept"] == "application/json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_sends_request_id_as_uuid():
|
|
67
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
68
|
+
mock_post.return_value = ok_score_response()
|
|
69
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
70
|
+
client.score(ScoreRequest(event_type="login", ip="10.0.0.1", email="a@b.com"))
|
|
71
|
+
headers = mock_post.call_args[1]["headers"]
|
|
72
|
+
import re
|
|
73
|
+
assert re.match(r"^[0-9a-f-]{36}$", headers["X-Request-ID"])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_throws_when_api_key_is_missing():
|
|
77
|
+
with pytest.raises(Exception, match="apiKey is required"):
|
|
78
|
+
Skala(SkalaOptions(api_key=""))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_throws_when_retries_is_negative():
|
|
82
|
+
with pytest.raises(Exception, match="retries must be greater"):
|
|
83
|
+
Skala(SkalaOptions(api_key="sk_test", retries=-1))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_throws_when_timeout_ms_is_not_positive():
|
|
87
|
+
with pytest.raises(Exception, match="timeoutMs must be greater"):
|
|
88
|
+
Skala(SkalaOptions(api_key="sk_test", timeout_ms=0))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from unittest.mock import patch, MagicMock
|
|
2
|
+
import pytest
|
|
3
|
+
from skala import Skala, SkalaOptions, ScoreRequest, OutcomeRequest
|
|
4
|
+
from skala._errors import SkalaApiError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SCORE_REQ = ScoreRequest(event_type="signup", ip="1.2.3.4", email="err@test.com")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_error_response(status, body=None):
|
|
11
|
+
resp = MagicMock()
|
|
12
|
+
resp.status_code = status
|
|
13
|
+
if body is None:
|
|
14
|
+
body = {"error": "unauthorized"}
|
|
15
|
+
resp.json.return_value = body
|
|
16
|
+
resp.text = str(body)
|
|
17
|
+
return resp
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_throws_on_401_without_retrying():
|
|
21
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
22
|
+
mock_post.return_value = make_error_response(401)
|
|
23
|
+
client = Skala(SkalaOptions(api_key="bad_key", retries=2))
|
|
24
|
+
with pytest.raises(SkalaApiError) as exc_info:
|
|
25
|
+
client.score(SCORE_REQ)
|
|
26
|
+
assert exc_info.value.status == 401
|
|
27
|
+
assert exc_info.value.body == {"error": "unauthorized"}
|
|
28
|
+
assert mock_post.call_count == 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_throws_on_422_without_retrying():
|
|
32
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
33
|
+
mock_post.return_value = make_error_response(422, {"error": "validation failed"})
|
|
34
|
+
client = Skala(SkalaOptions(api_key="sk_test", retries=2))
|
|
35
|
+
with pytest.raises(SkalaApiError):
|
|
36
|
+
client.score(SCORE_REQ)
|
|
37
|
+
assert mock_post.call_count == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_throws_on_403_without_retrying():
|
|
41
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
42
|
+
mock_post.return_value = make_error_response(403, {"error": "forbidden"})
|
|
43
|
+
client = Skala(SkalaOptions(api_key="sk_test", retries=2))
|
|
44
|
+
with pytest.raises(SkalaApiError):
|
|
45
|
+
client.score(SCORE_REQ)
|
|
46
|
+
assert mock_post.call_count == 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_does_not_retry_on_429():
|
|
50
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
51
|
+
mock_post.return_value = make_error_response(429, {"error": "rate_limited"})
|
|
52
|
+
client = Skala(SkalaOptions(api_key="sk_test", retries=3))
|
|
53
|
+
with pytest.raises(SkalaApiError) as exc_info:
|
|
54
|
+
client.score(SCORE_REQ)
|
|
55
|
+
assert exc_info.value.status == 429
|
|
56
|
+
assert exc_info.value.body == {"error": "rate_limited"}
|
|
57
|
+
assert mock_post.call_count == 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_exhausts_retries_on_500():
|
|
61
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
62
|
+
resp = MagicMock()
|
|
63
|
+
resp.status_code = 500
|
|
64
|
+
resp.text = "internal error"
|
|
65
|
+
resp.json.return_value = "internal error"
|
|
66
|
+
mock_post.return_value = resp
|
|
67
|
+
client = Skala(SkalaOptions(api_key="sk_test", retries=2))
|
|
68
|
+
with pytest.raises(SkalaApiError):
|
|
69
|
+
client.score(SCORE_REQ)
|
|
70
|
+
assert mock_post.call_count == 3
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_does_not_retry_when_retries_is_0():
|
|
74
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
75
|
+
mock_post.return_value = make_error_response(502, "error")
|
|
76
|
+
client = Skala(SkalaOptions(api_key="sk_test", retries=0))
|
|
77
|
+
with pytest.raises(SkalaApiError):
|
|
78
|
+
client.score(SCORE_REQ)
|
|
79
|
+
assert mock_post.call_count == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_throws_on_outcome_4xx_errors():
|
|
83
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
84
|
+
mock_post.return_value = make_error_response(404, {"error": "not found"})
|
|
85
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
86
|
+
with pytest.raises(SkalaApiError):
|
|
87
|
+
client.outcome(OutcomeRequest(request_id="req_bad", outcome="confirmed_fraud"))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from unittest.mock import patch, MagicMock
|
|
2
|
+
import pytest
|
|
3
|
+
from skala import Skala, SkalaOptions, OutcomeRequest
|
|
4
|
+
from skala._errors import SkalaTimeoutError, SkalaNetworkError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_outcome_response(ok=True, identifiers_updated=4):
|
|
8
|
+
resp = MagicMock()
|
|
9
|
+
resp.status_code = 200
|
|
10
|
+
resp.json.return_value = {
|
|
11
|
+
"ok": ok,
|
|
12
|
+
"identifiers_updated": identifiers_updated,
|
|
13
|
+
}
|
|
14
|
+
return resp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_reports_confirmed_fraud_outcome():
|
|
18
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
19
|
+
mock_post.return_value = make_outcome_response()
|
|
20
|
+
client = Skala(SkalaOptions(api_key="sk_test_123"))
|
|
21
|
+
result = client.outcome(
|
|
22
|
+
OutcomeRequest(request_id="req_123", outcome="confirmed_fraud")
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
assert result.ok is True
|
|
26
|
+
assert result.identifiers_updated == 4
|
|
27
|
+
|
|
28
|
+
url = mock_post.call_args[0][0]
|
|
29
|
+
assert url == "https://apiskala.varityweb.com/v1/outcome"
|
|
30
|
+
|
|
31
|
+
body = mock_post.call_args[1]["json"]
|
|
32
|
+
assert body == {"request_id": "req_123", "outcome": "confirmed_fraud"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_reports_false_positive_outcome():
|
|
36
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
37
|
+
mock_post.return_value = make_outcome_response(identifiers_updated=1)
|
|
38
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
39
|
+
result = client.outcome(
|
|
40
|
+
OutcomeRequest(request_id="req_456", outcome="false_positive")
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert result.ok is True
|
|
44
|
+
body = mock_post.call_args[1]["json"]
|
|
45
|
+
assert body["outcome"] == "false_positive"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_reports_converted_outcome():
|
|
49
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
50
|
+
mock_post.return_value = make_outcome_response(identifiers_updated=2)
|
|
51
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
52
|
+
result = client.outcome(
|
|
53
|
+
OutcomeRequest(request_id="req_789", outcome="converted")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
assert result.identifiers_updated == 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_throws_on_timeout_without_fallback():
|
|
60
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
61
|
+
mock_post.side_effect = __import__("httpx").TimeoutException("timeout")
|
|
62
|
+
client = Skala(SkalaOptions(api_key="sk_test", timeout_ms=50))
|
|
63
|
+
with pytest.raises(SkalaTimeoutError):
|
|
64
|
+
client.outcome(
|
|
65
|
+
OutcomeRequest(request_id="req_1", outcome="confirmed_fraud")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_throws_network_error_when_api_unreachable():
|
|
70
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
71
|
+
mock_post.side_effect = Exception("fetch failed")
|
|
72
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
73
|
+
with pytest.raises(SkalaNetworkError):
|
|
74
|
+
client.outcome(
|
|
75
|
+
OutcomeRequest(request_id="req_2", outcome="converted")
|
|
76
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from unittest.mock import patch, MagicMock
|
|
2
|
+
import pytest
|
|
3
|
+
from skala import Skala, SkalaOptions, ScoreRequest, ScoreFallbackResponse
|
|
4
|
+
from skala._client import _sha256_hex
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_score_response(
|
|
8
|
+
request_id="req_123",
|
|
9
|
+
risk_score=12,
|
|
10
|
+
decision="allow",
|
|
11
|
+
reason_codes=None,
|
|
12
|
+
latency_ms=9,
|
|
13
|
+
):
|
|
14
|
+
resp = MagicMock()
|
|
15
|
+
resp.status_code = 200
|
|
16
|
+
resp.json.return_value = {
|
|
17
|
+
"request_id": request_id,
|
|
18
|
+
"risk_score": risk_score,
|
|
19
|
+
"decision": decision,
|
|
20
|
+
"reason_codes": reason_codes or [],
|
|
21
|
+
"latency_ms": latency_ms,
|
|
22
|
+
}
|
|
23
|
+
return resp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_sends_score_request_and_returns_response():
|
|
27
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
28
|
+
mock_post.return_value = make_score_response()
|
|
29
|
+
client = Skala(SkalaOptions(api_key="sk_test_123"))
|
|
30
|
+
result = client.score(
|
|
31
|
+
ScoreRequest(
|
|
32
|
+
event_type="signup",
|
|
33
|
+
ip="203.0.113.9",
|
|
34
|
+
email="user@example.com",
|
|
35
|
+
user_agent="Mozilla/5.0",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert result.request_id == "req_123"
|
|
40
|
+
assert result.risk_score == 12
|
|
41
|
+
assert result.decision == "allow"
|
|
42
|
+
assert result.reason_codes == []
|
|
43
|
+
assert result.latency_ms == 9
|
|
44
|
+
|
|
45
|
+
call_args = mock_post.call_args
|
|
46
|
+
url = call_args[0][0]
|
|
47
|
+
assert url == "https://apiskala.varityweb.com/v1/score"
|
|
48
|
+
assert call_args[1]["headers"]["Authorization"] == "Bearer sk_test_123"
|
|
49
|
+
|
|
50
|
+
body = call_args[1]["json"]
|
|
51
|
+
assert body["event_type"] == "signup"
|
|
52
|
+
assert body["ip"] == "203.0.113.9"
|
|
53
|
+
assert body["email_hash"] == _sha256_hex("user@example.com")
|
|
54
|
+
assert body["user_agent"] == "Mozilla/5.0"
|
|
55
|
+
assert "email" not in body
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_uses_custom_base_url():
|
|
59
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
60
|
+
mock_post.return_value = make_score_response(request_id="req_1")
|
|
61
|
+
client = Skala(SkalaOptions(api_key="sk_test", base_url="https://custom.api.test"))
|
|
62
|
+
client.score(ScoreRequest(event_type="login", ip="10.0.0.1", email="test@test.com"))
|
|
63
|
+
url = mock_post.call_args[0][0]
|
|
64
|
+
assert url == "https://custom.api.test/v1/score"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_passes_optional_fields():
|
|
68
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
69
|
+
mock_post.return_value = make_score_response(
|
|
70
|
+
request_id="req_2",
|
|
71
|
+
risk_score=30,
|
|
72
|
+
decision="step_up",
|
|
73
|
+
reason_codes=["NEW_DEVICE"],
|
|
74
|
+
)
|
|
75
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
76
|
+
result = client.score(
|
|
77
|
+
ScoreRequest(
|
|
78
|
+
event_type="trial_start",
|
|
79
|
+
ip="10.0.0.2",
|
|
80
|
+
email="trial@example.com",
|
|
81
|
+
device_id="device_abc",
|
|
82
|
+
form_fill_ms=1200,
|
|
83
|
+
metadata={"plan": "pro"},
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
assert result.decision == "step_up"
|
|
88
|
+
assert "NEW_DEVICE" in result.reason_codes
|
|
89
|
+
|
|
90
|
+
body = mock_post.call_args[1]["json"]
|
|
91
|
+
assert body["device_id"] == "device_abc"
|
|
92
|
+
assert body["form_fill_ms"] == 1200
|
|
93
|
+
assert body["metadata"] == {"plan": "pro"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_retries_5xx_and_succeeds():
|
|
97
|
+
error_resp = MagicMock()
|
|
98
|
+
error_resp.status_code = 503
|
|
99
|
+
error_resp.text = '{"error": "temporary"}'
|
|
100
|
+
error_resp.json.return_value = {"error": "temporary"}
|
|
101
|
+
|
|
102
|
+
ok_resp = make_score_response(request_id="req_456", risk_score=55, decision="step_up", reason_codes=["HIGH_IP_VELOCITY"])
|
|
103
|
+
|
|
104
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
105
|
+
mock_post.side_effect = [error_resp, error_resp, ok_resp]
|
|
106
|
+
client = Skala(SkalaOptions(api_key="sk_test_123", retries=2))
|
|
107
|
+
result = client.score(
|
|
108
|
+
ScoreRequest(event_type="signup", ip="203.0.113.9", email="retry@example.com")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
assert result.decision == "step_up"
|
|
112
|
+
assert mock_post.call_count == 3
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_returns_allow_fallback_on_timeout():
|
|
116
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
117
|
+
mock_post.side_effect = __import__("httpx").TimeoutException("timeout")
|
|
118
|
+
client = Skala(SkalaOptions(api_key="sk_test_123", timeout_ms=10))
|
|
119
|
+
result = client.score(
|
|
120
|
+
ScoreRequest(event_type="signup", ip="203.0.113.9", email="slow@example.com")
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert isinstance(result, ScoreFallbackResponse)
|
|
124
|
+
assert result.decision == "allow"
|
|
125
|
+
assert result.risk_score == 0
|
|
126
|
+
assert result.reason_codes == ["SDK_TIMEOUT_FALLBACK"]
|
|
127
|
+
assert result.fallback is True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_returns_allow_fallback_on_network_error():
|
|
131
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
132
|
+
mock_post.side_effect = Exception("fetch failed")
|
|
133
|
+
client = Skala(SkalaOptions(api_key="sk_test_123"))
|
|
134
|
+
result = client.score(
|
|
135
|
+
ScoreRequest(event_type="signup", ip="203.0.113.9", email="netdown@example.com")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
assert isinstance(result, ScoreFallbackResponse)
|
|
139
|
+
assert result.decision == "allow"
|
|
140
|
+
assert result.risk_score == 0
|
|
141
|
+
assert result.reason_codes == ["SDK_NETWORK_FALLBACK"]
|
|
142
|
+
assert result.fallback is True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_hashes_email_before_sending():
|
|
146
|
+
with patch("skala._client.httpx.post") as mock_post:
|
|
147
|
+
mock_post.return_value = make_score_response()
|
|
148
|
+
client = Skala(SkalaOptions(api_key="sk_test"))
|
|
149
|
+
client.score(ScoreRequest(event_type="signup", ip="1.2.3.4", email="User@Example.COM"))
|
|
150
|
+
body = mock_post.call_args[1]["json"]
|
|
151
|
+
assert body["email_hash"] == _sha256_hex("user@example.com")
|
|
152
|
+
assert "email" not in body
|