astroway 0.1.0a1__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.
- astroway-0.1.0a1/.gitignore +19 -0
- astroway-0.1.0a1/CHANGELOG.md +31 -0
- astroway-0.1.0a1/LICENSE +21 -0
- astroway-0.1.0a1/PKG-INFO +264 -0
- astroway-0.1.0a1/README.md +230 -0
- astroway-0.1.0a1/pyproject.toml +77 -0
- astroway-0.1.0a1/src/astroway/__init__.py +56 -0
- astroway-0.1.0a1/src/astroway/_client.py +327 -0
- astroway-0.1.0a1/src/astroway/_retry.py +157 -0
- astroway-0.1.0a1/src/astroway/_version.py +3 -0
- astroway-0.1.0a1/src/astroway/errors.py +137 -0
- astroway-0.1.0a1/src/astroway/py.typed +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0a1 — 2026-05-09
|
|
4
|
+
|
|
5
|
+
Initial alpha release. Public API may shift before `0.1.0` proper based on integrator feedback.
|
|
6
|
+
|
|
7
|
+
### What's in the box
|
|
8
|
+
|
|
9
|
+
- **Synchronous (`Astroway`) and asynchronous (`AsyncAstroway`) clients** — identical surface, share constructor, methods, error types.
|
|
10
|
+
- **Built on `httpx`** — modern Python HTTP, supports HTTP/1.1 + HTTP/2, sync + async natively.
|
|
11
|
+
- **Two auth schemes:** `X-Api-Key` (default, matches curl/Postman) or `Authorization: Bearer` (matches Stripe/OpenAI/Anthropic convention) via `auth_scheme="bearer"`.
|
|
12
|
+
- **Stainless-template error hierarchy:** `ApiError` → `BadRequestError` / `AuthenticationError` / `PermissionDeniedError` / `NotFoundError` / `UnprocessableEntityError` / `RateLimitError` / `InternalServerError` / `APIConnectionError` (→ `APITimeoutError`).
|
|
13
|
+
- **Built-in retry** with exponential backoff + full jitter on 408 / 409 / 429 / 5xx and connection errors. Default 2 retries; configurable via `retry={"max_retries": 0}` to disable. Honors `Retry-After` (seconds or HTTP-date) on 429.
|
|
14
|
+
- **Per-request timeout** via `httpx.Timeout`, default 30s.
|
|
15
|
+
- **Identification headers** — `User-Agent: astroway-sdk-python/<version> (Python/<py-version>; <platform>)` and `X-Astroway-Channel: sdk-py`. **No telemetry, no phone-home.**
|
|
16
|
+
- **Auto-unwrap of `{ ok, data, error }` envelope** — methods return the `data` payload directly so user code reads naturally.
|
|
17
|
+
- **Context manager support** — both `with Astroway(...) as aw:` (sync) and `async with AsyncAstroway(...) as aw:` (async) close the underlying httpx client cleanly.
|
|
18
|
+
- **PEP 561 typed package** — `py.typed` marker shipped, full type hints throughout.
|
|
19
|
+
|
|
20
|
+
### Stack
|
|
21
|
+
|
|
22
|
+
- Python 3.9+ (CPython tested on 3.9 / 3.10 / 3.11 / 3.12 / 3.13).
|
|
23
|
+
- `httpx >= 0.27` — sync + async HTTP.
|
|
24
|
+
- `pydantic >= 2.0` — model validation surface (used by integrators for typed bodies).
|
|
25
|
+
- 40 unit tests — error classification, retry semantics, header propagation, auth scheme switching, async client parity.
|
|
26
|
+
|
|
27
|
+
### Internal
|
|
28
|
+
|
|
29
|
+
- Build with `hatchling`. Tests with `pytest` + `pytest-asyncio` + `respx`.
|
|
30
|
+
- Wheel + sdist published via PyPI Trusted Publishers (OIDC, no long-lived tokens).
|
|
31
|
+
- Provenance attestation on every release (Sigstore via GitHub Actions).
|
astroway-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AstroWay
|
|
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.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: astroway
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Official Python SDK for the AstroWay API — natal, synastry, transits, Vedic, Human Design, Tarot, Numerology, AI horoscopes. Type-safe, retry-aware, OpenAPI 3.1 generated.
|
|
5
|
+
Project-URL: Homepage, https://api.astroway.info/docs/
|
|
6
|
+
Project-URL: Repository, https://github.com/astroway/astroway-python
|
|
7
|
+
Project-URL: Issues, https://github.com/astroway/astroway-python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/astroway/astroway-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: AstroWay <astroway.info@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,astrology,human-design,natal-chart,numerology,openapi,sdk,swiss-ephemeris,synastry,tarot,transits,vedic
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
26
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: datamodel-code-generator[http]>=0.26; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# astroway
|
|
36
|
+
|
|
37
|
+
> Official Python SDK for the [AstroWay API](https://api.astroway.info) — natal charts, synastry, transits, Vedic dashas, Tarot, Numerology, Human Design, AI horoscopes. Sync + async, type-hinted, retry-aware.
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/astroway/)
|
|
40
|
+
[](https://pypi.org/project/astroway/)
|
|
41
|
+
[](LICENSE)
|
|
42
|
+
|
|
43
|
+
700+ endpoints. Synchronous and asynchronous clients with the same surface. Built-in retry on 408/409/429/5xx with exponential backoff. Stainless-style error hierarchy (`AuthenticationError` / `RateLimitError` / `BadRequestError` / …). Just `httpx` + `pydantic` under the hood.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install astroway
|
|
51
|
+
# or with uv
|
|
52
|
+
uv add astroway
|
|
53
|
+
# or with poetry
|
|
54
|
+
poetry add astroway
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Get an API key at <https://api.astroway.info/dashboard/sign-up> — **10 000 credits/month free**, no card required. Each endpoint costs 5–500 credits depending on what it computes ([pricing](https://api.astroway.info/pricing/)).
|
|
58
|
+
|
|
59
|
+
Requires Python 3.9+.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
### Synchronous
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from astroway import Astroway
|
|
69
|
+
|
|
70
|
+
aw = Astroway(api_key="aw_live_...")
|
|
71
|
+
|
|
72
|
+
chart = aw.post("/chart", body={
|
|
73
|
+
"date": "1990-07-14",
|
|
74
|
+
"time": "14:30:00",
|
|
75
|
+
"timezoneOffset": 3,
|
|
76
|
+
"latitude": 50.45,
|
|
77
|
+
"longitude": 30.52,
|
|
78
|
+
"houseSystem": "P",
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
asc = chart["angles"]["asc"]
|
|
82
|
+
print(f"ASC: {asc['sign']} {asc['degree']:.2f}°")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Asynchronous
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import asyncio
|
|
89
|
+
from astroway import AsyncAstroway
|
|
90
|
+
|
|
91
|
+
async def main() -> None:
|
|
92
|
+
async with AsyncAstroway(api_key="aw_live_...") as aw:
|
|
93
|
+
chart = await aw.post("/chart", body={
|
|
94
|
+
"date": "1990-07-14",
|
|
95
|
+
"time": "14:30:00",
|
|
96
|
+
"timezoneOffset": 3,
|
|
97
|
+
"latitude": 50.45,
|
|
98
|
+
"longitude": 30.52,
|
|
99
|
+
})
|
|
100
|
+
print(chart["angles"]["asc"])
|
|
101
|
+
|
|
102
|
+
asyncio.run(main())
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The two clients share an identical surface — same constructor params, same methods (`get`, `post`, `put`, `delete`, low-level `request`), same error types.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Common workflows
|
|
110
|
+
|
|
111
|
+
### Synastry
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
result = aw.post("/synastry", body={
|
|
115
|
+
"chart1": {"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3, "latitude": 50.45, "longitude": 30.52},
|
|
116
|
+
"chart2": {"date": "1992-03-22", "time": "09:15:00", "timezoneOffset": 2, "latitude": 48.85, "longitude": 2.35},
|
|
117
|
+
})
|
|
118
|
+
print(f"Score: {result['compatibility']['score']}/100 ({result['compatibility']['label']})")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Transits to natal
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
transits = aw.post("/transits", body={
|
|
125
|
+
"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3, "latitude": 50.45, "longitude": 30.52,
|
|
126
|
+
"targetDate": "2027-01-01",
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Vedic Vimshottari Mahadasha
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
dasha = aw.post("/vedic/dashas/vimshottari/maha", body={
|
|
134
|
+
"date": "1985-07-22", "time": "06:45:00", "timezoneOffset": 5.5,
|
|
135
|
+
"latitude": 19.07, "longitude": 72.87,
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Tarot reading
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
spread = aw.post("/tarot/rider-waite/spread", body={"spreadType": "three-card", "seed": 42})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Human Design
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
hd = aw.post("/human-design", body={
|
|
149
|
+
"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3, "latitude": 50.45, "longitude": 30.52,
|
|
150
|
+
})
|
|
151
|
+
print(f"{hd['type']} — {hd['strategy']} — {hd['authority']}")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Error handling
|
|
157
|
+
|
|
158
|
+
The SDK raises typed subclasses of `ApiError`. Catch order matters — most specific first:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from astroway import (
|
|
162
|
+
Astroway, ApiError,
|
|
163
|
+
AuthenticationError, RateLimitError, BadRequestError,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
aw.post("/chart", body=body)
|
|
168
|
+
except RateLimitError as e:
|
|
169
|
+
time.sleep(e.retry_after_seconds or 60)
|
|
170
|
+
# retry once...
|
|
171
|
+
except AuthenticationError:
|
|
172
|
+
raise RuntimeError("Rotate your AstroWay API key")
|
|
173
|
+
except BadRequestError as e:
|
|
174
|
+
print("Validation failed:", e.body)
|
|
175
|
+
except ApiError as e:
|
|
176
|
+
print(f"API error {e.status} ({e.code}): {e!s} [request_id={e.request_id}]")
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Full hierarchy:
|
|
180
|
+
|
|
181
|
+
- `ApiError` (base)
|
|
182
|
+
- `APIConnectionError`
|
|
183
|
+
- `APITimeoutError`
|
|
184
|
+
- `BadRequestError` (400)
|
|
185
|
+
- `AuthenticationError` (401)
|
|
186
|
+
- `PermissionDeniedError` (403)
|
|
187
|
+
- `NotFoundError` (404)
|
|
188
|
+
- `UnprocessableEntityError` (422)
|
|
189
|
+
- `RateLimitError` (429) — carries `retry_after_seconds`
|
|
190
|
+
- `InternalServerError` (5xx)
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Configuration
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
aw = Astroway(
|
|
198
|
+
api_key="aw_live_...", # required
|
|
199
|
+
base_url="https://api.astroway.info/v1", # override for staging / self-hosted
|
|
200
|
+
auth_scheme="header", # "header" (X-Api-Key, default) or "bearer" (Authorization: Bearer)
|
|
201
|
+
timeout=30.0, # per-request timeout in seconds
|
|
202
|
+
retry={
|
|
203
|
+
"max_retries": 2, # total attempts = 1 + max_retries
|
|
204
|
+
"base_delay_ms": 250,
|
|
205
|
+
"max_delay_ms": 30_000,
|
|
206
|
+
"retryable_statuses": frozenset({408, 409, 429, 500, 502, 503, 504}),
|
|
207
|
+
},
|
|
208
|
+
default_headers={"X-Trace-Id": "..."},
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The default retry honors `Retry-After` (seconds or HTTP-date) on 429 responses.
|
|
213
|
+
|
|
214
|
+
Set `retry={"max_retries": 0}` to disable retries entirely.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Authentication
|
|
219
|
+
|
|
220
|
+
Two equivalent auth schemes — pick whichever your stack prefers:
|
|
221
|
+
|
|
222
|
+
- **Header (default):** `X-Api-Key: aw_live_...` — same convention as `curl`/Postman examples.
|
|
223
|
+
- **Bearer:** `Authorization: Bearer aw_live_...` — same convention as Stripe/OpenAI/Anthropic SDKs.
|
|
224
|
+
|
|
225
|
+
Set via `auth_scheme="bearer"` in the constructor.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Privacy
|
|
230
|
+
|
|
231
|
+
The SDK does **not** phone home. There is no telemetry, no analytics, no usage reporting. The only network traffic the SDK originates is the AstroWay API calls you ask it to make.
|
|
232
|
+
|
|
233
|
+
Outgoing requests carry two identifying headers so the AstroWay backend can distinguish SDK traffic from raw HTTP traffic in its own logs:
|
|
234
|
+
|
|
235
|
+
- `User-Agent: astroway-sdk-python/<version> (Python/<py-version>; <platform>)`
|
|
236
|
+
- `X-Astroway-Channel: sdk-py`
|
|
237
|
+
|
|
238
|
+
Neither carries a session ID, machine fingerprint, or anything personal.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Stability
|
|
243
|
+
|
|
244
|
+
- **Public API stable inside a major version.** Methods/classes shipped under `1.x` won't be renamed or removed without a deprecation note in `CHANGELOG.md` and a one-minor parallel-availability window.
|
|
245
|
+
- **Body shape stable inside a minor version.** Tightening (constraints, enum) ships in patches; new required keys require a minor bump.
|
|
246
|
+
- **API version vs SDK version are independent.** SDK `0.x` follows its own semver; the API itself sits at `/v1/`.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Links
|
|
251
|
+
|
|
252
|
+
- 📦 PyPI: <https://pypi.org/project/astroway/>
|
|
253
|
+
- 📘 API docs: <https://api.astroway.info/docs/api/>
|
|
254
|
+
- 🔑 Sign up & dashboard: <https://api.astroway.info/dashboard/>
|
|
255
|
+
- 💰 Pricing: <https://api.astroway.info/pricing/>
|
|
256
|
+
- 🟦 TypeScript SDK: [`@astroway/sdk`](https://www.npmjs.com/package/@astroway/sdk)
|
|
257
|
+
- 🤖 MCP server: [`@astroway/mcp`](https://www.npmjs.com/package/@astroway/mcp)
|
|
258
|
+
- 🌐 Website: <https://astroway.info>
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## License
|
|
263
|
+
|
|
264
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# astroway
|
|
2
|
+
|
|
3
|
+
> Official Python SDK for the [AstroWay API](https://api.astroway.info) — natal charts, synastry, transits, Vedic dashas, Tarot, Numerology, Human Design, AI horoscopes. Sync + async, type-hinted, retry-aware.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/astroway/)
|
|
6
|
+
[](https://pypi.org/project/astroway/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
700+ endpoints. Synchronous and asynchronous clients with the same surface. Built-in retry on 408/409/429/5xx with exponential backoff. Stainless-style error hierarchy (`AuthenticationError` / `RateLimitError` / `BadRequestError` / …). Just `httpx` + `pydantic` under the hood.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install astroway
|
|
17
|
+
# or with uv
|
|
18
|
+
uv add astroway
|
|
19
|
+
# or with poetry
|
|
20
|
+
poetry add astroway
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Get an API key at <https://api.astroway.info/dashboard/sign-up> — **10 000 credits/month free**, no card required. Each endpoint costs 5–500 credits depending on what it computes ([pricing](https://api.astroway.info/pricing/)).
|
|
24
|
+
|
|
25
|
+
Requires Python 3.9+.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
### Synchronous
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from astroway import Astroway
|
|
35
|
+
|
|
36
|
+
aw = Astroway(api_key="aw_live_...")
|
|
37
|
+
|
|
38
|
+
chart = aw.post("/chart", body={
|
|
39
|
+
"date": "1990-07-14",
|
|
40
|
+
"time": "14:30:00",
|
|
41
|
+
"timezoneOffset": 3,
|
|
42
|
+
"latitude": 50.45,
|
|
43
|
+
"longitude": 30.52,
|
|
44
|
+
"houseSystem": "P",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
asc = chart["angles"]["asc"]
|
|
48
|
+
print(f"ASC: {asc['sign']} {asc['degree']:.2f}°")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Asynchronous
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from astroway import AsyncAstroway
|
|
56
|
+
|
|
57
|
+
async def main() -> None:
|
|
58
|
+
async with AsyncAstroway(api_key="aw_live_...") as aw:
|
|
59
|
+
chart = await aw.post("/chart", body={
|
|
60
|
+
"date": "1990-07-14",
|
|
61
|
+
"time": "14:30:00",
|
|
62
|
+
"timezoneOffset": 3,
|
|
63
|
+
"latitude": 50.45,
|
|
64
|
+
"longitude": 30.52,
|
|
65
|
+
})
|
|
66
|
+
print(chart["angles"]["asc"])
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The two clients share an identical surface — same constructor params, same methods (`get`, `post`, `put`, `delete`, low-level `request`), same error types.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Common workflows
|
|
76
|
+
|
|
77
|
+
### Synastry
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
result = aw.post("/synastry", body={
|
|
81
|
+
"chart1": {"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3, "latitude": 50.45, "longitude": 30.52},
|
|
82
|
+
"chart2": {"date": "1992-03-22", "time": "09:15:00", "timezoneOffset": 2, "latitude": 48.85, "longitude": 2.35},
|
|
83
|
+
})
|
|
84
|
+
print(f"Score: {result['compatibility']['score']}/100 ({result['compatibility']['label']})")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Transits to natal
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
transits = aw.post("/transits", body={
|
|
91
|
+
"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3, "latitude": 50.45, "longitude": 30.52,
|
|
92
|
+
"targetDate": "2027-01-01",
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Vedic Vimshottari Mahadasha
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
dasha = aw.post("/vedic/dashas/vimshottari/maha", body={
|
|
100
|
+
"date": "1985-07-22", "time": "06:45:00", "timezoneOffset": 5.5,
|
|
101
|
+
"latitude": 19.07, "longitude": 72.87,
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Tarot reading
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
spread = aw.post("/tarot/rider-waite/spread", body={"spreadType": "three-card", "seed": 42})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Human Design
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
hd = aw.post("/human-design", body={
|
|
115
|
+
"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3, "latitude": 50.45, "longitude": 30.52,
|
|
116
|
+
})
|
|
117
|
+
print(f"{hd['type']} — {hd['strategy']} — {hd['authority']}")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Error handling
|
|
123
|
+
|
|
124
|
+
The SDK raises typed subclasses of `ApiError`. Catch order matters — most specific first:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from astroway import (
|
|
128
|
+
Astroway, ApiError,
|
|
129
|
+
AuthenticationError, RateLimitError, BadRequestError,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
aw.post("/chart", body=body)
|
|
134
|
+
except RateLimitError as e:
|
|
135
|
+
time.sleep(e.retry_after_seconds or 60)
|
|
136
|
+
# retry once...
|
|
137
|
+
except AuthenticationError:
|
|
138
|
+
raise RuntimeError("Rotate your AstroWay API key")
|
|
139
|
+
except BadRequestError as e:
|
|
140
|
+
print("Validation failed:", e.body)
|
|
141
|
+
except ApiError as e:
|
|
142
|
+
print(f"API error {e.status} ({e.code}): {e!s} [request_id={e.request_id}]")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Full hierarchy:
|
|
146
|
+
|
|
147
|
+
- `ApiError` (base)
|
|
148
|
+
- `APIConnectionError`
|
|
149
|
+
- `APITimeoutError`
|
|
150
|
+
- `BadRequestError` (400)
|
|
151
|
+
- `AuthenticationError` (401)
|
|
152
|
+
- `PermissionDeniedError` (403)
|
|
153
|
+
- `NotFoundError` (404)
|
|
154
|
+
- `UnprocessableEntityError` (422)
|
|
155
|
+
- `RateLimitError` (429) — carries `retry_after_seconds`
|
|
156
|
+
- `InternalServerError` (5xx)
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Configuration
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
aw = Astroway(
|
|
164
|
+
api_key="aw_live_...", # required
|
|
165
|
+
base_url="https://api.astroway.info/v1", # override for staging / self-hosted
|
|
166
|
+
auth_scheme="header", # "header" (X-Api-Key, default) or "bearer" (Authorization: Bearer)
|
|
167
|
+
timeout=30.0, # per-request timeout in seconds
|
|
168
|
+
retry={
|
|
169
|
+
"max_retries": 2, # total attempts = 1 + max_retries
|
|
170
|
+
"base_delay_ms": 250,
|
|
171
|
+
"max_delay_ms": 30_000,
|
|
172
|
+
"retryable_statuses": frozenset({408, 409, 429, 500, 502, 503, 504}),
|
|
173
|
+
},
|
|
174
|
+
default_headers={"X-Trace-Id": "..."},
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The default retry honors `Retry-After` (seconds or HTTP-date) on 429 responses.
|
|
179
|
+
|
|
180
|
+
Set `retry={"max_retries": 0}` to disable retries entirely.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Authentication
|
|
185
|
+
|
|
186
|
+
Two equivalent auth schemes — pick whichever your stack prefers:
|
|
187
|
+
|
|
188
|
+
- **Header (default):** `X-Api-Key: aw_live_...` — same convention as `curl`/Postman examples.
|
|
189
|
+
- **Bearer:** `Authorization: Bearer aw_live_...` — same convention as Stripe/OpenAI/Anthropic SDKs.
|
|
190
|
+
|
|
191
|
+
Set via `auth_scheme="bearer"` in the constructor.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Privacy
|
|
196
|
+
|
|
197
|
+
The SDK does **not** phone home. There is no telemetry, no analytics, no usage reporting. The only network traffic the SDK originates is the AstroWay API calls you ask it to make.
|
|
198
|
+
|
|
199
|
+
Outgoing requests carry two identifying headers so the AstroWay backend can distinguish SDK traffic from raw HTTP traffic in its own logs:
|
|
200
|
+
|
|
201
|
+
- `User-Agent: astroway-sdk-python/<version> (Python/<py-version>; <platform>)`
|
|
202
|
+
- `X-Astroway-Channel: sdk-py`
|
|
203
|
+
|
|
204
|
+
Neither carries a session ID, machine fingerprint, or anything personal.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Stability
|
|
209
|
+
|
|
210
|
+
- **Public API stable inside a major version.** Methods/classes shipped under `1.x` won't be renamed or removed without a deprecation note in `CHANGELOG.md` and a one-minor parallel-availability window.
|
|
211
|
+
- **Body shape stable inside a minor version.** Tightening (constraints, enum) ships in patches; new required keys require a minor bump.
|
|
212
|
+
- **API version vs SDK version are independent.** SDK `0.x` follows its own semver; the API itself sits at `/v1/`.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Links
|
|
217
|
+
|
|
218
|
+
- 📦 PyPI: <https://pypi.org/project/astroway/>
|
|
219
|
+
- 📘 API docs: <https://api.astroway.info/docs/api/>
|
|
220
|
+
- 🔑 Sign up & dashboard: <https://api.astroway.info/dashboard/>
|
|
221
|
+
- 💰 Pricing: <https://api.astroway.info/pricing/>
|
|
222
|
+
- 🟦 TypeScript SDK: [`@astroway/sdk`](https://www.npmjs.com/package/@astroway/sdk)
|
|
223
|
+
- 🤖 MCP server: [`@astroway/mcp`](https://www.npmjs.com/package/@astroway/mcp)
|
|
224
|
+
- 🌐 Website: <https://astroway.info>
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "astroway"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "Official Python SDK for the AstroWay API — natal, synastry, transits, Vedic, Human Design, Tarot, Numerology, AI horoscopes. Type-safe, retry-aware, OpenAPI 3.1 generated."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "AstroWay", email = "astroway.info@gmail.com" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"astrology", "api", "sdk",
|
|
16
|
+
"natal-chart", "synastry", "transits",
|
|
17
|
+
"vedic", "human-design", "tarot", "numerology",
|
|
18
|
+
"swiss-ephemeris", "openapi",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"httpx>=0.27,<1.0",
|
|
35
|
+
"pydantic>=2.0,<3.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://api.astroway.info/docs/"
|
|
40
|
+
Repository = "https://github.com/astroway/astroway-python"
|
|
41
|
+
Issues = "https://github.com/astroway/astroway-python/issues"
|
|
42
|
+
Changelog = "https://github.com/astroway/astroway-python/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=8.0",
|
|
47
|
+
"pytest-asyncio>=0.23",
|
|
48
|
+
"respx>=0.21",
|
|
49
|
+
"ruff>=0.6",
|
|
50
|
+
"datamodel-code-generator[http]>=0.26",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["src/astroway"]
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.sdist]
|
|
57
|
+
include = [
|
|
58
|
+
"src/astroway",
|
|
59
|
+
"openapi.json",
|
|
60
|
+
"README.md",
|
|
61
|
+
"CHANGELOG.md",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
"pyproject.toml",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
asyncio_mode = "auto"
|
|
68
|
+
testpaths = ["tests"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 100
|
|
72
|
+
target-version = "py39"
|
|
73
|
+
src = ["src", "tests"]
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint]
|
|
76
|
+
select = ["E", "F", "I", "B", "UP", "RUF"]
|
|
77
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""astroway — Official Python SDK for the AstroWay API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from astroway import Astroway
|
|
6
|
+
|
|
7
|
+
aw = Astroway(api_key="aw_live_...")
|
|
8
|
+
chart = aw.post("/chart", body={
|
|
9
|
+
"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3,
|
|
10
|
+
"latitude": 50.45, "longitude": 30.52,
|
|
11
|
+
})
|
|
12
|
+
print(chart["angles"]["asc"])
|
|
13
|
+
|
|
14
|
+
For async workloads use :class:`AsyncAstroway` with the same surface.
|
|
15
|
+
|
|
16
|
+
Errors thrown by the SDK live in :mod:`astroway.errors` — catch
|
|
17
|
+
``RateLimitError`` / ``AuthenticationError`` / ``BadRequestError`` /
|
|
18
|
+
``ApiError`` for the common cases.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ._client import AsyncAstroway, Astroway
|
|
24
|
+
from ._retry import RetryConfig
|
|
25
|
+
from ._version import SDK_VERSION
|
|
26
|
+
from .errors import (
|
|
27
|
+
APIConnectionError,
|
|
28
|
+
APITimeoutError,
|
|
29
|
+
ApiError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
BadRequestError,
|
|
32
|
+
InternalServerError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
PermissionDeniedError,
|
|
35
|
+
RateLimitError,
|
|
36
|
+
UnprocessableEntityError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = SDK_VERSION
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"APIConnectionError",
|
|
43
|
+
"APITimeoutError",
|
|
44
|
+
"ApiError",
|
|
45
|
+
"AsyncAstroway",
|
|
46
|
+
"Astroway",
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"BadRequestError",
|
|
49
|
+
"InternalServerError",
|
|
50
|
+
"NotFoundError",
|
|
51
|
+
"PermissionDeniedError",
|
|
52
|
+
"RateLimitError",
|
|
53
|
+
"RetryConfig",
|
|
54
|
+
"SDK_VERSION",
|
|
55
|
+
"UnprocessableEntityError",
|
|
56
|
+
]
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Astroway / AsyncAstroway — sync + async clients wrapping httpx with auth,
|
|
2
|
+
retry, error mapping, and identification headers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._retry import AsyncRetryTransport, RetryConfig, SyncRetryTransport
|
|
15
|
+
from ._version import SDK_VERSION
|
|
16
|
+
from .errors import (
|
|
17
|
+
APIConnectionError,
|
|
18
|
+
APITimeoutError,
|
|
19
|
+
ApiError,
|
|
20
|
+
classify_http_error,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.astroway.info/v1"
|
|
24
|
+
AuthScheme = Literal["header", "bearer"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _user_agent() -> str:
|
|
28
|
+
py = f"Python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
29
|
+
plat = f"{platform.system().lower()}-{platform.machine().lower()}"
|
|
30
|
+
return f"astroway-sdk-python/{SDK_VERSION} ({py}; {plat})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_headers(api_key: str, auth_scheme: AuthScheme) -> dict[str, str]:
|
|
34
|
+
headers: dict[str, str] = {
|
|
35
|
+
"User-Agent": _user_agent(),
|
|
36
|
+
"X-Astroway-Channel": "sdk-py",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
}
|
|
39
|
+
if auth_scheme == "bearer":
|
|
40
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
41
|
+
else:
|
|
42
|
+
headers["X-Api-Key"] = api_key
|
|
43
|
+
return headers
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _raise_on_error(response: httpx.Response) -> None:
|
|
47
|
+
if response.is_success:
|
|
48
|
+
return
|
|
49
|
+
request_id = response.headers.get("x-request-id")
|
|
50
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
51
|
+
retry_after_seconds: int | None = None
|
|
52
|
+
if retry_after_raw is not None:
|
|
53
|
+
try:
|
|
54
|
+
retry_after_seconds = int(float(retry_after_raw))
|
|
55
|
+
except ValueError:
|
|
56
|
+
retry_after_seconds = None
|
|
57
|
+
|
|
58
|
+
body: Any = None
|
|
59
|
+
code: str | None = None
|
|
60
|
+
message = f"{response.status_code} {response.reason_phrase}"
|
|
61
|
+
try:
|
|
62
|
+
body = response.json()
|
|
63
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
64
|
+
if isinstance(err, dict):
|
|
65
|
+
if err.get("code"):
|
|
66
|
+
code = str(err["code"])
|
|
67
|
+
if err.get("message"):
|
|
68
|
+
message = str(err["message"])
|
|
69
|
+
except Exception: # noqa: BLE001 — body wasn't JSON, fine
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
raise classify_http_error(
|
|
73
|
+
status=response.status_code,
|
|
74
|
+
message=message,
|
|
75
|
+
code=code,
|
|
76
|
+
body=body,
|
|
77
|
+
request_id=request_id,
|
|
78
|
+
retry_after_seconds=retry_after_seconds,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _BaseAstroway:
|
|
83
|
+
"""Shared constructor logic + property accessors for sync and async clients."""
|
|
84
|
+
|
|
85
|
+
base_url: str
|
|
86
|
+
api_key: str
|
|
87
|
+
auth_scheme: AuthScheme
|
|
88
|
+
timeout: float
|
|
89
|
+
retry: RetryConfig
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
api_key: str,
|
|
95
|
+
base_url: str | None = None,
|
|
96
|
+
auth_scheme: AuthScheme = "header",
|
|
97
|
+
timeout: float = 30.0,
|
|
98
|
+
retry: dict | RetryConfig | None = None,
|
|
99
|
+
default_headers: Mapping[str, str] | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
if not api_key:
|
|
102
|
+
raise ApiError(
|
|
103
|
+
"Astroway: api_key is required. Get one at "
|
|
104
|
+
"https://api.astroway.info/dashboard/sign-up — 10,000 credits/month free."
|
|
105
|
+
)
|
|
106
|
+
self.api_key = api_key
|
|
107
|
+
self.base_url = base_url or DEFAULT_BASE_URL
|
|
108
|
+
self.auth_scheme = auth_scheme
|
|
109
|
+
self.timeout = timeout
|
|
110
|
+
self.retry = (
|
|
111
|
+
retry if isinstance(retry, RetryConfig) else RetryConfig.from_dict(retry)
|
|
112
|
+
)
|
|
113
|
+
self._headers: dict[str, str] = {
|
|
114
|
+
**_default_headers(api_key, auth_scheme),
|
|
115
|
+
**(dict(default_headers) if default_headers else {}),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Astroway(_BaseAstroway):
|
|
120
|
+
"""Synchronous Astroway client.
|
|
121
|
+
|
|
122
|
+
Example::
|
|
123
|
+
|
|
124
|
+
from astroway import Astroway
|
|
125
|
+
aw = Astroway(api_key="aw_live_...")
|
|
126
|
+
chart = aw.post("/chart", body={
|
|
127
|
+
"date": "1990-07-14", "time": "14:30:00", "timezoneOffset": 3,
|
|
128
|
+
"latitude": 50.45, "longitude": 30.52,
|
|
129
|
+
})
|
|
130
|
+
print(chart["angles"]["asc"])
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_http: httpx.Client
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
api_key: str,
|
|
139
|
+
base_url: str | None = None,
|
|
140
|
+
auth_scheme: AuthScheme = "header",
|
|
141
|
+
timeout: float = 30.0,
|
|
142
|
+
retry: dict | RetryConfig | None = None,
|
|
143
|
+
default_headers: Mapping[str, str] | None = None,
|
|
144
|
+
transport: httpx.BaseTransport | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
super().__init__(
|
|
147
|
+
api_key=api_key,
|
|
148
|
+
base_url=base_url,
|
|
149
|
+
auth_scheme=auth_scheme,
|
|
150
|
+
timeout=timeout,
|
|
151
|
+
retry=retry,
|
|
152
|
+
default_headers=default_headers,
|
|
153
|
+
)
|
|
154
|
+
inner = transport if transport is not None else httpx.HTTPTransport()
|
|
155
|
+
wrapped = SyncRetryTransport(inner, self.retry)
|
|
156
|
+
self._http = httpx.Client(
|
|
157
|
+
base_url=self.base_url,
|
|
158
|
+
headers=self._headers,
|
|
159
|
+
timeout=self.timeout,
|
|
160
|
+
transport=wrapped,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def request(
|
|
164
|
+
self,
|
|
165
|
+
method: str,
|
|
166
|
+
path: str,
|
|
167
|
+
*,
|
|
168
|
+
body: Any | None = None,
|
|
169
|
+
params: Mapping[str, Any] | None = None,
|
|
170
|
+
headers: Mapping[str, str] | None = None,
|
|
171
|
+
) -> Any:
|
|
172
|
+
try:
|
|
173
|
+
response = self._http.request(
|
|
174
|
+
method,
|
|
175
|
+
path,
|
|
176
|
+
json=body,
|
|
177
|
+
params=params,
|
|
178
|
+
headers=dict(headers) if headers else None,
|
|
179
|
+
)
|
|
180
|
+
except httpx.TimeoutException as exc:
|
|
181
|
+
raise APITimeoutError(
|
|
182
|
+
f"Request to {path} timed out after {self.timeout}s"
|
|
183
|
+
) from exc
|
|
184
|
+
except httpx.HTTPError as exc:
|
|
185
|
+
raise APIConnectionError(
|
|
186
|
+
f"Network error calling {path}: {exc!s}. Check connection or base_url."
|
|
187
|
+
) from exc
|
|
188
|
+
|
|
189
|
+
_raise_on_error(response)
|
|
190
|
+
try:
|
|
191
|
+
payload = response.json()
|
|
192
|
+
except Exception: # noqa: BLE001
|
|
193
|
+
return response.text
|
|
194
|
+
# Endpoints wrap responses as { ok, data, error } — unwrap data when present.
|
|
195
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
196
|
+
return payload["data"]
|
|
197
|
+
return payload
|
|
198
|
+
|
|
199
|
+
def get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
|
|
200
|
+
return self.request("GET", path, params=params)
|
|
201
|
+
|
|
202
|
+
def post(
|
|
203
|
+
self,
|
|
204
|
+
path: str,
|
|
205
|
+
*,
|
|
206
|
+
body: Any | None = None,
|
|
207
|
+
params: Mapping[str, Any] | None = None,
|
|
208
|
+
) -> Any:
|
|
209
|
+
return self.request("POST", path, body=body, params=params)
|
|
210
|
+
|
|
211
|
+
def put(self, path: str, *, body: Any | None = None) -> Any:
|
|
212
|
+
return self.request("PUT", path, body=body)
|
|
213
|
+
|
|
214
|
+
def delete(self, path: str) -> Any:
|
|
215
|
+
return self.request("DELETE", path)
|
|
216
|
+
|
|
217
|
+
def close(self) -> None:
|
|
218
|
+
self._http.close()
|
|
219
|
+
|
|
220
|
+
def __enter__(self) -> Astroway:
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def __exit__(self, *_: object) -> None:
|
|
224
|
+
self.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AsyncAstroway(_BaseAstroway):
|
|
228
|
+
"""Async counterpart of :class:`Astroway`. Same surface, awaitable methods.
|
|
229
|
+
|
|
230
|
+
Example::
|
|
231
|
+
|
|
232
|
+
from astroway import AsyncAstroway
|
|
233
|
+
async with AsyncAstroway(api_key="aw_live_...") as aw:
|
|
234
|
+
chart = await aw.post("/chart", body={...})
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
_http: httpx.AsyncClient
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
api_key: str,
|
|
243
|
+
base_url: str | None = None,
|
|
244
|
+
auth_scheme: AuthScheme = "header",
|
|
245
|
+
timeout: float = 30.0,
|
|
246
|
+
retry: dict | RetryConfig | None = None,
|
|
247
|
+
default_headers: Mapping[str, str] | None = None,
|
|
248
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
super().__init__(
|
|
251
|
+
api_key=api_key,
|
|
252
|
+
base_url=base_url,
|
|
253
|
+
auth_scheme=auth_scheme,
|
|
254
|
+
timeout=timeout,
|
|
255
|
+
retry=retry,
|
|
256
|
+
default_headers=default_headers,
|
|
257
|
+
)
|
|
258
|
+
inner = transport if transport is not None else httpx.AsyncHTTPTransport()
|
|
259
|
+
wrapped = AsyncRetryTransport(inner, self.retry)
|
|
260
|
+
self._http = httpx.AsyncClient(
|
|
261
|
+
base_url=self.base_url,
|
|
262
|
+
headers=self._headers,
|
|
263
|
+
timeout=self.timeout,
|
|
264
|
+
transport=wrapped,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def request(
|
|
268
|
+
self,
|
|
269
|
+
method: str,
|
|
270
|
+
path: str,
|
|
271
|
+
*,
|
|
272
|
+
body: Any | None = None,
|
|
273
|
+
params: Mapping[str, Any] | None = None,
|
|
274
|
+
headers: Mapping[str, str] | None = None,
|
|
275
|
+
) -> Any:
|
|
276
|
+
try:
|
|
277
|
+
response = await self._http.request(
|
|
278
|
+
method,
|
|
279
|
+
path,
|
|
280
|
+
json=body,
|
|
281
|
+
params=params,
|
|
282
|
+
headers=dict(headers) if headers else None,
|
|
283
|
+
)
|
|
284
|
+
except httpx.TimeoutException as exc:
|
|
285
|
+
raise APITimeoutError(
|
|
286
|
+
f"Request to {path} timed out after {self.timeout}s"
|
|
287
|
+
) from exc
|
|
288
|
+
except httpx.HTTPError as exc:
|
|
289
|
+
raise APIConnectionError(
|
|
290
|
+
f"Network error calling {path}: {exc!s}. Check connection or base_url."
|
|
291
|
+
) from exc
|
|
292
|
+
|
|
293
|
+
_raise_on_error(response)
|
|
294
|
+
try:
|
|
295
|
+
payload = response.json()
|
|
296
|
+
except Exception: # noqa: BLE001
|
|
297
|
+
return response.text
|
|
298
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
299
|
+
return payload["data"]
|
|
300
|
+
return payload
|
|
301
|
+
|
|
302
|
+
async def get(self, path: str, *, params: Mapping[str, Any] | None = None) -> Any:
|
|
303
|
+
return await self.request("GET", path, params=params)
|
|
304
|
+
|
|
305
|
+
async def post(
|
|
306
|
+
self,
|
|
307
|
+
path: str,
|
|
308
|
+
*,
|
|
309
|
+
body: Any | None = None,
|
|
310
|
+
params: Mapping[str, Any] | None = None,
|
|
311
|
+
) -> Any:
|
|
312
|
+
return await self.request("POST", path, body=body, params=params)
|
|
313
|
+
|
|
314
|
+
async def put(self, path: str, *, body: Any | None = None) -> Any:
|
|
315
|
+
return await self.request("PUT", path, body=body)
|
|
316
|
+
|
|
317
|
+
async def delete(self, path: str) -> Any:
|
|
318
|
+
return await self.request("DELETE", path)
|
|
319
|
+
|
|
320
|
+
async def aclose(self) -> None:
|
|
321
|
+
await self._http.aclose()
|
|
322
|
+
|
|
323
|
+
async def __aenter__(self) -> AsyncAstroway:
|
|
324
|
+
return self
|
|
325
|
+
|
|
326
|
+
async def __aexit__(self, *_: object) -> None:
|
|
327
|
+
await self.aclose()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""httpx retry transports — sync + async. Default: 2 retries, exponential
|
|
2
|
+
backoff with full jitter, on connection errors / 408 / 409 / 429 / 5xx.
|
|
3
|
+
Honors ``Retry-After`` (seconds or HTTP-date) when present on 429.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import email.utils
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
DEFAULT_RETRYABLE: frozenset[int] = frozenset({408, 409, 429, 500, 502, 503, 504})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RetryConfig:
|
|
22
|
+
"""Retry knobs for the SDK transport. ``max_retries=0`` disables retries entirely."""
|
|
23
|
+
|
|
24
|
+
max_retries: int = 2
|
|
25
|
+
base_delay_ms: int = 250
|
|
26
|
+
max_delay_ms: int = 30_000
|
|
27
|
+
retryable_statuses: frozenset[int] = DEFAULT_RETRYABLE
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, value: dict | None) -> RetryConfig:
|
|
31
|
+
if value is None:
|
|
32
|
+
return cls()
|
|
33
|
+
kwargs: dict = {}
|
|
34
|
+
if "max_retries" in value:
|
|
35
|
+
kwargs["max_retries"] = int(value["max_retries"])
|
|
36
|
+
if "base_delay_ms" in value:
|
|
37
|
+
kwargs["base_delay_ms"] = int(value["base_delay_ms"])
|
|
38
|
+
if "max_delay_ms" in value:
|
|
39
|
+
kwargs["max_delay_ms"] = int(value["max_delay_ms"])
|
|
40
|
+
if "retryable_statuses" in value:
|
|
41
|
+
kwargs["retryable_statuses"] = frozenset(value["retryable_statuses"])
|
|
42
|
+
return cls(**kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
46
|
+
"""Returns delay in milliseconds, or None when header absent / unparseable."""
|
|
47
|
+
if not value:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
seconds = float(value)
|
|
51
|
+
if seconds >= 0:
|
|
52
|
+
return seconds * 1000
|
|
53
|
+
except ValueError:
|
|
54
|
+
pass
|
|
55
|
+
try:
|
|
56
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
57
|
+
delta = (when.timestamp() - time.time()) * 1000
|
|
58
|
+
return max(0.0, delta)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _jitter_delay_ms(attempt: int, base: int, cap: int) -> float:
|
|
64
|
+
upper = min(cap, base * (2**attempt))
|
|
65
|
+
return random.random() * upper
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_RetryableExceptions: tuple[type[BaseException], ...] = (
|
|
69
|
+
httpx.ConnectError,
|
|
70
|
+
httpx.ConnectTimeout,
|
|
71
|
+
httpx.ReadError,
|
|
72
|
+
httpx.WriteError,
|
|
73
|
+
httpx.PoolTimeout,
|
|
74
|
+
httpx.ReadTimeout,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SyncRetryTransport(httpx.BaseTransport):
|
|
79
|
+
"""Wraps a sync transport with retry semantics."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, inner: httpx.BaseTransport, config: RetryConfig) -> None:
|
|
82
|
+
self._inner = inner
|
|
83
|
+
self._config = config
|
|
84
|
+
|
|
85
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
86
|
+
last_exc: BaseException | None = None
|
|
87
|
+
for attempt in range(self._config.max_retries + 1):
|
|
88
|
+
try:
|
|
89
|
+
response = self._inner.handle_request(request)
|
|
90
|
+
if (
|
|
91
|
+
response.status_code not in self._config.retryable_statuses
|
|
92
|
+
or attempt == self._config.max_retries
|
|
93
|
+
):
|
|
94
|
+
return response
|
|
95
|
+
# Drain response body before retry — httpx requires this so
|
|
96
|
+
# the connection can be returned to the pool cleanly.
|
|
97
|
+
response.read()
|
|
98
|
+
response.close()
|
|
99
|
+
retry_after_ms = _parse_retry_after(response.headers.get("retry-after"))
|
|
100
|
+
delay = retry_after_ms or _jitter_delay_ms(
|
|
101
|
+
attempt, self._config.base_delay_ms, self._config.max_delay_ms
|
|
102
|
+
)
|
|
103
|
+
time.sleep(delay / 1000)
|
|
104
|
+
except _RetryableExceptions as exc:
|
|
105
|
+
last_exc = exc
|
|
106
|
+
if attempt == self._config.max_retries:
|
|
107
|
+
raise
|
|
108
|
+
delay = _jitter_delay_ms(
|
|
109
|
+
attempt, self._config.base_delay_ms, self._config.max_delay_ms
|
|
110
|
+
)
|
|
111
|
+
time.sleep(delay / 1000)
|
|
112
|
+
if last_exc is not None:
|
|
113
|
+
raise last_exc
|
|
114
|
+
raise RuntimeError("retry loop exhausted without response or exception")
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._inner.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AsyncRetryTransport(httpx.AsyncBaseTransport):
|
|
121
|
+
"""Async counterpart of :class:`SyncRetryTransport`."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, inner: httpx.AsyncBaseTransport, config: RetryConfig) -> None:
|
|
124
|
+
self._inner = inner
|
|
125
|
+
self._config = config
|
|
126
|
+
|
|
127
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
128
|
+
last_exc: BaseException | None = None
|
|
129
|
+
for attempt in range(self._config.max_retries + 1):
|
|
130
|
+
try:
|
|
131
|
+
response = await self._inner.handle_async_request(request)
|
|
132
|
+
if (
|
|
133
|
+
response.status_code not in self._config.retryable_statuses
|
|
134
|
+
or attempt == self._config.max_retries
|
|
135
|
+
):
|
|
136
|
+
return response
|
|
137
|
+
await response.aread()
|
|
138
|
+
await response.aclose()
|
|
139
|
+
retry_after_ms = _parse_retry_after(response.headers.get("retry-after"))
|
|
140
|
+
delay = retry_after_ms or _jitter_delay_ms(
|
|
141
|
+
attempt, self._config.base_delay_ms, self._config.max_delay_ms
|
|
142
|
+
)
|
|
143
|
+
await asyncio.sleep(delay / 1000)
|
|
144
|
+
except _RetryableExceptions as exc:
|
|
145
|
+
last_exc = exc
|
|
146
|
+
if attempt == self._config.max_retries:
|
|
147
|
+
raise
|
|
148
|
+
delay = _jitter_delay_ms(
|
|
149
|
+
attempt, self._config.base_delay_ms, self._config.max_delay_ms
|
|
150
|
+
)
|
|
151
|
+
await asyncio.sleep(delay / 1000)
|
|
152
|
+
if last_exc is not None:
|
|
153
|
+
raise last_exc
|
|
154
|
+
raise RuntimeError("retry loop exhausted without response or exception")
|
|
155
|
+
|
|
156
|
+
async def aclose(self) -> None:
|
|
157
|
+
await self._inner.aclose()
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Error hierarchy mirroring the Stainless template (OpenAI / Anthropic / Cloudflare SDKs).
|
|
2
|
+
|
|
3
|
+
Catch order recommendation in user code::
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
...
|
|
7
|
+
except RateLimitError as e:
|
|
8
|
+
# respect e.retry_after_seconds
|
|
9
|
+
except AuthenticationError:
|
|
10
|
+
# rotate the API key
|
|
11
|
+
except ApiError as e:
|
|
12
|
+
# generic 4xx/5xx, inspect e.status / e.code / e.body / e.request_id
|
|
13
|
+
except Exception:
|
|
14
|
+
raise
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiError(Exception):
|
|
23
|
+
"""Base class for every error raised by the SDK on top of Python's built-ins."""
|
|
24
|
+
|
|
25
|
+
status: int | None
|
|
26
|
+
code: str | None
|
|
27
|
+
body: Any | None
|
|
28
|
+
request_id: str | None
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
status: int | None = None,
|
|
35
|
+
code: str | None = None,
|
|
36
|
+
body: Any | None = None,
|
|
37
|
+
request_id: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status = status
|
|
41
|
+
self.code = code
|
|
42
|
+
self.body = body
|
|
43
|
+
self.request_id = request_id
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class APIConnectionError(ApiError):
|
|
47
|
+
"""Network-level failure — DNS, connection refused, TLS, timeouts before bytes received."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APITimeoutError(APIConnectionError):
|
|
51
|
+
"""Request exceeded the configured timeout."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BadRequestError(ApiError):
|
|
55
|
+
"""HTTP 400 — request did not parse or violated a basic constraint."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AuthenticationError(ApiError):
|
|
59
|
+
"""HTTP 401 — API key missing, invalid, or revoked."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PermissionDeniedError(ApiError):
|
|
63
|
+
"""HTTP 403 — authenticated but not allowed to call this endpoint."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NotFoundError(ApiError):
|
|
67
|
+
"""HTTP 404 — resource (or endpoint) does not exist."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UnprocessableEntityError(ApiError):
|
|
71
|
+
"""HTTP 422 — payload parsed but failed schema validation."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RateLimitError(ApiError):
|
|
75
|
+
"""HTTP 429 — rate limit exceeded. ``retry_after_seconds`` from ``Retry-After`` if present."""
|
|
76
|
+
|
|
77
|
+
retry_after_seconds: int | None
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
message: str,
|
|
82
|
+
*,
|
|
83
|
+
status: int | None = None,
|
|
84
|
+
code: str | None = None,
|
|
85
|
+
body: Any | None = None,
|
|
86
|
+
request_id: str | None = None,
|
|
87
|
+
retry_after_seconds: int | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
super().__init__(message, status=status, code=code, body=body, request_id=request_id)
|
|
90
|
+
self.retry_after_seconds = retry_after_seconds
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InternalServerError(ApiError):
|
|
94
|
+
"""HTTP 5xx — server-side failure. Retried by default unless ``retry={"max_retries": 0}``."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def classify_http_error(
|
|
98
|
+
*,
|
|
99
|
+
status: int,
|
|
100
|
+
message: str,
|
|
101
|
+
code: str | None = None,
|
|
102
|
+
body: Any | None = None,
|
|
103
|
+
request_id: str | None = None,
|
|
104
|
+
retry_after_seconds: int | None = None,
|
|
105
|
+
) -> ApiError:
|
|
106
|
+
"""Maps an HTTP status to the most specific subclass."""
|
|
107
|
+
init: dict[str, Any] = {"status": status, "code": code, "body": body, "request_id": request_id}
|
|
108
|
+
if status == 400:
|
|
109
|
+
return BadRequestError(message, **init)
|
|
110
|
+
if status == 401:
|
|
111
|
+
return AuthenticationError(message, **init)
|
|
112
|
+
if status == 403:
|
|
113
|
+
return PermissionDeniedError(message, **init)
|
|
114
|
+
if status == 404:
|
|
115
|
+
return NotFoundError(message, **init)
|
|
116
|
+
if status == 422:
|
|
117
|
+
return UnprocessableEntityError(message, **init)
|
|
118
|
+
if status == 429:
|
|
119
|
+
return RateLimitError(message, **init, retry_after_seconds=retry_after_seconds)
|
|
120
|
+
if status >= 500:
|
|
121
|
+
return InternalServerError(message, **init)
|
|
122
|
+
return ApiError(message, **init)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
"APIConnectionError",
|
|
127
|
+
"APITimeoutError",
|
|
128
|
+
"ApiError",
|
|
129
|
+
"AuthenticationError",
|
|
130
|
+
"BadRequestError",
|
|
131
|
+
"InternalServerError",
|
|
132
|
+
"NotFoundError",
|
|
133
|
+
"PermissionDeniedError",
|
|
134
|
+
"RateLimitError",
|
|
135
|
+
"UnprocessableEntityError",
|
|
136
|
+
"classify_http_error",
|
|
137
|
+
]
|
|
File without changes
|