a3api 0.1.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.
- a3api-0.1.0/.gitignore +57 -0
- a3api-0.1.0/LICENSE +21 -0
- a3api-0.1.0/PKG-INFO +136 -0
- a3api-0.1.0/README.md +106 -0
- a3api-0.1.0/pyproject.toml +49 -0
- a3api-0.1.0/src/a3api/__init__.py +61 -0
- a3api-0.1.0/src/a3api/_client.py +192 -0
- a3api-0.1.0/src/a3api/_errors.py +62 -0
- a3api-0.1.0/src/a3api/_retry.py +95 -0
- a3api-0.1.0/src/a3api/_types.py +141 -0
- a3api-0.1.0/src/a3api/_version.py +1 -0
- a3api-0.1.0/src/a3api/py.typed +0 -0
- a3api-0.1.0/tests/__init__.py +0 -0
- a3api-0.1.0/tests/conftest.py +43 -0
- a3api-0.1.0/tests/test_async_client.py +94 -0
- a3api-0.1.0/tests/test_client.py +222 -0
- a3api-0.1.0/tests/test_retry.py +130 -0
- a3api-0.1.0/tests/test_types.py +163 -0
a3api-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Build output
|
|
5
|
+
dist/
|
|
6
|
+
|
|
7
|
+
# Serverless
|
|
8
|
+
.serverless/
|
|
9
|
+
|
|
10
|
+
# IDE
|
|
11
|
+
.idea/
|
|
12
|
+
.vscode/
|
|
13
|
+
|
|
14
|
+
# Claude local settings (may contain credentials)
|
|
15
|
+
.claude/settings.local.json
|
|
16
|
+
*.swp
|
|
17
|
+
*.swo
|
|
18
|
+
|
|
19
|
+
# OS files
|
|
20
|
+
.DS_Store
|
|
21
|
+
Thumbs.db
|
|
22
|
+
|
|
23
|
+
# Environment
|
|
24
|
+
.env
|
|
25
|
+
.env.*
|
|
26
|
+
!.env.example
|
|
27
|
+
|
|
28
|
+
# TypeScript incremental
|
|
29
|
+
*.tsbuildinfo
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.pyc
|
|
34
|
+
*.egg-info/
|
|
35
|
+
|
|
36
|
+
# Next.js
|
|
37
|
+
.next/
|
|
38
|
+
out/
|
|
39
|
+
|
|
40
|
+
# AWS Amplify
|
|
41
|
+
amplify-builds/
|
|
42
|
+
|
|
43
|
+
# Benchmark generated datasets
|
|
44
|
+
**/benchmarks/benchmark_dataset_*.json
|
|
45
|
+
**/benchmarks/benchmark_results_*.json
|
|
46
|
+
|
|
47
|
+
# Lambda packaging artifacts
|
|
48
|
+
**/benchmarks/package/
|
|
49
|
+
function.zip
|
|
50
|
+
|
|
51
|
+
# Terraform
|
|
52
|
+
**/.terraform/
|
|
53
|
+
*.tfstate
|
|
54
|
+
*.tfstate.backup
|
|
55
|
+
*.tfplan
|
|
56
|
+
**/placeholder.zip
|
|
57
|
+
secrets.auto.tfvars
|
a3api-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 A3 API Team
|
|
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.
|
a3api-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a3api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the A3 Age Assurance API
|
|
5
|
+
Project-URL: Homepage, https://www.a3api.io
|
|
6
|
+
Project-URL: Documentation, https://www.a3api.io/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/a3api/a3api-python
|
|
8
|
+
Author: A3 API Team
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: a3,ab-1043,age-assurance,age-verification,coppa
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: httpx<1,>=0.24
|
|
24
|
+
Requires-Dist: pydantic<3,>=2
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# a3api
|
|
32
|
+
|
|
33
|
+
Official Python client for the [A3 Age Assurance API](https://www.a3api.io).
|
|
34
|
+
|
|
35
|
+
Typed, async-ready, with built-in retries — supports Python 3.9+.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install a3api
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from a3api import A3Client, AssessAgeRequest, OsSignal
|
|
47
|
+
|
|
48
|
+
client = A3Client(api_key="your-api-key")
|
|
49
|
+
|
|
50
|
+
response = client.assess_age(AssessAgeRequest(
|
|
51
|
+
os_signal=OsSignal.AGE_18_PLUS,
|
|
52
|
+
user_country_code="US",
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
print(response.verdict) # CONSISTENT
|
|
56
|
+
print(response.confidence_score) # 0.95
|
|
57
|
+
print(response.verification_token)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Async
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from a3api import AsyncA3Client, AssessAgeRequest, OsSignal
|
|
64
|
+
|
|
65
|
+
async with AsyncA3Client(api_key="your-api-key") as client:
|
|
66
|
+
response = await client.assess_age(AssessAgeRequest(
|
|
67
|
+
os_signal=OsSignal.AGE_18_PLUS,
|
|
68
|
+
user_country_code="US",
|
|
69
|
+
))
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### With Behavioral Signals
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from a3api import (
|
|
76
|
+
A3Client, AssessAgeRequest, OsSignal,
|
|
77
|
+
BehavioralMetrics, DeviceContext,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
client = A3Client(api_key="your-api-key")
|
|
81
|
+
|
|
82
|
+
response = client.assess_age(AssessAgeRequest(
|
|
83
|
+
os_signal=OsSignal.AGE_13_15,
|
|
84
|
+
user_country_code="US",
|
|
85
|
+
behavioral_metrics=BehavioralMetrics(
|
|
86
|
+
avg_touch_precision=0.82,
|
|
87
|
+
scroll_velocity=340.0,
|
|
88
|
+
form_completion_time_ms=12000.0,
|
|
89
|
+
),
|
|
90
|
+
device_context=DeviceContext(
|
|
91
|
+
os_version="iOS 17.2",
|
|
92
|
+
device_model="iPhone 15",
|
|
93
|
+
is_high_contrast_enabled=False,
|
|
94
|
+
screen_scale_factor=3.0,
|
|
95
|
+
),
|
|
96
|
+
))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Error Handling
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from a3api import (
|
|
103
|
+
A3Client, A3AuthenticationError,
|
|
104
|
+
A3RateLimitError, A3ValidationError, A3ConnectionError,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
response = client.assess_age(request)
|
|
109
|
+
except A3AuthenticationError:
|
|
110
|
+
# Invalid or missing API key (401)
|
|
111
|
+
pass
|
|
112
|
+
except A3RateLimitError as e:
|
|
113
|
+
# Too many requests (429) — e.retry_after has the wait time
|
|
114
|
+
pass
|
|
115
|
+
except A3ValidationError as e:
|
|
116
|
+
# Bad request (400) — e.validation_errors has details
|
|
117
|
+
pass
|
|
118
|
+
except A3ConnectionError:
|
|
119
|
+
# Network or timeout error
|
|
120
|
+
pass
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
client = A3Client(
|
|
127
|
+
api_key="your-api-key",
|
|
128
|
+
base_url="https://api.a3api.io", # default
|
|
129
|
+
timeout=30.0, # seconds, default
|
|
130
|
+
max_retries=2, # default
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
a3api-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# a3api
|
|
2
|
+
|
|
3
|
+
Official Python client for the [A3 Age Assurance API](https://www.a3api.io).
|
|
4
|
+
|
|
5
|
+
Typed, async-ready, with built-in retries — supports Python 3.9+.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install a3api
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from a3api import A3Client, AssessAgeRequest, OsSignal
|
|
17
|
+
|
|
18
|
+
client = A3Client(api_key="your-api-key")
|
|
19
|
+
|
|
20
|
+
response = client.assess_age(AssessAgeRequest(
|
|
21
|
+
os_signal=OsSignal.AGE_18_PLUS,
|
|
22
|
+
user_country_code="US",
|
|
23
|
+
))
|
|
24
|
+
|
|
25
|
+
print(response.verdict) # CONSISTENT
|
|
26
|
+
print(response.confidence_score) # 0.95
|
|
27
|
+
print(response.verification_token)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Async
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from a3api import AsyncA3Client, AssessAgeRequest, OsSignal
|
|
34
|
+
|
|
35
|
+
async with AsyncA3Client(api_key="your-api-key") as client:
|
|
36
|
+
response = await client.assess_age(AssessAgeRequest(
|
|
37
|
+
os_signal=OsSignal.AGE_18_PLUS,
|
|
38
|
+
user_country_code="US",
|
|
39
|
+
))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### With Behavioral Signals
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from a3api import (
|
|
46
|
+
A3Client, AssessAgeRequest, OsSignal,
|
|
47
|
+
BehavioralMetrics, DeviceContext,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
client = A3Client(api_key="your-api-key")
|
|
51
|
+
|
|
52
|
+
response = client.assess_age(AssessAgeRequest(
|
|
53
|
+
os_signal=OsSignal.AGE_13_15,
|
|
54
|
+
user_country_code="US",
|
|
55
|
+
behavioral_metrics=BehavioralMetrics(
|
|
56
|
+
avg_touch_precision=0.82,
|
|
57
|
+
scroll_velocity=340.0,
|
|
58
|
+
form_completion_time_ms=12000.0,
|
|
59
|
+
),
|
|
60
|
+
device_context=DeviceContext(
|
|
61
|
+
os_version="iOS 17.2",
|
|
62
|
+
device_model="iPhone 15",
|
|
63
|
+
is_high_contrast_enabled=False,
|
|
64
|
+
screen_scale_factor=3.0,
|
|
65
|
+
),
|
|
66
|
+
))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Error Handling
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from a3api import (
|
|
73
|
+
A3Client, A3AuthenticationError,
|
|
74
|
+
A3RateLimitError, A3ValidationError, A3ConnectionError,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
response = client.assess_age(request)
|
|
79
|
+
except A3AuthenticationError:
|
|
80
|
+
# Invalid or missing API key (401)
|
|
81
|
+
pass
|
|
82
|
+
except A3RateLimitError as e:
|
|
83
|
+
# Too many requests (429) — e.retry_after has the wait time
|
|
84
|
+
pass
|
|
85
|
+
except A3ValidationError as e:
|
|
86
|
+
# Bad request (400) — e.validation_errors has details
|
|
87
|
+
pass
|
|
88
|
+
except A3ConnectionError:
|
|
89
|
+
# Network or timeout error
|
|
90
|
+
pass
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client = A3Client(
|
|
97
|
+
api_key="your-api-key",
|
|
98
|
+
base_url="https://api.a3api.io", # default
|
|
99
|
+
timeout=30.0, # seconds, default
|
|
100
|
+
max_retries=2, # default
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "a3api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the A3 Age Assurance API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "A3 API Team" }]
|
|
14
|
+
keywords = ["a3", "age-assurance", "age-verification", "ab-1043", "coppa"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.24,<1",
|
|
29
|
+
"pydantic>=2,<3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://www.a3api.io"
|
|
34
|
+
Documentation = "https://www.a3api.io/docs"
|
|
35
|
+
Repository = "https://github.com/a3api/a3api-python"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=7",
|
|
40
|
+
"pytest-asyncio>=0.21",
|
|
41
|
+
"pytest-httpx>=0.30",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/a3api"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""A3 Age Assurance API — official Python client."""
|
|
2
|
+
|
|
3
|
+
from ._client import A3Client, AsyncA3Client
|
|
4
|
+
from ._errors import (
|
|
5
|
+
A3ApiError,
|
|
6
|
+
A3AuthenticationError,
|
|
7
|
+
A3ConnectionError,
|
|
8
|
+
A3RateLimitError,
|
|
9
|
+
A3ValidationError,
|
|
10
|
+
)
|
|
11
|
+
from ._types import (
|
|
12
|
+
AccountLongevity,
|
|
13
|
+
AgeBracket,
|
|
14
|
+
AssessAgeRequest,
|
|
15
|
+
AssessAgeResponse,
|
|
16
|
+
BehavioralMetrics,
|
|
17
|
+
ConsentSource,
|
|
18
|
+
ContextualSignals,
|
|
19
|
+
DeviceContext,
|
|
20
|
+
FaceEstimationProvider,
|
|
21
|
+
FaceEstimationResult,
|
|
22
|
+
InputComplexity,
|
|
23
|
+
IpType,
|
|
24
|
+
OsSignal,
|
|
25
|
+
ParentalConsentStatus,
|
|
26
|
+
ReferrerCategory,
|
|
27
|
+
Verdict,
|
|
28
|
+
)
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# Clients
|
|
33
|
+
"A3Client",
|
|
34
|
+
"AsyncA3Client",
|
|
35
|
+
# Errors
|
|
36
|
+
"A3ApiError",
|
|
37
|
+
"A3AuthenticationError",
|
|
38
|
+
"A3ConnectionError",
|
|
39
|
+
"A3RateLimitError",
|
|
40
|
+
"A3ValidationError",
|
|
41
|
+
# Request types
|
|
42
|
+
"AssessAgeRequest",
|
|
43
|
+
"OsSignal",
|
|
44
|
+
"ParentalConsentStatus",
|
|
45
|
+
"ConsentSource",
|
|
46
|
+
"FaceEstimationProvider",
|
|
47
|
+
"IpType",
|
|
48
|
+
"ReferrerCategory",
|
|
49
|
+
"BehavioralMetrics",
|
|
50
|
+
"DeviceContext",
|
|
51
|
+
"ContextualSignals",
|
|
52
|
+
"AccountLongevity",
|
|
53
|
+
"InputComplexity",
|
|
54
|
+
"FaceEstimationResult",
|
|
55
|
+
# Response types
|
|
56
|
+
"AssessAgeResponse",
|
|
57
|
+
"Verdict",
|
|
58
|
+
"AgeBracket",
|
|
59
|
+
# Version
|
|
60
|
+
"__version__",
|
|
61
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._errors import (
|
|
8
|
+
A3ApiError,
|
|
9
|
+
A3AuthenticationError,
|
|
10
|
+
A3ConnectionError,
|
|
11
|
+
A3RateLimitError,
|
|
12
|
+
A3ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from ._retry import (
|
|
15
|
+
RetryConfig,
|
|
16
|
+
is_retryable_status,
|
|
17
|
+
parse_retry_after,
|
|
18
|
+
with_retry_async,
|
|
19
|
+
with_retry_sync,
|
|
20
|
+
)
|
|
21
|
+
from ._types import AssessAgeRequest, AssessAgeResponse
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.a3api.io"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_headers(api_key: str) -> dict[str, str]:
|
|
30
|
+
return {
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
"x-api-key": api_key,
|
|
33
|
+
"User-Agent": f"a3api-python/{__version__}",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _handle_error(response: httpx.Response) -> None:
|
|
38
|
+
body: dict[str, Any] | None = None
|
|
39
|
+
try:
|
|
40
|
+
body = response.json()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
status = response.status_code
|
|
45
|
+
if status == 400:
|
|
46
|
+
raise A3ValidationError(body)
|
|
47
|
+
if status == 401:
|
|
48
|
+
raise A3AuthenticationError(body)
|
|
49
|
+
if status == 429:
|
|
50
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
51
|
+
raise A3RateLimitError(retry_after, body)
|
|
52
|
+
raise A3ApiError(
|
|
53
|
+
body.get("error", f"HTTP {status}") if body else f"HTTP {status}",
|
|
54
|
+
status,
|
|
55
|
+
body,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _should_retry(exc: Exception) -> tuple[bool, float | None]:
|
|
60
|
+
if isinstance(exc, A3RateLimitError):
|
|
61
|
+
return True, exc.retry_after
|
|
62
|
+
if isinstance(exc, A3ApiError) and is_retryable_status(exc.status_code):
|
|
63
|
+
return True, None
|
|
64
|
+
if isinstance(exc, A3ConnectionError):
|
|
65
|
+
return True, None
|
|
66
|
+
return False, None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class A3Client:
|
|
70
|
+
"""Synchronous A3 API client using httpx."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: str,
|
|
75
|
+
*,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
78
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
79
|
+
) -> None:
|
|
80
|
+
if not api_key:
|
|
81
|
+
raise ValueError("api_key is required")
|
|
82
|
+
self._api_key = api_key
|
|
83
|
+
self._base_url = base_url.rstrip("/")
|
|
84
|
+
self._retry_config = RetryConfig(max_retries=max_retries)
|
|
85
|
+
self._client = httpx.Client(
|
|
86
|
+
base_url=self._base_url,
|
|
87
|
+
headers=_build_headers(api_key),
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> A3Client:
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *args: object) -> None:
|
|
95
|
+
self.close()
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
self._client.close()
|
|
99
|
+
|
|
100
|
+
def assess_age(self, request: AssessAgeRequest) -> AssessAgeResponse:
|
|
101
|
+
"""Assess age and return a parsed response model."""
|
|
102
|
+
raw = self.assess_age_raw(request)
|
|
103
|
+
return AssessAgeResponse.model_validate(raw)
|
|
104
|
+
|
|
105
|
+
def assess_age_raw(self, request: AssessAgeRequest) -> dict[str, Any]:
|
|
106
|
+
"""Assess age and return the raw JSON dict."""
|
|
107
|
+
return with_retry_sync(
|
|
108
|
+
lambda: self._do_assess_age(request),
|
|
109
|
+
self._retry_config,
|
|
110
|
+
_should_retry,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _do_assess_age(self, request: AssessAgeRequest) -> dict[str, Any]:
|
|
114
|
+
try:
|
|
115
|
+
response = self._client.post(
|
|
116
|
+
"/v1/assurance/assess-age",
|
|
117
|
+
json=request.model_dump(exclude_none=True),
|
|
118
|
+
)
|
|
119
|
+
except httpx.TimeoutException as exc:
|
|
120
|
+
raise A3ConnectionError(f"Request timed out: {exc}", exc) from exc
|
|
121
|
+
except httpx.NetworkError as exc:
|
|
122
|
+
raise A3ConnectionError(f"Network error: {exc}", exc) from exc
|
|
123
|
+
except httpx.ProtocolError as exc:
|
|
124
|
+
raise A3ConnectionError(f"Protocol error: {exc}", exc) from exc
|
|
125
|
+
|
|
126
|
+
if response.is_success:
|
|
127
|
+
return response.json() # type: ignore[no-any-return]
|
|
128
|
+
_handle_error(response)
|
|
129
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AsyncA3Client:
|
|
133
|
+
"""Asynchronous A3 API client using httpx."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
api_key: str,
|
|
138
|
+
*,
|
|
139
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
140
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
141
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
142
|
+
) -> None:
|
|
143
|
+
if not api_key:
|
|
144
|
+
raise ValueError("api_key is required")
|
|
145
|
+
self._api_key = api_key
|
|
146
|
+
self._base_url = base_url.rstrip("/")
|
|
147
|
+
self._retry_config = RetryConfig(max_retries=max_retries)
|
|
148
|
+
self._client = httpx.AsyncClient(
|
|
149
|
+
base_url=self._base_url,
|
|
150
|
+
headers=_build_headers(api_key),
|
|
151
|
+
timeout=timeout,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def __aenter__(self) -> AsyncA3Client:
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
async def __aexit__(self, *args: object) -> None:
|
|
158
|
+
await self.close()
|
|
159
|
+
|
|
160
|
+
async def close(self) -> None:
|
|
161
|
+
await self._client.aclose()
|
|
162
|
+
|
|
163
|
+
async def assess_age(self, request: AssessAgeRequest) -> AssessAgeResponse:
|
|
164
|
+
"""Assess age and return a parsed response model."""
|
|
165
|
+
raw = await self.assess_age_raw(request)
|
|
166
|
+
return AssessAgeResponse.model_validate(raw)
|
|
167
|
+
|
|
168
|
+
async def assess_age_raw(self, request: AssessAgeRequest) -> dict[str, Any]:
|
|
169
|
+
"""Assess age and return the raw JSON dict."""
|
|
170
|
+
return await with_retry_async(
|
|
171
|
+
lambda: self._do_assess_age(request),
|
|
172
|
+
self._retry_config,
|
|
173
|
+
_should_retry,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def _do_assess_age(self, request: AssessAgeRequest) -> dict[str, Any]:
|
|
177
|
+
try:
|
|
178
|
+
response = await self._client.post(
|
|
179
|
+
"/v1/assurance/assess-age",
|
|
180
|
+
json=request.model_dump(exclude_none=True),
|
|
181
|
+
)
|
|
182
|
+
except httpx.TimeoutException as exc:
|
|
183
|
+
raise A3ConnectionError(f"Request timed out: {exc}", exc) from exc
|
|
184
|
+
except httpx.NetworkError as exc:
|
|
185
|
+
raise A3ConnectionError(f"Network error: {exc}", exc) from exc
|
|
186
|
+
except httpx.ProtocolError as exc:
|
|
187
|
+
raise A3ConnectionError(f"Protocol error: {exc}", exc) from exc
|
|
188
|
+
|
|
189
|
+
if response.is_success:
|
|
190
|
+
return response.json() # type: ignore[no-any-return]
|
|
191
|
+
_handle_error(response)
|
|
192
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class A3ApiError(Exception):
|
|
7
|
+
"""Base error for all A3 API errors with an HTTP status code."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
status_code: int,
|
|
13
|
+
body: Optional[dict[str, Any]] = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.body = body
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class A3AuthenticationError(A3ApiError):
|
|
21
|
+
"""Raised on 401 Unauthorized responses."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, body: Optional[dict[str, Any]] = None) -> None:
|
|
24
|
+
raw = body.get("message", "Unauthorized") if body else "Unauthorized"
|
|
25
|
+
msg = ", ".join(raw) if isinstance(raw, list) else str(raw)
|
|
26
|
+
super().__init__(msg, 401, body)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class A3RateLimitError(A3ApiError):
|
|
30
|
+
"""Raised on 429 Too Many Requests responses."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
retry_after: Optional[float] = None,
|
|
35
|
+
body: Optional[dict[str, Any]] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__("Rate limit exceeded", 429, body)
|
|
38
|
+
self.retry_after = retry_after
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class A3ValidationError(A3ApiError):
|
|
42
|
+
"""Raised on 400 Bad Request responses with validation details."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, body: Optional[dict[str, Any]] = None) -> None:
|
|
45
|
+
raw: Union[str, list[str]] = (body or {}).get("message", [])
|
|
46
|
+
self.validation_errors: list[str] = (
|
|
47
|
+
raw if isinstance(raw, list) else [raw] if raw else []
|
|
48
|
+
)
|
|
49
|
+
msg = (
|
|
50
|
+
f"Validation failed: {', '.join(self.validation_errors)}"
|
|
51
|
+
if self.validation_errors
|
|
52
|
+
else "Validation failed"
|
|
53
|
+
)
|
|
54
|
+
super().__init__(msg, 400, body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class A3ConnectionError(Exception):
|
|
58
|
+
"""Raised on network or timeout errors (no HTTP status code)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.__cause__ = cause
|