gunspec 0.1.0__py3-none-any.whl
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.
- gunspec/__init__.py +57 -0
- gunspec/_client.py +213 -0
- gunspec/_core/__init__.py +70 -0
- gunspec/_core/_auth.py +28 -0
- gunspec/_core/_errors.py +190 -0
- gunspec/_core/_http_client.py +580 -0
- gunspec/_core/_pagination.py +95 -0
- gunspec/_core/_retry.py +111 -0
- gunspec/_resources/__init__.py +51 -0
- gunspec/_resources/_ammunition.py +70 -0
- gunspec/_resources/_calibers.py +96 -0
- gunspec/_resources/_categories.py +37 -0
- gunspec/_resources/_conflicts.py +26 -0
- gunspec/_resources/_countries.py +33 -0
- gunspec/_resources/_data_quality.py +32 -0
- gunspec/_resources/_favorites.py +39 -0
- gunspec/_resources/_firearms.py +228 -0
- gunspec/_resources/_game.py +50 -0
- gunspec/_resources/_game_stats.py +47 -0
- gunspec/_resources/_manufacturers.py +74 -0
- gunspec/_resources/_reports.py +32 -0
- gunspec/_resources/_stats.py +98 -0
- gunspec/_resources/_support.py +49 -0
- gunspec/_resources/_usage.py +26 -0
- gunspec/_resources/_webhooks.py +59 -0
- gunspec/_version.py +1 -0
- gunspec/py.typed +0 -0
- gunspec/types/__init__.py +402 -0
- gunspec/types/errors.py +17 -0
- gunspec/types/models.py +1577 -0
- gunspec/types/params.py +482 -0
- gunspec-0.1.0.dist-info/METADATA +210 -0
- gunspec-0.1.0.dist-info/RECORD +34 -0
- gunspec-0.1.0.dist-info/WHEEL +4 -0
gunspec/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""GunSpec.io Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from gunspec._client import AsyncGunSpec, GunSpec
|
|
6
|
+
from gunspec._core._errors import (
|
|
7
|
+
APIError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
ConnectError,
|
|
11
|
+
GunSpecError,
|
|
12
|
+
InternalServerError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
RequestTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
from gunspec._core._http_client import (
|
|
19
|
+
APIResponse,
|
|
20
|
+
HttpClientConfig,
|
|
21
|
+
PaginatedResponse,
|
|
22
|
+
PaginationMeta,
|
|
23
|
+
RateLimitInfo,
|
|
24
|
+
)
|
|
25
|
+
from gunspec._core._pagination import AsyncPage, SyncPage
|
|
26
|
+
from gunspec._core._retry import RetryConfig
|
|
27
|
+
from gunspec._version import __version__
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# Client
|
|
31
|
+
"GunSpec",
|
|
32
|
+
"AsyncGunSpec",
|
|
33
|
+
# Version
|
|
34
|
+
"__version__",
|
|
35
|
+
# Config
|
|
36
|
+
"HttpClientConfig",
|
|
37
|
+
"RetryConfig",
|
|
38
|
+
# Response types
|
|
39
|
+
"APIResponse",
|
|
40
|
+
"PaginatedResponse",
|
|
41
|
+
"PaginationMeta",
|
|
42
|
+
"RateLimitInfo",
|
|
43
|
+
# Pagination
|
|
44
|
+
"SyncPage",
|
|
45
|
+
"AsyncPage",
|
|
46
|
+
# Errors
|
|
47
|
+
"GunSpecError",
|
|
48
|
+
"APIError",
|
|
49
|
+
"AuthenticationError",
|
|
50
|
+
"PermissionDeniedError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"BadRequestError",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"InternalServerError",
|
|
55
|
+
"ConnectError",
|
|
56
|
+
"RequestTimeoutError",
|
|
57
|
+
]
|
gunspec/_client.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ._core._http_client import (
|
|
6
|
+
AsyncHttpClient,
|
|
7
|
+
HttpClientConfig,
|
|
8
|
+
SyncHttpClient,
|
|
9
|
+
)
|
|
10
|
+
from ._core._retry import RetryConfig
|
|
11
|
+
from ._resources import (
|
|
12
|
+
Ammunition,
|
|
13
|
+
AsyncAmmunition,
|
|
14
|
+
AsyncCalibers,
|
|
15
|
+
AsyncCategories,
|
|
16
|
+
AsyncConflicts,
|
|
17
|
+
AsyncCountries,
|
|
18
|
+
AsyncDataQuality,
|
|
19
|
+
AsyncFavorites,
|
|
20
|
+
AsyncFirearms,
|
|
21
|
+
AsyncGame,
|
|
22
|
+
AsyncGameStats_,
|
|
23
|
+
AsyncManufacturers,
|
|
24
|
+
AsyncReports,
|
|
25
|
+
AsyncStats,
|
|
26
|
+
AsyncSupport,
|
|
27
|
+
AsyncUsage,
|
|
28
|
+
AsyncWebhooks,
|
|
29
|
+
Calibers,
|
|
30
|
+
Categories,
|
|
31
|
+
Conflicts,
|
|
32
|
+
Countries,
|
|
33
|
+
DataQuality,
|
|
34
|
+
Favorites,
|
|
35
|
+
Firearms,
|
|
36
|
+
Game,
|
|
37
|
+
GameStats_,
|
|
38
|
+
Manufacturers,
|
|
39
|
+
Reports,
|
|
40
|
+
Stats,
|
|
41
|
+
Support,
|
|
42
|
+
Usage,
|
|
43
|
+
Webhooks,
|
|
44
|
+
)
|
|
45
|
+
from ._version import __version__
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GunSpec:
|
|
49
|
+
"""Synchronous GunSpec.io API client.
|
|
50
|
+
|
|
51
|
+
Example::
|
|
52
|
+
|
|
53
|
+
from gunspec import GunSpec
|
|
54
|
+
|
|
55
|
+
client = GunSpec(api_key="gs_...")
|
|
56
|
+
|
|
57
|
+
# List firearms
|
|
58
|
+
result = client.firearms.list({"category": "pistol"})
|
|
59
|
+
for firearm in result.data:
|
|
60
|
+
print(firearm)
|
|
61
|
+
|
|
62
|
+
# Context manager for clean shutdown
|
|
63
|
+
with GunSpec() as client:
|
|
64
|
+
data = client.firearms.get("glock-g17")
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
firearms: Firearms
|
|
68
|
+
manufacturers: Manufacturers
|
|
69
|
+
calibers: Calibers
|
|
70
|
+
categories: Categories
|
|
71
|
+
stats: Stats
|
|
72
|
+
game: Game
|
|
73
|
+
game_stats: GameStats_
|
|
74
|
+
ammunition: Ammunition
|
|
75
|
+
countries: Countries
|
|
76
|
+
conflicts: Conflicts
|
|
77
|
+
data_quality: DataQuality
|
|
78
|
+
favorites: Favorites
|
|
79
|
+
reports: Reports
|
|
80
|
+
support: Support
|
|
81
|
+
webhooks: Webhooks
|
|
82
|
+
usage: Usage
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
api_key: Optional[str] = None,
|
|
88
|
+
base_url: Optional[str] = None,
|
|
89
|
+
timeout: Optional[float] = None,
|
|
90
|
+
retry: Optional[RetryConfig] = None,
|
|
91
|
+
default_headers: Optional[dict[str, str]] = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
config = HttpClientConfig(
|
|
94
|
+
base_url=base_url or "https://api.gunspec.io",
|
|
95
|
+
timeout=timeout or 30.0,
|
|
96
|
+
api_key=api_key,
|
|
97
|
+
retry=retry or RetryConfig(),
|
|
98
|
+
headers={
|
|
99
|
+
**(default_headers or {}),
|
|
100
|
+
"User-Agent": f"gunspec-sdk/python/{__version__}",
|
|
101
|
+
"X-SDK-Version": __version__,
|
|
102
|
+
"X-SDK-Language": "python",
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
self._client = SyncHttpClient(config)
|
|
106
|
+
|
|
107
|
+
self.firearms = Firearms(self._client)
|
|
108
|
+
self.manufacturers = Manufacturers(self._client)
|
|
109
|
+
self.calibers = Calibers(self._client)
|
|
110
|
+
self.categories = Categories(self._client)
|
|
111
|
+
self.stats = Stats(self._client)
|
|
112
|
+
self.game = Game(self._client)
|
|
113
|
+
self.game_stats = GameStats_(self._client)
|
|
114
|
+
self.ammunition = Ammunition(self._client)
|
|
115
|
+
self.countries = Countries(self._client)
|
|
116
|
+
self.conflicts = Conflicts(self._client)
|
|
117
|
+
self.data_quality = DataQuality(self._client)
|
|
118
|
+
self.favorites = Favorites(self._client)
|
|
119
|
+
self.reports = Reports(self._client)
|
|
120
|
+
self.support = Support(self._client)
|
|
121
|
+
self.webhooks = Webhooks(self._client)
|
|
122
|
+
self.usage = Usage(self._client)
|
|
123
|
+
|
|
124
|
+
def close(self) -> None:
|
|
125
|
+
"""Close the underlying HTTP connection pool."""
|
|
126
|
+
self._client.close()
|
|
127
|
+
|
|
128
|
+
def __enter__(self) -> GunSpec:
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *_: object) -> None:
|
|
132
|
+
self.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AsyncGunSpec:
|
|
136
|
+
"""Asynchronous GunSpec.io API client.
|
|
137
|
+
|
|
138
|
+
Example::
|
|
139
|
+
|
|
140
|
+
from gunspec import AsyncGunSpec
|
|
141
|
+
|
|
142
|
+
async with AsyncGunSpec(api_key="gs_...") as client:
|
|
143
|
+
result = await client.firearms.list({"category": "rifle"})
|
|
144
|
+
for firearm in result.data:
|
|
145
|
+
print(firearm)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
firearms: AsyncFirearms
|
|
149
|
+
manufacturers: AsyncManufacturers
|
|
150
|
+
calibers: AsyncCalibers
|
|
151
|
+
categories: AsyncCategories
|
|
152
|
+
stats: AsyncStats
|
|
153
|
+
game: AsyncGame
|
|
154
|
+
game_stats: AsyncGameStats_
|
|
155
|
+
ammunition: AsyncAmmunition
|
|
156
|
+
countries: AsyncCountries
|
|
157
|
+
conflicts: AsyncConflicts
|
|
158
|
+
data_quality: AsyncDataQuality
|
|
159
|
+
favorites: AsyncFavorites
|
|
160
|
+
reports: AsyncReports
|
|
161
|
+
support: AsyncSupport
|
|
162
|
+
webhooks: AsyncWebhooks
|
|
163
|
+
usage: AsyncUsage
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
api_key: Optional[str] = None,
|
|
169
|
+
base_url: Optional[str] = None,
|
|
170
|
+
timeout: Optional[float] = None,
|
|
171
|
+
retry: Optional[RetryConfig] = None,
|
|
172
|
+
default_headers: Optional[dict[str, str]] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
config = HttpClientConfig(
|
|
175
|
+
base_url=base_url or "https://api.gunspec.io",
|
|
176
|
+
timeout=timeout or 30.0,
|
|
177
|
+
api_key=api_key,
|
|
178
|
+
retry=retry or RetryConfig(),
|
|
179
|
+
headers={
|
|
180
|
+
**(default_headers or {}),
|
|
181
|
+
"User-Agent": f"gunspec-sdk/python/{__version__}",
|
|
182
|
+
"X-SDK-Version": __version__,
|
|
183
|
+
"X-SDK-Language": "python",
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
self._client = AsyncHttpClient(config)
|
|
187
|
+
|
|
188
|
+
self.firearms = AsyncFirearms(self._client)
|
|
189
|
+
self.manufacturers = AsyncManufacturers(self._client)
|
|
190
|
+
self.calibers = AsyncCalibers(self._client)
|
|
191
|
+
self.categories = AsyncCategories(self._client)
|
|
192
|
+
self.stats = AsyncStats(self._client)
|
|
193
|
+
self.game = AsyncGame(self._client)
|
|
194
|
+
self.game_stats = AsyncGameStats_(self._client)
|
|
195
|
+
self.ammunition = AsyncAmmunition(self._client)
|
|
196
|
+
self.countries = AsyncCountries(self._client)
|
|
197
|
+
self.conflicts = AsyncConflicts(self._client)
|
|
198
|
+
self.data_quality = AsyncDataQuality(self._client)
|
|
199
|
+
self.favorites = AsyncFavorites(self._client)
|
|
200
|
+
self.reports = AsyncReports(self._client)
|
|
201
|
+
self.support = AsyncSupport(self._client)
|
|
202
|
+
self.webhooks = AsyncWebhooks(self._client)
|
|
203
|
+
self.usage = AsyncUsage(self._client)
|
|
204
|
+
|
|
205
|
+
async def aclose(self) -> None:
|
|
206
|
+
"""Close the underlying HTTP connection pool."""
|
|
207
|
+
await self._client.aclose()
|
|
208
|
+
|
|
209
|
+
async def __aenter__(self) -> AsyncGunSpec:
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
async def __aexit__(self, *_: object) -> None:
|
|
213
|
+
await self.aclose()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._auth import build_auth_headers, resolve_api_key
|
|
4
|
+
from ._errors import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ConnectError,
|
|
9
|
+
GunSpecError,
|
|
10
|
+
InternalServerError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
RequestTimeoutError,
|
|
15
|
+
create_api_error,
|
|
16
|
+
)
|
|
17
|
+
from ._http_client import (
|
|
18
|
+
DEFAULT_BASE_URL,
|
|
19
|
+
DEFAULT_TIMEOUT,
|
|
20
|
+
SDK_USER_AGENT,
|
|
21
|
+
APIResponse,
|
|
22
|
+
AsyncHttpClient,
|
|
23
|
+
HttpClientConfig,
|
|
24
|
+
PaginatedResponse,
|
|
25
|
+
PaginationMeta,
|
|
26
|
+
RateLimitInfo,
|
|
27
|
+
SyncHttpClient,
|
|
28
|
+
serialize_query,
|
|
29
|
+
)
|
|
30
|
+
from ._pagination import AsyncPage, SyncPage
|
|
31
|
+
from ._retry import RetryConfig, async_with_retry, compute_delay, is_retryable, with_retry
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Errors
|
|
35
|
+
"GunSpecError",
|
|
36
|
+
"APIError",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"PermissionDeniedError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"BadRequestError",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"InternalServerError",
|
|
43
|
+
"ConnectError",
|
|
44
|
+
"RequestTimeoutError",
|
|
45
|
+
"create_api_error",
|
|
46
|
+
# Auth
|
|
47
|
+
"resolve_api_key",
|
|
48
|
+
"build_auth_headers",
|
|
49
|
+
# HTTP client
|
|
50
|
+
"SyncHttpClient",
|
|
51
|
+
"AsyncHttpClient",
|
|
52
|
+
"HttpClientConfig",
|
|
53
|
+
"APIResponse",
|
|
54
|
+
"PaginatedResponse",
|
|
55
|
+
"PaginationMeta",
|
|
56
|
+
"RateLimitInfo",
|
|
57
|
+
"serialize_query",
|
|
58
|
+
"DEFAULT_BASE_URL",
|
|
59
|
+
"DEFAULT_TIMEOUT",
|
|
60
|
+
"SDK_USER_AGENT",
|
|
61
|
+
# Pagination
|
|
62
|
+
"SyncPage",
|
|
63
|
+
"AsyncPage",
|
|
64
|
+
# Retry
|
|
65
|
+
"RetryConfig",
|
|
66
|
+
"is_retryable",
|
|
67
|
+
"compute_delay",
|
|
68
|
+
"with_retry",
|
|
69
|
+
"async_with_retry",
|
|
70
|
+
]
|
gunspec/_core/_auth.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_api_key(api_key: Optional[str] = None) -> Optional[str]:
|
|
8
|
+
"""Resolve the API key from the explicit parameter or GUNSPEC_API_KEY env var.
|
|
9
|
+
|
|
10
|
+
Resolution order:
|
|
11
|
+
1. Explicit ``api_key`` parameter.
|
|
12
|
+
2. ``GUNSPEC_API_KEY`` environment variable.
|
|
13
|
+
3. ``None`` (anonymous / unauthenticated).
|
|
14
|
+
"""
|
|
15
|
+
if api_key is not None and api_key != "":
|
|
16
|
+
return api_key
|
|
17
|
+
return os.environ.get("GUNSPEC_API_KEY") or None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_auth_headers(api_key: Optional[str] = None) -> Dict[str, str]:
|
|
21
|
+
"""Build the authentication headers for a request.
|
|
22
|
+
|
|
23
|
+
Returns ``{"X-API-Key": key}`` when a key is available, or an empty dict
|
|
24
|
+
for anonymous mode.
|
|
25
|
+
"""
|
|
26
|
+
if api_key is not None and api_key != "":
|
|
27
|
+
return {"X-API-Key": api_key}
|
|
28
|
+
return {}
|
gunspec/_core/_errors.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GunSpecError(Exception):
|
|
9
|
+
"""Base error class for every error raised by the GunSpec SDK."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(GunSpecError):
|
|
13
|
+
"""An error returned by the GunSpec API with an HTTP status code."""
|
|
14
|
+
|
|
15
|
+
status: int
|
|
16
|
+
code: str
|
|
17
|
+
message: str
|
|
18
|
+
request_id: str
|
|
19
|
+
headers: Dict[str, str]
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
status: int,
|
|
24
|
+
code: str,
|
|
25
|
+
message: str,
|
|
26
|
+
request_id: str,
|
|
27
|
+
headers: Dict[str, str],
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status = status
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
self.headers = headers
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AuthenticationError(APIError):
|
|
38
|
+
"""Raised when the API returns 401 Unauthorized."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
code: str,
|
|
43
|
+
message: str,
|
|
44
|
+
request_id: str,
|
|
45
|
+
headers: Dict[str, str],
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(401, code, message, request_id, headers)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PermissionDeniedError(APIError):
|
|
51
|
+
"""Raised when the API returns 403 Forbidden."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
code: str,
|
|
56
|
+
message: str,
|
|
57
|
+
request_id: str,
|
|
58
|
+
headers: Dict[str, str],
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(403, code, message, request_id, headers)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NotFoundError(APIError):
|
|
64
|
+
"""Raised when the API returns 404 Not Found."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
code: str,
|
|
69
|
+
message: str,
|
|
70
|
+
request_id: str,
|
|
71
|
+
headers: Dict[str, str],
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(404, code, message, request_id, headers)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BadRequestError(APIError):
|
|
77
|
+
"""Raised when the API returns 400 Bad Request."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
code: str,
|
|
82
|
+
message: str,
|
|
83
|
+
request_id: str,
|
|
84
|
+
headers: Dict[str, str],
|
|
85
|
+
) -> None:
|
|
86
|
+
super().__init__(400, code, message, request_id, headers)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RateLimitError(APIError):
|
|
90
|
+
"""Raised when the API returns 429 Too Many Requests."""
|
|
91
|
+
|
|
92
|
+
retry_after: Optional[float]
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
code: str,
|
|
97
|
+
message: str,
|
|
98
|
+
request_id: str,
|
|
99
|
+
headers: Dict[str, str],
|
|
100
|
+
retry_after: Optional[float],
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__(429, code, message, request_id, headers)
|
|
103
|
+
self.retry_after = retry_after
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InternalServerError(APIError):
|
|
107
|
+
"""Raised when the API returns 500 Internal Server Error."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
code: str,
|
|
112
|
+
message: str,
|
|
113
|
+
request_id: str,
|
|
114
|
+
headers: Dict[str, str],
|
|
115
|
+
) -> None:
|
|
116
|
+
super().__init__(500, code, message, request_id, headers)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ConnectError(GunSpecError):
|
|
120
|
+
"""Raised on network-level failures (DNS, connection reset, etc.)."""
|
|
121
|
+
|
|
122
|
+
cause: Optional[BaseException]
|
|
123
|
+
|
|
124
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
125
|
+
super().__init__(message)
|
|
126
|
+
self.cause = cause
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RequestTimeoutError(GunSpecError):
|
|
130
|
+
"""Raised when a request exceeds the configured timeout."""
|
|
131
|
+
|
|
132
|
+
timeout_s: float
|
|
133
|
+
|
|
134
|
+
def __init__(self, timeout_s: float) -> None:
|
|
135
|
+
super().__init__(f"Request timed out after {timeout_s}s")
|
|
136
|
+
self.timeout_s = timeout_s
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_retry_after(headers: Dict[str, str]) -> Optional[float]:
|
|
140
|
+
"""Parse the Retry-After header value into seconds."""
|
|
141
|
+
raw = headers.get("Retry-After") or headers.get("retry-after")
|
|
142
|
+
if raw is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
# Try delta-seconds first.
|
|
146
|
+
try:
|
|
147
|
+
seconds = float(raw)
|
|
148
|
+
if not math.isnan(seconds) and seconds >= 0:
|
|
149
|
+
return seconds
|
|
150
|
+
except ValueError:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
# Fall back to HTTP-date.
|
|
154
|
+
try:
|
|
155
|
+
dt = datetime.strptime(raw, "%a, %d %b %Y %H:%M:%S %Z")
|
|
156
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
157
|
+
delta = (dt - datetime.now(timezone.utc)).total_seconds()
|
|
158
|
+
return max(delta, 0.0)
|
|
159
|
+
except ValueError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_api_error(
|
|
166
|
+
status: int,
|
|
167
|
+
body: Optional[Dict[str, Any]],
|
|
168
|
+
request_id: str,
|
|
169
|
+
headers: Dict[str, str],
|
|
170
|
+
) -> APIError:
|
|
171
|
+
"""Create the appropriate APIError subclass for the given HTTP status."""
|
|
172
|
+
error_data = body.get("error", {}) if body else {}
|
|
173
|
+
code: str = error_data.get("code", f"HTTP_{status}")
|
|
174
|
+
message: str = error_data.get("message", f"Request failed with status {status}")
|
|
175
|
+
|
|
176
|
+
if status == 400:
|
|
177
|
+
return BadRequestError(code, message, request_id, headers)
|
|
178
|
+
if status == 401:
|
|
179
|
+
return AuthenticationError(code, message, request_id, headers)
|
|
180
|
+
if status == 403:
|
|
181
|
+
return PermissionDeniedError(code, message, request_id, headers)
|
|
182
|
+
if status == 404:
|
|
183
|
+
return NotFoundError(code, message, request_id, headers)
|
|
184
|
+
if status == 429:
|
|
185
|
+
return RateLimitError(
|
|
186
|
+
code, message, request_id, headers, _parse_retry_after(headers)
|
|
187
|
+
)
|
|
188
|
+
if status == 500:
|
|
189
|
+
return InternalServerError(code, message, request_id, headers)
|
|
190
|
+
return APIError(status, code, message, request_id, headers)
|