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.
@@ -0,0 +1,8 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -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
@@ -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
@@ -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