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.
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ .venv/
7
+ venv/
8
+ env/
9
+ build/
10
+ dist/
11
+ *.egg-info/
12
+ .eggs/
13
+ *.egg
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .mypy_cache/
17
+ .coverage
18
+ htmlcov/
19
+ .DS_Store
@@ -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).
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/astroway.svg?style=flat&color=blue)](https://pypi.org/project/astroway/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/astroway.svg)](https://pypi.org/project/astroway/)
41
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/astroway.svg?style=flat&color=blue)](https://pypi.org/project/astroway/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/astroway.svg)](https://pypi.org/project/astroway/)
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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,3 @@
1
+ """SDK version. Mirror of pyproject.toml `version`. Bumped together."""
2
+
3
+ SDK_VERSION = "0.1.0a1"
@@ -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