cosmoner-sdk 1.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.
- cosmoner_sdk-1.1.0/.gitignore +26 -0
- cosmoner_sdk-1.1.0/PKG-INFO +16 -0
- cosmoner_sdk-1.1.0/README.md +136 -0
- cosmoner_sdk-1.1.0/pyproject.toml +66 -0
- cosmoner_sdk-1.1.0/src/cosmoner/__init__.py +52 -0
- cosmoner_sdk-1.1.0/src/cosmoner/_config.py +61 -0
- cosmoner_sdk-1.1.0/src/cosmoner/_retry.py +84 -0
- cosmoner_sdk-1.1.0/src/cosmoner/_transport.py +209 -0
- cosmoner_sdk-1.1.0/src/cosmoner/_version.py +12 -0
- cosmoner_sdk-1.1.0/src/cosmoner/client.py +96 -0
- cosmoner_sdk-1.1.0/src/cosmoner/email.py +103 -0
- cosmoner_sdk-1.1.0/src/cosmoner/errors.py +150 -0
- cosmoner_sdk-1.1.0/src/cosmoner/webhook_signature.py +131 -0
- cosmoner_sdk-1.1.0/src/cosmoner/webhooks.py +456 -0
- cosmoner_sdk-1.1.0/tests/__init__.py +0 -0
- cosmoner_sdk-1.1.0/tests/test_client.py +110 -0
- cosmoner_sdk-1.1.0/tests/test_email.py +223 -0
- cosmoner_sdk-1.1.0/tests/test_transport.py +180 -0
- cosmoner_sdk-1.1.0/tests/test_webhook_signature.py +141 -0
- cosmoner_sdk-1.1.0/tests/test_webhooks.py +352 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# JavaScript
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
*.pyc
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.venv/
|
|
15
|
+
|
|
16
|
+
# PHP
|
|
17
|
+
vendor/
|
|
18
|
+
.phpunit.result.cache
|
|
19
|
+
.phpcs-cache
|
|
20
|
+
.phpstan.cache/
|
|
21
|
+
|
|
22
|
+
# IDE
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
*.swp
|
|
26
|
+
.DS_Store
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cosmoner-sdk
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Cosmoner SDK for Python
|
|
5
|
+
Project-URL: Homepage, https://cosmoner.com
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: cosmoner,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: httpx>=0.24
|
|
10
|
+
Provides-Extra: lint
|
|
11
|
+
Requires-Dist: mypy>=1.15; extra == 'lint'
|
|
12
|
+
Requires-Dist: ruff>=0.9; extra == 'lint'
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
15
|
+
Requires-Dist: pytest-httpx>=0.28; extra == 'test'
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# cosmoner-sdk
|
|
2
|
+
|
|
3
|
+
Official Cosmoner SDK for Python.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install cosmoner-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from cosmoner import Cosmoner
|
|
15
|
+
|
|
16
|
+
client = Cosmoner(
|
|
17
|
+
api_key="your-api-key",
|
|
18
|
+
project_id="your-project-id",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Send to a single recipient
|
|
22
|
+
result = client.email.send(
|
|
23
|
+
credential_id="your-credential-id",
|
|
24
|
+
to="recipient@example.com",
|
|
25
|
+
subject="Hello from Cosmoner",
|
|
26
|
+
html="<h1>Hello!</h1><p>This is a test email.</p>",
|
|
27
|
+
)
|
|
28
|
+
print(result["data"]["messageId"])
|
|
29
|
+
|
|
30
|
+
# Send to multiple recipients (up to 50)
|
|
31
|
+
client.email.send(
|
|
32
|
+
credential_id="your-credential-id",
|
|
33
|
+
to=["alice@example.com", "bob@example.com"],
|
|
34
|
+
subject="Team Update",
|
|
35
|
+
text="Plain text email body",
|
|
36
|
+
reply_to="noreply@yourdomain.com",
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
| Parameter | Required | Default | Description |
|
|
43
|
+
| ------------- | -------- | -------------------------- | -------------------------------------------------- |
|
|
44
|
+
| `api_key` | Yes | — | Your Cosmoner API key |
|
|
45
|
+
| `project_id` | No | — | Default project; can also be passed per call |
|
|
46
|
+
| `base_url` | No | `https://api.cosmoner.com` | API base URL override |
|
|
47
|
+
| `timeout` | No | `30.0` | Per-request timeout in seconds |
|
|
48
|
+
| `max_retries` | No | `2` | Retries for transient failures (see [Retries](#retries)) |
|
|
49
|
+
|
|
50
|
+
`project_id` is optional so one client can span projects. Pass it per call to
|
|
51
|
+
override the default:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client = Cosmoner(api_key="your-api-key")
|
|
55
|
+
|
|
56
|
+
client.email.send(project_id="proj-2", credential_id="...", to="...", subject="...", text="...")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Async
|
|
60
|
+
|
|
61
|
+
`AsyncCosmoner` mirrors the sync client method for method:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from cosmoner import AsyncCosmoner
|
|
65
|
+
|
|
66
|
+
async with AsyncCosmoner(api_key="your-api-key", project_id="your-project-id") as client:
|
|
67
|
+
await client.email.send(
|
|
68
|
+
credential_id="your-credential-id",
|
|
69
|
+
to="recipient@example.com",
|
|
70
|
+
subject="Hello",
|
|
71
|
+
text="Hi there",
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Both clients hold a connection pool. Use them as context managers, or call
|
|
76
|
+
`client.close()` / `await client.aclose()` when you are done.
|
|
77
|
+
|
|
78
|
+
## Retries
|
|
79
|
+
|
|
80
|
+
Transient failures are retried automatically with exponential backoff and full
|
|
81
|
+
jitter, honouring `Retry-After` when the API sends it.
|
|
82
|
+
|
|
83
|
+
- **429** is always retried — the request was rejected before it was processed,
|
|
84
|
+
so replaying it cannot duplicate a side effect.
|
|
85
|
+
- **Timeouts and 5xx** are retried only for idempotent methods. A write such as
|
|
86
|
+
`email.send` is *not* replayed, because the API may have completed the send
|
|
87
|
+
before failing to answer.
|
|
88
|
+
|
|
89
|
+
Set `max_retries=0` to disable retries entirely.
|
|
90
|
+
|
|
91
|
+
## Email
|
|
92
|
+
|
|
93
|
+
### `client.email.send(...)`
|
|
94
|
+
|
|
95
|
+
| Parameter | Type | Required | Description |
|
|
96
|
+
| --------------- | ------------------------ | -------- | ------------------------------------ |
|
|
97
|
+
| `credential_id` | `str` | Yes | SMTP credential ID |
|
|
98
|
+
| `to` | `str \| list[str]` | Yes | Recipient(s), max 50 |
|
|
99
|
+
| `subject` | `str` | Yes | Email subject line |
|
|
100
|
+
| `html` | `str` | No* | HTML body |
|
|
101
|
+
| `text` | `str` | No* | Plain text body |
|
|
102
|
+
| `reply_to` | `str \| list[str]` | No | Reply-to address(es), max 5 |
|
|
103
|
+
| `project_id` | `str` | No | Overrides the client-level project |
|
|
104
|
+
|
|
105
|
+
\* At least one of `html` or `text` must be provided.
|
|
106
|
+
|
|
107
|
+
## Error Handling
|
|
108
|
+
|
|
109
|
+
Every failure raises a subclass of `CosmonerError`, so you can catch broadly or
|
|
110
|
+
narrowly:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from cosmoner import Cosmoner, CosmonerError, RateLimitError
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
client.email.send(...)
|
|
117
|
+
except RateLimitError as e:
|
|
118
|
+
print(e.retry_after) # seconds, when the API supplies it
|
|
119
|
+
except CosmonerError as e:
|
|
120
|
+
print(e.code) # e.g. "INSUFFICIENT_SCOPE"
|
|
121
|
+
print(e.status) # e.g. 403
|
|
122
|
+
print(e.details) # field-level validation errors, when present
|
|
123
|
+
print(str(e)) # Human-readable message
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Exception | Raised on |
|
|
127
|
+
| -------------------------- | --------------------------------------------- |
|
|
128
|
+
| `ValidationError` | 400, 422 |
|
|
129
|
+
| `AuthenticationError` | 401 |
|
|
130
|
+
| `InsufficientScopeError` | 403 — API key is missing a `resource:action` |
|
|
131
|
+
| `NotFoundError` | 404 |
|
|
132
|
+
| `ConflictError` | 409 |
|
|
133
|
+
| `RateLimitError` | 429 |
|
|
134
|
+
| `ServerError` | 5xx |
|
|
135
|
+
| `CosmonerTimeoutError` | Request exceeded `timeout` |
|
|
136
|
+
| `CosmonerConnectionError` | DNS, TCP, TLS or socket failure |
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cosmoner-sdk"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Cosmoner SDK for Python"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.24",
|
|
13
|
+
]
|
|
14
|
+
keywords = ["cosmoner", "sdk"]
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/cosmoner"]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
test = [
|
|
21
|
+
"pytest>=7.0",
|
|
22
|
+
"pytest-httpx>=0.28",
|
|
23
|
+
"pytest-asyncio>=0.23",
|
|
24
|
+
]
|
|
25
|
+
lint = [
|
|
26
|
+
"ruff>=0.9",
|
|
27
|
+
"mypy>=1.15",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 90
|
|
36
|
+
target-version = "py39"
|
|
37
|
+
src = ["src", "tests"]
|
|
38
|
+
extend-exclude = ["*.md"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = [
|
|
42
|
+
"E", # pycodestyle errors
|
|
43
|
+
"W", # pycodestyle warnings
|
|
44
|
+
"F", # pyflakes
|
|
45
|
+
"I", # isort
|
|
46
|
+
"B", # flake8-bugbear
|
|
47
|
+
"UP", # pyupgrade
|
|
48
|
+
"SIM", # flake8-simplify
|
|
49
|
+
"RUF", # ruff-specific
|
|
50
|
+
"D", # pydocstyle — every function/class needs a docstring
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint.pydocstyle]
|
|
54
|
+
convention = "google"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint.per-file-ignores]
|
|
57
|
+
# Tests document themselves through their names; docstrings on every case are noise.
|
|
58
|
+
"tests/*" = ["D"]
|
|
59
|
+
|
|
60
|
+
[tool.mypy]
|
|
61
|
+
files = ["src"]
|
|
62
|
+
strict = true
|
|
63
|
+
warn_unused_ignores = true
|
|
64
|
+
|
|
65
|
+
[project.urls]
|
|
66
|
+
Homepage = "https://cosmoner.com"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Cosmoner SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import AsyncCosmoner, Cosmoner
|
|
5
|
+
from .errors import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
CosmonerConnectionError,
|
|
9
|
+
CosmonerError,
|
|
10
|
+
CosmonerTimeoutError,
|
|
11
|
+
InsufficientScopeError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
WebhookSignatureError,
|
|
17
|
+
)
|
|
18
|
+
from .webhook_signature import (
|
|
19
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
20
|
+
DELIVERY_ID_HEADER,
|
|
21
|
+
EVENT_TYPE_HEADER,
|
|
22
|
+
SIGNATURE_HEADER,
|
|
23
|
+
construct_event,
|
|
24
|
+
verify_webhook_signature,
|
|
25
|
+
)
|
|
26
|
+
from .webhooks import WEBHOOK_EVENT_TYPES, AsyncWebhooksService, WebhooksService
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
30
|
+
"DELIVERY_ID_HEADER",
|
|
31
|
+
"EVENT_TYPE_HEADER",
|
|
32
|
+
"SIGNATURE_HEADER",
|
|
33
|
+
"WEBHOOK_EVENT_TYPES",
|
|
34
|
+
"AsyncCosmoner",
|
|
35
|
+
"AsyncWebhooksService",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"ConflictError",
|
|
38
|
+
"Cosmoner",
|
|
39
|
+
"CosmonerConnectionError",
|
|
40
|
+
"CosmonerError",
|
|
41
|
+
"CosmonerTimeoutError",
|
|
42
|
+
"InsufficientScopeError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"RateLimitError",
|
|
45
|
+
"ServerError",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"WebhookSignatureError",
|
|
48
|
+
"WebhooksService",
|
|
49
|
+
"__version__",
|
|
50
|
+
"construct_event",
|
|
51
|
+
"verify_webhook_signature",
|
|
52
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared, validated configuration for the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
DEFAULT_BASE_URL = "https://api.cosmoner.com"
|
|
8
|
+
DEFAULT_TIMEOUT = 30.0
|
|
9
|
+
DEFAULT_MAX_RETRIES = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ClientConfig:
|
|
14
|
+
"""Immutable connection settings shared by a client and its service namespaces."""
|
|
15
|
+
|
|
16
|
+
api_key: str
|
|
17
|
+
project_id: str | None
|
|
18
|
+
base_url: str
|
|
19
|
+
timeout: float
|
|
20
|
+
max_retries: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_config(
|
|
24
|
+
api_key: str,
|
|
25
|
+
project_id: str | None,
|
|
26
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
27
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
28
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
29
|
+
) -> ClientConfig:
|
|
30
|
+
"""Validates constructor arguments and normalizes them into a ``ClientConfig``.
|
|
31
|
+
|
|
32
|
+
``project_id`` is optional so a single client can span projects, but an
|
|
33
|
+
explicitly empty string is rejected rather than silently treated as unset —
|
|
34
|
+
that almost always means an unresolved environment variable.
|
|
35
|
+
"""
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise ValueError("api_key is required")
|
|
38
|
+
if project_id is not None and not project_id:
|
|
39
|
+
raise ValueError("project_id is required")
|
|
40
|
+
if timeout <= 0:
|
|
41
|
+
raise ValueError("timeout must be greater than 0")
|
|
42
|
+
if max_retries < 0:
|
|
43
|
+
raise ValueError("max_retries cannot be negative")
|
|
44
|
+
|
|
45
|
+
return ClientConfig(
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
project_id=project_id,
|
|
48
|
+
base_url=base_url.rstrip("/"),
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
max_retries=max_retries,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_project_id(config: ClientConfig, override: str | None = None) -> str:
|
|
55
|
+
"""Returns the per-call project id, falling back to the client-level default."""
|
|
56
|
+
project_id = override or config.project_id
|
|
57
|
+
if not project_id:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"project_id is required — pass it to the Cosmoner client or to this method"
|
|
60
|
+
)
|
|
61
|
+
return project_id
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Retry policy governing which failed requests are safe to send again."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
# Methods the HTTP spec defines as idempotent: replaying one cannot create a
|
|
10
|
+
# second side effect, so they are safe to retry after a timeout or a 5xx.
|
|
11
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
|
|
12
|
+
|
|
13
|
+
BACKOFF_BASE_SECONDS = 0.5
|
|
14
|
+
BACKOFF_CAP_SECONDS = 8.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RetryPolicy:
|
|
19
|
+
"""Decides whether a given failure should be retried, and how long to wait."""
|
|
20
|
+
|
|
21
|
+
max_retries: int
|
|
22
|
+
|
|
23
|
+
def should_retry(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
attempt: int,
|
|
27
|
+
method: str,
|
|
28
|
+
status: int | None,
|
|
29
|
+
retry_non_idempotent: bool = False,
|
|
30
|
+
) -> bool:
|
|
31
|
+
"""Returns whether another attempt is warranted.
|
|
32
|
+
|
|
33
|
+
A ``status`` of ``None`` means the request failed at the transport layer
|
|
34
|
+
and never reached the API.
|
|
35
|
+
|
|
36
|
+
429 is always retryable regardless of method: the request was rejected
|
|
37
|
+
before it was processed, so replaying it cannot duplicate a side effect.
|
|
38
|
+
Timeouts and 5xx are different — the API may well have completed the work
|
|
39
|
+
before failing to answer, so those are only retried for idempotent
|
|
40
|
+
methods, or when the caller opts in because the request carries an
|
|
41
|
+
idempotency key the server honours.
|
|
42
|
+
"""
|
|
43
|
+
if attempt >= self.max_retries:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if status == 429:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
is_retryable_failure = status is None or status >= 500
|
|
50
|
+
if not is_retryable_failure:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
return method.upper() in IDEMPOTENT_METHODS or retry_non_idempotent
|
|
54
|
+
|
|
55
|
+
def backoff_seconds(self, attempt: int, retry_after: float | None = None) -> float:
|
|
56
|
+
"""Returns the delay before the next attempt.
|
|
57
|
+
|
|
58
|
+
A server-supplied ``Retry-After`` wins outright. Otherwise the delay is
|
|
59
|
+
exponential with full jitter, which spreads a thundering herd of clients
|
|
60
|
+
that all got rate limited in the same window.
|
|
61
|
+
"""
|
|
62
|
+
if retry_after is not None and retry_after >= 0:
|
|
63
|
+
return min(retry_after, BACKOFF_CAP_SECONDS)
|
|
64
|
+
|
|
65
|
+
ceiling = min(BACKOFF_BASE_SECONDS * (2**attempt), BACKOFF_CAP_SECONDS)
|
|
66
|
+
return random.uniform(0, ceiling)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_retry_after(headers: Mapping[str, str]) -> float | None:
|
|
70
|
+
"""Reads a ``Retry-After`` header expressed in seconds.
|
|
71
|
+
|
|
72
|
+
The API does not currently send this header; it is honoured so the SDK
|
|
73
|
+
starts respecting it the moment the platform adds it. The HTTP-date form is
|
|
74
|
+
ignored deliberately — it is rare in practice and jittered backoff is a
|
|
75
|
+
perfectly good fallback.
|
|
76
|
+
"""
|
|
77
|
+
raw = headers.get("retry-after") or headers.get("Retry-After")
|
|
78
|
+
if raw is None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return float(raw)
|
|
83
|
+
except (TypeError, ValueError):
|
|
84
|
+
return None
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""HTTP transport: header assembly, retries, and response/error decoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._config import ClientConfig
|
|
14
|
+
from ._retry import RetryPolicy, parse_retry_after
|
|
15
|
+
from ._version import USER_AGENT
|
|
16
|
+
from .errors import (
|
|
17
|
+
CosmonerConnectionError,
|
|
18
|
+
CosmonerError,
|
|
19
|
+
CosmonerTimeoutError,
|
|
20
|
+
error_from_response,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Methods that mutate state and therefore carry an idempotency key.
|
|
24
|
+
WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _BaseTransport:
|
|
28
|
+
"""Shared request construction and response decoding for both transports."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
31
|
+
self._config = config
|
|
32
|
+
self._policy = RetryPolicy(max_retries=config.max_retries)
|
|
33
|
+
|
|
34
|
+
def _url(self, path: str) -> str:
|
|
35
|
+
"""Joins a route path onto the configured base URL."""
|
|
36
|
+
return f"{self._config.base_url}{path}"
|
|
37
|
+
|
|
38
|
+
def _headers(
|
|
39
|
+
self,
|
|
40
|
+
method: str,
|
|
41
|
+
has_body: bool,
|
|
42
|
+
idempotency_key: str | None,
|
|
43
|
+
) -> dict[str, str]:
|
|
44
|
+
"""Builds the header set for one request.
|
|
45
|
+
|
|
46
|
+
The idempotency key is generated once per logical request and reused
|
|
47
|
+
across retries, so a replayed write can be collapsed server-side. The
|
|
48
|
+
API does not consume this header yet — it is sent now so that retries
|
|
49
|
+
become safe for writes as soon as it does.
|
|
50
|
+
"""
|
|
51
|
+
headers = {
|
|
52
|
+
"Authorization": f"Bearer {self._config.api_key}",
|
|
53
|
+
"Accept": "application/json",
|
|
54
|
+
"User-Agent": USER_AGENT,
|
|
55
|
+
}
|
|
56
|
+
if has_body:
|
|
57
|
+
headers["Content-Type"] = "application/json"
|
|
58
|
+
if method.upper() in WRITE_METHODS:
|
|
59
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
60
|
+
return headers
|
|
61
|
+
|
|
62
|
+
def _decode(self, response: httpx.Response) -> Any:
|
|
63
|
+
"""Parses a successful response body, or raises the mapped API error."""
|
|
64
|
+
try:
|
|
65
|
+
body: Any = response.json()
|
|
66
|
+
except ValueError:
|
|
67
|
+
body = None
|
|
68
|
+
|
|
69
|
+
if response.is_success:
|
|
70
|
+
if body is None:
|
|
71
|
+
raise CosmonerError(
|
|
72
|
+
status=response.status_code,
|
|
73
|
+
code="INVALID_RESPONSE",
|
|
74
|
+
message="API returned a non-JSON response",
|
|
75
|
+
)
|
|
76
|
+
return body
|
|
77
|
+
|
|
78
|
+
raise error_from_response(
|
|
79
|
+
status=response.status_code,
|
|
80
|
+
body=body,
|
|
81
|
+
headers=response.headers,
|
|
82
|
+
retry_after=parse_retry_after(response.headers),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Transport(_BaseTransport):
|
|
87
|
+
"""Synchronous HTTP transport backed by a pooled ``httpx.Client``."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
90
|
+
super().__init__(config)
|
|
91
|
+
self._client = httpx.Client(timeout=config.timeout)
|
|
92
|
+
|
|
93
|
+
def request(
|
|
94
|
+
self,
|
|
95
|
+
method: str,
|
|
96
|
+
path: str,
|
|
97
|
+
*,
|
|
98
|
+
json: Mapping[str, Any] | None = None,
|
|
99
|
+
params: Mapping[str, Any] | None = None,
|
|
100
|
+
idempotency_key: str | None = None,
|
|
101
|
+
retry_non_idempotent: bool = False,
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""Issues a request, retrying transient failures, and returns the parsed body."""
|
|
104
|
+
url = self._url(path)
|
|
105
|
+
headers = self._headers(method, json is not None, idempotency_key)
|
|
106
|
+
attempt = 0
|
|
107
|
+
|
|
108
|
+
while True:
|
|
109
|
+
status: int | None = None
|
|
110
|
+
retry_after: float | None = None
|
|
111
|
+
error: CosmonerConnectionError | None = None
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
response = self._client.request(
|
|
115
|
+
method, url, json=json, params=params, headers=headers
|
|
116
|
+
)
|
|
117
|
+
status = response.status_code
|
|
118
|
+
retry_after = parse_retry_after(response.headers)
|
|
119
|
+
|
|
120
|
+
if response.is_success:
|
|
121
|
+
return self._decode(response)
|
|
122
|
+
except httpx.TimeoutException as err:
|
|
123
|
+
response, error = None, CosmonerTimeoutError(f"Request timed out: {err}")
|
|
124
|
+
except httpx.HTTPError as err:
|
|
125
|
+
response, error = None, CosmonerConnectionError(f"Request failed: {err}")
|
|
126
|
+
else:
|
|
127
|
+
error = None
|
|
128
|
+
|
|
129
|
+
if not self._policy.should_retry(
|
|
130
|
+
attempt=attempt,
|
|
131
|
+
method=method,
|
|
132
|
+
status=status,
|
|
133
|
+
retry_non_idempotent=retry_non_idempotent,
|
|
134
|
+
):
|
|
135
|
+
if error is not None:
|
|
136
|
+
raise error
|
|
137
|
+
# ``response`` is always set when no transport error was captured.
|
|
138
|
+
assert response is not None
|
|
139
|
+
return self._decode(response) # raises the mapped API error
|
|
140
|
+
|
|
141
|
+
time.sleep(self._policy.backoff_seconds(attempt, retry_after))
|
|
142
|
+
attempt += 1
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
"""Closes the underlying connection pool."""
|
|
146
|
+
self._client.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class AsyncTransport(_BaseTransport):
|
|
150
|
+
"""Asynchronous HTTP transport backed by a pooled ``httpx.AsyncClient``."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, config: ClientConfig) -> None:
|
|
153
|
+
super().__init__(config)
|
|
154
|
+
self._client = httpx.AsyncClient(timeout=config.timeout)
|
|
155
|
+
|
|
156
|
+
async def request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
json: Mapping[str, Any] | None = None,
|
|
162
|
+
params: Mapping[str, Any] | None = None,
|
|
163
|
+
idempotency_key: str | None = None,
|
|
164
|
+
retry_non_idempotent: bool = False,
|
|
165
|
+
) -> Any:
|
|
166
|
+
"""Issues a request, retrying transient failures, and returns the parsed body."""
|
|
167
|
+
url = self._url(path)
|
|
168
|
+
headers = self._headers(method, json is not None, idempotency_key)
|
|
169
|
+
attempt = 0
|
|
170
|
+
|
|
171
|
+
while True:
|
|
172
|
+
status: int | None = None
|
|
173
|
+
retry_after: float | None = None
|
|
174
|
+
error: CosmonerConnectionError | None = None
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
response = await self._client.request(
|
|
178
|
+
method, url, json=json, params=params, headers=headers
|
|
179
|
+
)
|
|
180
|
+
status = response.status_code
|
|
181
|
+
retry_after = parse_retry_after(response.headers)
|
|
182
|
+
|
|
183
|
+
if response.is_success:
|
|
184
|
+
return self._decode(response)
|
|
185
|
+
except httpx.TimeoutException as err:
|
|
186
|
+
response, error = None, CosmonerTimeoutError(f"Request timed out: {err}")
|
|
187
|
+
except httpx.HTTPError as err:
|
|
188
|
+
response, error = None, CosmonerConnectionError(f"Request failed: {err}")
|
|
189
|
+
else:
|
|
190
|
+
error = None
|
|
191
|
+
|
|
192
|
+
if not self._policy.should_retry(
|
|
193
|
+
attempt=attempt,
|
|
194
|
+
method=method,
|
|
195
|
+
status=status,
|
|
196
|
+
retry_non_idempotent=retry_non_idempotent,
|
|
197
|
+
):
|
|
198
|
+
if error is not None:
|
|
199
|
+
raise error
|
|
200
|
+
# ``response`` is always set when no transport error was captured.
|
|
201
|
+
assert response is not None
|
|
202
|
+
return self._decode(response) # raises the mapped API error
|
|
203
|
+
|
|
204
|
+
await asyncio.sleep(self._policy.backoff_seconds(attempt, retry_after))
|
|
205
|
+
attempt += 1
|
|
206
|
+
|
|
207
|
+
async def aclose(self) -> None:
|
|
208
|
+
"""Closes the underlying connection pool."""
|
|
209
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Resolves the installed package version for the SDK's User-Agent header."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = metadata.version("cosmoner-sdk")
|
|
9
|
+
except metadata.PackageNotFoundError: # running from a source checkout
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
USER_AGENT = f"cosmoner-python/{__version__}"
|