sequenx-ai 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.
- sequenx_ai-0.1.0/PKG-INFO +106 -0
- sequenx_ai-0.1.0/README.md +85 -0
- sequenx_ai-0.1.0/pyproject.toml +46 -0
- sequenx_ai-0.1.0/setup.cfg +4 -0
- sequenx_ai-0.1.0/src/sequenx/__init__.py +59 -0
- sequenx_ai-0.1.0/src/sequenx/_endpoints.py +44 -0
- sequenx_ai-0.1.0/src/sequenx/_http.py +170 -0
- sequenx_ai-0.1.0/src/sequenx/_version.py +1 -0
- sequenx_ai-0.1.0/src/sequenx/client.py +226 -0
- sequenx_ai-0.1.0/src/sequenx/errors.py +84 -0
- sequenx_ai-0.1.0/src/sequenx/models.py +25 -0
- sequenx_ai-0.1.0/src/sequenx/py.typed +0 -0
- sequenx_ai-0.1.0/src/sequenx/types.py +214 -0
- sequenx_ai-0.1.0/src/sequenx_ai.egg-info/PKG-INFO +106 -0
- sequenx_ai-0.1.0/src/sequenx_ai.egg-info/SOURCES.txt +20 -0
- sequenx_ai-0.1.0/src/sequenx_ai.egg-info/dependency_links.txt +1 -0
- sequenx_ai-0.1.0/src/sequenx_ai.egg-info/requires.txt +7 -0
- sequenx_ai-0.1.0/src/sequenx_ai.egg-info/top_level.txt +1 -0
- sequenx_ai-0.1.0/tests/test_client_forecast.py +148 -0
- sequenx_ai-0.1.0/tests/test_contract_drift.py +111 -0
- sequenx_ai-0.1.0/tests/test_errors.py +88 -0
- sequenx_ai-0.1.0/tests/test_retry.py +240 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sequenx-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the SequenxAI time-series platform API
|
|
5
|
+
Author-email: SequenxAI <lcw_1497007939@163.com>
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Keywords: time-series,forecasting,sequenxai
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
17
|
+
Provides-Extra: pandas
|
|
18
|
+
Requires-Dist: pandas>=2.0; extra == "pandas"
|
|
19
|
+
Provides-Extra: polars
|
|
20
|
+
Requires-Dist: polars>=1.0; extra == "polars"
|
|
21
|
+
|
|
22
|
+
# sequenx (SequenxAI Python client)
|
|
23
|
+
|
|
24
|
+
Python client for the SequenxAI platform API (`packages/contracts/openapi.yaml` is the single
|
|
25
|
+
source of truth for the wire format). Install locally:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e packages/sequenx-py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from sequenx import SequenxClient
|
|
33
|
+
|
|
34
|
+
client = SequenxClient("sqx_live_...")
|
|
35
|
+
result = client.forecast(series, h=96) # series: list[dict] with unique_id/ds/y
|
|
36
|
+
print(result["model_used"], result["usage"]["billable_points"])
|
|
37
|
+
client.close()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Covered endpoints (v1): `POST /forecast`, `POST /forecast/rolling`, `POST /forecast/batch`
|
|
41
|
+
(+ get / cancel / delete job), `POST /clean` (dry-run), `GET /models`. `POST /anomaly-detection`
|
|
42
|
+
is intentionally not covered (endpoint not open). PyPI publishing is a separate decision.
|
|
43
|
+
|
|
44
|
+
## Idempotency and retries
|
|
45
|
+
|
|
46
|
+
The control plane claims an `Idempotency-Key` on `POST /forecast`, `POST /forecast/rolling` and
|
|
47
|
+
`POST /forecast/batch` only: a repeated identical request replays the stored response instead of
|
|
48
|
+
charging again. The SDK sends the key on exactly those three routes and reuses it across retries,
|
|
49
|
+
so a transport error or a 429/5xx is retried safely (`max_retries=0` disables retries).
|
|
50
|
+
|
|
51
|
+
A retry that races the original request can answer 409 (`IDEMPOTENCY_IN_PROGRESS`): the request
|
|
52
|
+
that claimed the key is still running. The SDK retries it with the same key (the control plane
|
|
53
|
+
replays the stored response once it settles). If it still cannot finish, the raised
|
|
54
|
+
`ConflictError` carries the `idempotency_key` it used, so the call can be replayed safely:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from sequenx import ConflictError
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
out = client.forecast(series, h=96)
|
|
61
|
+
except ConflictError as exc:
|
|
62
|
+
out = client.forecast(series, h=96, idempotency_key=exc.idempotency_key)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The SDK generates one key per call; pass your own `idempotency_key=` to `forecast`,
|
|
66
|
+
`forecast_rolling` or `forecast_batch` when you want to hold on to it yourself.
|
|
67
|
+
|
|
68
|
+
Every other route is re-run by the control plane, so the SDK neither sends a key nor retries it
|
|
69
|
+
automatically: `POST /clean` (a free dry-run), `POST /forecast/batch/{job_id}/cancel` and
|
|
70
|
+
`DELETE /forecast/batch/{job_id}` (a repeated delete would answer 404 although the first delete
|
|
71
|
+
succeeded — read the job with `batch_job()` when the outcome matters). `GET` requests are retried.
|
|
72
|
+
|
|
73
|
+
## Batch jobs
|
|
74
|
+
|
|
75
|
+
`forecast_batch()` submits a job; `batch_job()` polls it, `cancel_batch_job()` cancels it and
|
|
76
|
+
`delete_batch_job()` removes a finished job and its result file. Polling is the caller's loop.
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
Errors are typed (`AuthenticationError`, `ValidationError`, `RateLimitedError`, `ServerError`,
|
|
81
|
+
`TransportError`, ...) and carry the contract's `code` / `request_id` / `status`.
|
|
82
|
+
|
|
83
|
+
## Response types
|
|
84
|
+
|
|
85
|
+
Responses are typed too (`ForecastResponse`, `RollingForecastResponse`, `ModelList`,
|
|
86
|
+
`BatchJobSummary`, `BatchJobDetail`, `CleanResponse`, ...); the shapes mirror the contract
|
|
87
|
+
schemas and the package ships `py.typed`. Nested objects the SDK does not model (intervals,
|
|
88
|
+
cleaning report, effective policy) stay plain dictionaries.
|
|
89
|
+
|
|
90
|
+
## DataFrames
|
|
91
|
+
|
|
92
|
+
`pandas` and `polars` frames are accepted as input (`pip install -e "packages/sequenx-py[pandas]"`
|
|
93
|
+
or `[polars]`); neither is required.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e packages/sequenx-py -r packages/sequenx-py/requirements-dev.txt
|
|
99
|
+
python -m pytest -q # unit + contract drift tests
|
|
100
|
+
python -m ruff check src tests
|
|
101
|
+
SEQUENX_SDK_LIVE_SMOKE=1 SEQUENX_API_KEY=sqx_live_... python scripts/live_smoke.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The live smoke is opt-in and never passes silently: without the flag and key it exits 2 with
|
|
105
|
+
instructions. The contract drift test reads `packages/contracts/openapi.yaml` and fails when the
|
|
106
|
+
SDK and the contract disagree; Gitee Go runs it via `scripts/ci/gitee-verify.sh sdk`.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# sequenx (SequenxAI Python client)
|
|
2
|
+
|
|
3
|
+
Python client for the SequenxAI platform API (`packages/contracts/openapi.yaml` is the single
|
|
4
|
+
source of truth for the wire format). Install locally:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install -e packages/sequenx-py
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from sequenx import SequenxClient
|
|
12
|
+
|
|
13
|
+
client = SequenxClient("sqx_live_...")
|
|
14
|
+
result = client.forecast(series, h=96) # series: list[dict] with unique_id/ds/y
|
|
15
|
+
print(result["model_used"], result["usage"]["billable_points"])
|
|
16
|
+
client.close()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Covered endpoints (v1): `POST /forecast`, `POST /forecast/rolling`, `POST /forecast/batch`
|
|
20
|
+
(+ get / cancel / delete job), `POST /clean` (dry-run), `GET /models`. `POST /anomaly-detection`
|
|
21
|
+
is intentionally not covered (endpoint not open). PyPI publishing is a separate decision.
|
|
22
|
+
|
|
23
|
+
## Idempotency and retries
|
|
24
|
+
|
|
25
|
+
The control plane claims an `Idempotency-Key` on `POST /forecast`, `POST /forecast/rolling` and
|
|
26
|
+
`POST /forecast/batch` only: a repeated identical request replays the stored response instead of
|
|
27
|
+
charging again. The SDK sends the key on exactly those three routes and reuses it across retries,
|
|
28
|
+
so a transport error or a 429/5xx is retried safely (`max_retries=0` disables retries).
|
|
29
|
+
|
|
30
|
+
A retry that races the original request can answer 409 (`IDEMPOTENCY_IN_PROGRESS`): the request
|
|
31
|
+
that claimed the key is still running. The SDK retries it with the same key (the control plane
|
|
32
|
+
replays the stored response once it settles). If it still cannot finish, the raised
|
|
33
|
+
`ConflictError` carries the `idempotency_key` it used, so the call can be replayed safely:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from sequenx import ConflictError
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
out = client.forecast(series, h=96)
|
|
40
|
+
except ConflictError as exc:
|
|
41
|
+
out = client.forecast(series, h=96, idempotency_key=exc.idempotency_key)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The SDK generates one key per call; pass your own `idempotency_key=` to `forecast`,
|
|
45
|
+
`forecast_rolling` or `forecast_batch` when you want to hold on to it yourself.
|
|
46
|
+
|
|
47
|
+
Every other route is re-run by the control plane, so the SDK neither sends a key nor retries it
|
|
48
|
+
automatically: `POST /clean` (a free dry-run), `POST /forecast/batch/{job_id}/cancel` and
|
|
49
|
+
`DELETE /forecast/batch/{job_id}` (a repeated delete would answer 404 although the first delete
|
|
50
|
+
succeeded — read the job with `batch_job()` when the outcome matters). `GET` requests are retried.
|
|
51
|
+
|
|
52
|
+
## Batch jobs
|
|
53
|
+
|
|
54
|
+
`forecast_batch()` submits a job; `batch_job()` polls it, `cancel_batch_job()` cancels it and
|
|
55
|
+
`delete_batch_job()` removes a finished job and its result file. Polling is the caller's loop.
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
Errors are typed (`AuthenticationError`, `ValidationError`, `RateLimitedError`, `ServerError`,
|
|
60
|
+
`TransportError`, ...) and carry the contract's `code` / `request_id` / `status`.
|
|
61
|
+
|
|
62
|
+
## Response types
|
|
63
|
+
|
|
64
|
+
Responses are typed too (`ForecastResponse`, `RollingForecastResponse`, `ModelList`,
|
|
65
|
+
`BatchJobSummary`, `BatchJobDetail`, `CleanResponse`, ...); the shapes mirror the contract
|
|
66
|
+
schemas and the package ships `py.typed`. Nested objects the SDK does not model (intervals,
|
|
67
|
+
cleaning report, effective policy) stay plain dictionaries.
|
|
68
|
+
|
|
69
|
+
## DataFrames
|
|
70
|
+
|
|
71
|
+
`pandas` and `polars` frames are accepted as input (`pip install -e "packages/sequenx-py[pandas]"`
|
|
72
|
+
or `[polars]`); neither is required.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install -e packages/sequenx-py -r packages/sequenx-py/requirements-dev.txt
|
|
78
|
+
python -m pytest -q # unit + contract drift tests
|
|
79
|
+
python -m ruff check src tests
|
|
80
|
+
SEQUENX_SDK_LIVE_SMOKE=1 SEQUENX_API_KEY=sqx_live_... python scripts/live_smoke.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The live smoke is opt-in and never passes silently: without the flag and key it exits 2 with
|
|
84
|
+
instructions. The contract drift test reads `packages/contracts/openapi.yaml` and fails when the
|
|
85
|
+
SDK and the contract disagree; Gitee Go runs it via `scripts/ci/gitee-verify.sh sdk`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sequenx-ai"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python client for the SequenxAI time-series platform API"
|
|
9
|
+
authors = [{ name = "SequenxAI", email = "lcw_1497007939@163.com" }]
|
|
10
|
+
keywords = ["time-series", "forecasting", "sequenxai"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
18
|
+
]
|
|
19
|
+
readme = "README.md"
|
|
20
|
+
requires-python = ">=3.10"
|
|
21
|
+
license = "LicenseRef-Proprietary"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.27,<1.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
pandas = ["pandas>=2.0"]
|
|
28
|
+
polars = ["polars>=1.0"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.dynamic]
|
|
31
|
+
version = { attr = "sequenx._version.__version__" }
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
sequenx = ["py.typed"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
target-version = "py310"
|
|
42
|
+
src = ["src", "tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
# Same rule set as the other Python packages in the monorepo.
|
|
46
|
+
select = ["E", "F", "I", "B", "W", "UP"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Python client for the SequenxAI platform API."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import SequenxClient
|
|
5
|
+
from .errors import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
RateLimitedError,
|
|
10
|
+
SequenxError,
|
|
11
|
+
ServerError,
|
|
12
|
+
TransportError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
from .types import (
|
|
16
|
+
BatchJobDetail,
|
|
17
|
+
BatchJobProgress,
|
|
18
|
+
BatchJobSummary,
|
|
19
|
+
CleanResponse,
|
|
20
|
+
DataQualityReport,
|
|
21
|
+
ForecastPoint,
|
|
22
|
+
ForecastResponse,
|
|
23
|
+
ForecastWarning,
|
|
24
|
+
ModelCapabilities,
|
|
25
|
+
ModelInfo,
|
|
26
|
+
ModelLimits,
|
|
27
|
+
ModelList,
|
|
28
|
+
RollingForecastPoint,
|
|
29
|
+
RollingForecastResponse,
|
|
30
|
+
UsageMetrics,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"BatchJobDetail",
|
|
36
|
+
"BatchJobProgress",
|
|
37
|
+
"BatchJobSummary",
|
|
38
|
+
"CleanResponse",
|
|
39
|
+
"DataQualityReport",
|
|
40
|
+
"ConflictError",
|
|
41
|
+
"ForecastPoint",
|
|
42
|
+
"ForecastResponse",
|
|
43
|
+
"ForecastWarning",
|
|
44
|
+
"ModelCapabilities",
|
|
45
|
+
"ModelInfo",
|
|
46
|
+
"ModelLimits",
|
|
47
|
+
"ModelList",
|
|
48
|
+
"NotFoundError",
|
|
49
|
+
"RateLimitedError",
|
|
50
|
+
"RollingForecastPoint",
|
|
51
|
+
"RollingForecastResponse",
|
|
52
|
+
"SequenxClient",
|
|
53
|
+
"SequenxError",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"TransportError",
|
|
56
|
+
"UsageMetrics",
|
|
57
|
+
"ValidationError",
|
|
58
|
+
"__version__",
|
|
59
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The v1 endpoint surface: one row per client method.
|
|
2
|
+
|
|
3
|
+
Single source of truth shared by the client (method + path + policy) and the
|
|
4
|
+
contract drift test, which asserts that every method/path row below exists in
|
|
5
|
+
packages/contracts/openapi.yaml. Path templates use ``{param}`` placeholders
|
|
6
|
+
filled in by the calling client method.
|
|
7
|
+
|
|
8
|
+
Idempotency: the control plane claims an ``Idempotency-Key`` only on routes that
|
|
9
|
+
run through ``_idempotent -> _admit -> _claim_idempotency``
|
|
10
|
+
(services/api/src/sequenx_api/modules/public_api/router.py), i.e. ``POST
|
|
11
|
+
/forecast``, ``POST /forecast/rolling`` and ``POST /forecast/batch``; the claim is
|
|
12
|
+
settled with the response, and replaying an identical retry returns the stored
|
|
13
|
+
response instead of charging again
|
|
14
|
+
(services/api/tests/test_public_idempotency.py). That replay is what makes a
|
|
15
|
+
retry safe, so only those rows are auto-retried; every other route would simply
|
|
16
|
+
run again.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Endpoint:
|
|
26
|
+
"""One HTTP endpoint: wire shape plus its idempotency/retry policy."""
|
|
27
|
+
|
|
28
|
+
method: str
|
|
29
|
+
path: str
|
|
30
|
+
claims_idempotency_key: bool
|
|
31
|
+
auto_retry: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ENDPOINTS: dict[str, Endpoint] = {
|
|
35
|
+
# name: method, path, claims_key, auto_retry
|
|
36
|
+
"models": Endpoint("GET", "/models", False, True),
|
|
37
|
+
"forecast": Endpoint("POST", "/forecast", True, True),
|
|
38
|
+
"rolling": Endpoint("POST", "/forecast/rolling", True, True),
|
|
39
|
+
"batch": Endpoint("POST", "/forecast/batch", True, True),
|
|
40
|
+
"batch_job": Endpoint("GET", "/forecast/batch/{job_id}", False, True),
|
|
41
|
+
"cancel_batch_job": Endpoint("POST", "/forecast/batch/{job_id}/cancel", False, False),
|
|
42
|
+
"delete_batch_job": Endpoint("DELETE", "/forecast/batch/{job_id}", False, False),
|
|
43
|
+
"clean": Endpoint("POST", "/clean", False, False),
|
|
44
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""HTTP layer: auth header, retry policy and typed error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._endpoints import ENDPOINTS
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .errors import (
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RateLimitedError,
|
|
19
|
+
SequenxError,
|
|
20
|
+
ServerError,
|
|
21
|
+
TransportError,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
26
|
+
# 409 is the control plane's IDEMPOTENCY_IN_PROGRESS: the request that claimed
|
|
27
|
+
# the key is still running. Retrying it with the same key returns the stored
|
|
28
|
+
# response once it settles, so it is safe on the routes that carry a key.
|
|
29
|
+
_CONFLICT_STATUS = 409
|
|
30
|
+
_BACKOFF_SECONDS = 0.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _error_from_response(
|
|
34
|
+
resp: httpx.Response, idempotency_key: str | None = None
|
|
35
|
+
) -> SequenxError:
|
|
36
|
+
try:
|
|
37
|
+
body: Any = resp.json()
|
|
38
|
+
except ValueError:
|
|
39
|
+
body = {}
|
|
40
|
+
err: dict[str, Any] = {}
|
|
41
|
+
if isinstance(body, dict):
|
|
42
|
+
inner = body.get("error")
|
|
43
|
+
err = inner if isinstance(inner, dict) else {}
|
|
44
|
+
code = err.get("code")
|
|
45
|
+
message = err.get("message") or f"HTTP {resp.status_code}"
|
|
46
|
+
request_id = err.get("request_id") or resp.headers.get("x-request-id")
|
|
47
|
+
kw: dict[str, Any] = {
|
|
48
|
+
"code": code,
|
|
49
|
+
"request_id": request_id,
|
|
50
|
+
"status": resp.status_code,
|
|
51
|
+
"category": err.get("category"),
|
|
52
|
+
"retryable": err.get("retryable"),
|
|
53
|
+
"details": err.get("details"),
|
|
54
|
+
"idempotency_key": idempotency_key,
|
|
55
|
+
}
|
|
56
|
+
status = resp.status_code
|
|
57
|
+
if status in (401, 403):
|
|
58
|
+
return AuthenticationError(message, **kw)
|
|
59
|
+
if status == 404:
|
|
60
|
+
return NotFoundError(message, **kw)
|
|
61
|
+
if status == 409:
|
|
62
|
+
return ConflictError(message, **kw)
|
|
63
|
+
if status == 422:
|
|
64
|
+
return ValidationError(message, **kw)
|
|
65
|
+
if status == 429:
|
|
66
|
+
retry_after = resp.headers.get("retry-after")
|
|
67
|
+
limit_type = resp.headers.get("x-ratelimit-limit-type")
|
|
68
|
+
return RateLimitedError(
|
|
69
|
+
message,
|
|
70
|
+
retry_after=float(retry_after) if retry_after else None,
|
|
71
|
+
limit_type=limit_type,
|
|
72
|
+
**kw,
|
|
73
|
+
)
|
|
74
|
+
if status >= 500:
|
|
75
|
+
return ServerError(message, **kw)
|
|
76
|
+
return SequenxError(message, **kw)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Http:
|
|
80
|
+
"""Bearer-auth HTTP calls with per-endpoint retry policy and typed errors."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
api_key: str,
|
|
85
|
+
base_url: str,
|
|
86
|
+
*,
|
|
87
|
+
timeout: float = 60.0,
|
|
88
|
+
max_retries: int = 2,
|
|
89
|
+
transport: httpx.BaseTransport | None = None,
|
|
90
|
+
sleep: Any = time.sleep,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._client = httpx.Client(
|
|
93
|
+
base_url=base_url,
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
transport=transport,
|
|
96
|
+
headers={
|
|
97
|
+
"Authorization": f"Bearer {api_key}",
|
|
98
|
+
"User-Agent": f"sequenx-python/{__version__}",
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
self._max_retries = max(0, max_retries)
|
|
102
|
+
self._sleep = sleep
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
self._client.close()
|
|
106
|
+
|
|
107
|
+
def request(
|
|
108
|
+
self,
|
|
109
|
+
endpoint: str,
|
|
110
|
+
*,
|
|
111
|
+
path_args: Mapping[str, Any] | None = None,
|
|
112
|
+
json_body: Any = None,
|
|
113
|
+
params: dict[str, Any] | None = None,
|
|
114
|
+
idempotency_key: str | None = None,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Call one endpoint of the contract by name.
|
|
117
|
+
|
|
118
|
+
The endpoint name selects the method, path and the idempotency/retry
|
|
119
|
+
policy from ``_endpoints.ENDPOINTS``; the key is generated once per call
|
|
120
|
+
and reused by every retry of that call.
|
|
121
|
+
"""
|
|
122
|
+
spec = ENDPOINTS[endpoint]
|
|
123
|
+
if idempotency_key is not None and not spec.claims_idempotency_key:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"{endpoint}: the control plane ignores Idempotency-Key on "
|
|
126
|
+
f"{spec.method} {spec.path}; it is not claimed for idempotency there"
|
|
127
|
+
)
|
|
128
|
+
headers: dict[str, str] = {}
|
|
129
|
+
key: str | None = None
|
|
130
|
+
if spec.claims_idempotency_key:
|
|
131
|
+
key = idempotency_key or uuid.uuid4().hex
|
|
132
|
+
headers["Idempotency-Key"] = key
|
|
133
|
+
path = spec.path.format(**(path_args or {}))
|
|
134
|
+
attempt = 0
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
resp = self._client.request(
|
|
138
|
+
spec.method, path, json=json_body, params=params, headers=headers or None
|
|
139
|
+
)
|
|
140
|
+
except httpx.TransportError as exc:
|
|
141
|
+
if spec.auto_retry and attempt < self._max_retries:
|
|
142
|
+
attempt += 1
|
|
143
|
+
self._sleep(_BACKOFF_SECONDS * attempt)
|
|
144
|
+
continue
|
|
145
|
+
raise TransportError(
|
|
146
|
+
f"{spec.method} {path} failed: {exc}", idempotency_key=key
|
|
147
|
+
) from exc
|
|
148
|
+
retryable = (
|
|
149
|
+
spec.auto_retry
|
|
150
|
+
and attempt < self._max_retries
|
|
151
|
+
and (
|
|
152
|
+
resp.status_code in _RETRYABLE_STATUS
|
|
153
|
+
or (resp.status_code == _CONFLICT_STATUS and spec.claims_idempotency_key)
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
if retryable:
|
|
157
|
+
retry_after = resp.headers.get("retry-after")
|
|
158
|
+
self._sleep(float(retry_after) if retry_after else _BACKOFF_SECONDS * (attempt + 1))
|
|
159
|
+
attempt += 1
|
|
160
|
+
continue
|
|
161
|
+
if resp.is_success:
|
|
162
|
+
if resp.status_code == 204 or not resp.content:
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
return resp.json()
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
raise ValidationError(
|
|
168
|
+
f"the API returned a non-JSON body for {spec.method} {path}"
|
|
169
|
+
) from exc
|
|
170
|
+
raise _error_from_response(resp, key)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|