actos 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.
- actos/__init__.py +101 -0
- actos/_pagination.py +99 -0
- actos/_transport/__init__.py +8 -0
- actos/_transport/_async.py +224 -0
- actos/_transport/_sync.py +226 -0
- actos/client.py +266 -0
- actos/errors.py +337 -0
- actos/py.typed +0 -0
- actos/resources/_async/__init__.py +35 -0
- actos/resources/_async/actors.py +426 -0
- actos/resources/_async/admin.py +254 -0
- actos/resources/_async/auth.py +147 -0
- actos/resources/_async/base.py +15 -0
- actos/resources/_async/comments.py +194 -0
- actos/resources/_async/feed.py +180 -0
- actos/resources/_async/inbox.py +173 -0
- actos/resources/_async/meta.py +62 -0
- actos/resources/_async/posts.py +154 -0
- actos/resources/_async/reports.py +38 -0
- actos/resources/_async/saves.py +109 -0
- actos/resources/_async/search.py +89 -0
- actos/resources/_async/tags.py +160 -0
- actos/resources/_async/uploads.py +107 -0
- actos/resources/_async/votes.py +98 -0
- actos/resources/_sync/__init__.py +37 -0
- actos/resources/_sync/actors.py +428 -0
- actos/resources/_sync/admin.py +256 -0
- actos/resources/_sync/auth.py +149 -0
- actos/resources/_sync/base.py +17 -0
- actos/resources/_sync/comments.py +196 -0
- actos/resources/_sync/feed.py +182 -0
- actos/resources/_sync/inbox.py +173 -0
- actos/resources/_sync/meta.py +64 -0
- actos/resources/_sync/posts.py +156 -0
- actos/resources/_sync/reports.py +40 -0
- actos/resources/_sync/saves.py +111 -0
- actos/resources/_sync/search.py +91 -0
- actos/resources/_sync/tags.py +162 -0
- actos/resources/_sync/uploads.py +109 -0
- actos/resources/_sync/votes.py +100 -0
- actos/types.py +778 -0
- actos-0.1.0.dist-info/METADATA +280 -0
- actos-0.1.0.dist-info/RECORD +45 -0
- actos-0.1.0.dist-info/WHEEL +4 -0
- actos-0.1.0.dist-info/licenses/LICENSE +190 -0
actos/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Official Python SDK for the Actos platform (sync + async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from actos import errors, types
|
|
6
|
+
from actos._pagination import Page
|
|
7
|
+
from actos.client import Actos, AsyncActos
|
|
8
|
+
from actos.errors import (
|
|
9
|
+
ActosAPIError,
|
|
10
|
+
ActosError,
|
|
11
|
+
ActosTransportError,
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APITimeoutError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
BannedError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
ForbiddenError,
|
|
18
|
+
GoneError,
|
|
19
|
+
InternalServerError,
|
|
20
|
+
InvalidCursorError,
|
|
21
|
+
InvalidKeyError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
UnsupportedMediaError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
create_api_error,
|
|
27
|
+
)
|
|
28
|
+
from actos.resources._async.actors import UNSET, UnsetSentinel
|
|
29
|
+
from actos.resources._async.inbox import InboxPage
|
|
30
|
+
from actos.types import (
|
|
31
|
+
Actor,
|
|
32
|
+
ActorProfile,
|
|
33
|
+
AdminAction,
|
|
34
|
+
Attachment,
|
|
35
|
+
Ban,
|
|
36
|
+
Comment,
|
|
37
|
+
CommentDetail,
|
|
38
|
+
CommentNode,
|
|
39
|
+
CommentSummary,
|
|
40
|
+
ErrorCode,
|
|
41
|
+
FeedWindow,
|
|
42
|
+
HealthResponse,
|
|
43
|
+
MetaVersionResponse,
|
|
44
|
+
Post,
|
|
45
|
+
PostSort,
|
|
46
|
+
RateLimit,
|
|
47
|
+
Report,
|
|
48
|
+
Tag,
|
|
49
|
+
Upload,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.1.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"UNSET",
|
|
56
|
+
"APIConnectionError",
|
|
57
|
+
"APITimeoutError",
|
|
58
|
+
"Actor",
|
|
59
|
+
"ActorProfile",
|
|
60
|
+
"Actos",
|
|
61
|
+
"ActosAPIError",
|
|
62
|
+
"ActosError",
|
|
63
|
+
"ActosTransportError",
|
|
64
|
+
"AdminAction",
|
|
65
|
+
"AsyncActos",
|
|
66
|
+
"Attachment",
|
|
67
|
+
"AuthenticationError",
|
|
68
|
+
"Ban",
|
|
69
|
+
"BannedError",
|
|
70
|
+
"Comment",
|
|
71
|
+
"CommentDetail",
|
|
72
|
+
"CommentNode",
|
|
73
|
+
"CommentSummary",
|
|
74
|
+
"ConflictError",
|
|
75
|
+
"ErrorCode",
|
|
76
|
+
"FeedWindow",
|
|
77
|
+
"ForbiddenError",
|
|
78
|
+
"GoneError",
|
|
79
|
+
"HealthResponse",
|
|
80
|
+
"InboxPage",
|
|
81
|
+
"InternalServerError",
|
|
82
|
+
"InvalidCursorError",
|
|
83
|
+
"InvalidKeyError",
|
|
84
|
+
"MetaVersionResponse",
|
|
85
|
+
"NotFoundError",
|
|
86
|
+
"Page",
|
|
87
|
+
"Post",
|
|
88
|
+
"PostSort",
|
|
89
|
+
"RateLimit",
|
|
90
|
+
"RateLimitError",
|
|
91
|
+
"Report",
|
|
92
|
+
"Tag",
|
|
93
|
+
"UnsetSentinel",
|
|
94
|
+
"UnsupportedMediaError",
|
|
95
|
+
"Upload",
|
|
96
|
+
"ValidationError",
|
|
97
|
+
"__version__",
|
|
98
|
+
"create_api_error",
|
|
99
|
+
"errors",
|
|
100
|
+
"types",
|
|
101
|
+
]
|
actos/_pagination.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Two-tier pagination: raw Page[T] and auto-paging iterators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Page(Generic[T]):
|
|
16
|
+
"""A single page of paginated results returned by list() endpoints.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
items: List of items on this page.
|
|
20
|
+
next_cursor: Opaque cursor string for requesting the subsequent page,
|
|
21
|
+
or None if this is the final page.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
items: list[T]
|
|
25
|
+
next_cursor: str | None = None
|
|
26
|
+
|
|
27
|
+
def __len__(self) -> int:
|
|
28
|
+
return len(self.items)
|
|
29
|
+
|
|
30
|
+
def __iter__(self) -> Iterator[T]:
|
|
31
|
+
return iter(self.items)
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, index: int) -> T:
|
|
34
|
+
return self.items[index]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncPaginator(Generic[T]):
|
|
38
|
+
"""Asynchronous auto-paging iterator for iter_* methods."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
fetch_page: Callable[[str | None], Awaitable[Page[T]]],
|
|
43
|
+
) -> None:
|
|
44
|
+
self._fetch_page = fetch_page
|
|
45
|
+
|
|
46
|
+
def __aiter__(self) -> AsyncIterator[T]:
|
|
47
|
+
return self._iterate()
|
|
48
|
+
|
|
49
|
+
async def _iterate(self) -> AsyncIterator[T]:
|
|
50
|
+
cursor: str | None = None
|
|
51
|
+
has_more = True
|
|
52
|
+
while has_more:
|
|
53
|
+
page = await self._fetch_page(cursor)
|
|
54
|
+
for item in page.items:
|
|
55
|
+
yield item
|
|
56
|
+
cursor = page.next_cursor
|
|
57
|
+
if not cursor:
|
|
58
|
+
has_more = False
|
|
59
|
+
|
|
60
|
+
async def collect(self, limit: int | None = None) -> list[T]:
|
|
61
|
+
"""Collect paginated items into a list, up to an optional limit."""
|
|
62
|
+
results: list[T] = []
|
|
63
|
+
async for item in self:
|
|
64
|
+
results.append(item)
|
|
65
|
+
if limit is not None and len(results) >= limit:
|
|
66
|
+
break
|
|
67
|
+
return results
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SyncPaginator(Generic[T]):
|
|
71
|
+
"""Synchronous auto-paging iterator for iter_* methods."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
fetch_page: Callable[[str | None], Page[T]],
|
|
76
|
+
) -> None:
|
|
77
|
+
self._fetch_page = fetch_page
|
|
78
|
+
|
|
79
|
+
def __iter__(self) -> Iterator[T]:
|
|
80
|
+
return self._iterate()
|
|
81
|
+
|
|
82
|
+
def _iterate(self) -> Iterator[T]:
|
|
83
|
+
cursor: str | None = None
|
|
84
|
+
has_more = True
|
|
85
|
+
while has_more:
|
|
86
|
+
page = self._fetch_page(cursor)
|
|
87
|
+
yield from page.items
|
|
88
|
+
cursor = page.next_cursor
|
|
89
|
+
if not cursor:
|
|
90
|
+
has_more = False
|
|
91
|
+
|
|
92
|
+
def collect(self, limit: int | None = None) -> list[T]:
|
|
93
|
+
"""Collect paginated items into a list, up to an optional limit."""
|
|
94
|
+
results: list[T] = []
|
|
95
|
+
for item in self:
|
|
96
|
+
results.append(item)
|
|
97
|
+
if limit is not None and len(results) >= limit:
|
|
98
|
+
break
|
|
99
|
+
return results
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Asynchronous HTTP transport layer with retry, backoff, and rate-limit tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
import actos
|
|
14
|
+
from actos.errors import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APITimeoutError,
|
|
17
|
+
create_api_error,
|
|
18
|
+
parse_retry_after,
|
|
19
|
+
)
|
|
20
|
+
from actos.types import RateLimit
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from types import TracebackType
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def compute_backoff_delay(attempt: int, retry_after: float | None = None) -> float:
|
|
27
|
+
"""Calculate delay for retry with exponential backoff and full jitter.
|
|
28
|
+
|
|
29
|
+
If Retry-After is specified, it takes absolute precedence (§2.8).
|
|
30
|
+
"""
|
|
31
|
+
if retry_after is not None:
|
|
32
|
+
return retry_after
|
|
33
|
+
|
|
34
|
+
base_delay = 0.25
|
|
35
|
+
max_delay = 30.0
|
|
36
|
+
ceiling = min(max_delay, base_delay * (2**attempt))
|
|
37
|
+
return random.uniform(0.0, ceiling)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncTransport:
|
|
41
|
+
"""Handles HTTP requests, connection pooling, headers, retries, and rate limits."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
base_url: str | None = None,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
max_retries: int = 2,
|
|
49
|
+
client: httpx.AsyncClient | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
raw_base_url = (
|
|
52
|
+
base_url
|
|
53
|
+
if base_url is not None
|
|
54
|
+
else os.environ.get("ACTOS_BASE_URL", "http://127.0.0.1:3100")
|
|
55
|
+
)
|
|
56
|
+
self.base_url = raw_base_url.rstrip("/")
|
|
57
|
+
self.api_key = api_key if api_key is not None else os.environ.get("ACTOS_API_KEY")
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.max_retries = max_retries
|
|
60
|
+
self.rate_limit: RateLimit | None = None
|
|
61
|
+
|
|
62
|
+
self._custom_client = client is not None
|
|
63
|
+
self._client = client if client is not None else httpx.AsyncClient(timeout=timeout)
|
|
64
|
+
|
|
65
|
+
def _update_rate_limit(self, headers: httpx.Headers) -> None:
|
|
66
|
+
limit_str = headers.get("x-ratelimit-limit")
|
|
67
|
+
rem_str = headers.get("x-ratelimit-remaining")
|
|
68
|
+
reset_str = headers.get("x-ratelimit-reset")
|
|
69
|
+
|
|
70
|
+
if limit_str is not None and rem_str is not None and reset_str is not None:
|
|
71
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
72
|
+
self.rate_limit = RateLimit(
|
|
73
|
+
limit=int(limit_str),
|
|
74
|
+
remaining=int(rem_str),
|
|
75
|
+
reset=int(reset_str),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
79
|
+
data: Any = None
|
|
80
|
+
content_type = response.headers.get("content-type", "")
|
|
81
|
+
|
|
82
|
+
if "application/json" in content_type or "application/problem+json" in content_type:
|
|
83
|
+
with contextlib.suppress(Exception):
|
|
84
|
+
data = response.json()
|
|
85
|
+
|
|
86
|
+
if data is None and response.text:
|
|
87
|
+
data = response.text
|
|
88
|
+
|
|
89
|
+
error = create_api_error(
|
|
90
|
+
status=response.status_code,
|
|
91
|
+
data=data,
|
|
92
|
+
headers=response.headers,
|
|
93
|
+
rate_limit=self.rate_limit,
|
|
94
|
+
)
|
|
95
|
+
raise error
|
|
96
|
+
|
|
97
|
+
def _format_params(self, params: dict[str, Any] | None) -> dict[str, str] | None:
|
|
98
|
+
if not params:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
formatted: dict[str, str] = {}
|
|
102
|
+
for k, v in params.items():
|
|
103
|
+
if v is None:
|
|
104
|
+
continue
|
|
105
|
+
if isinstance(v, (list, tuple, set)):
|
|
106
|
+
formatted[k] = ",".join(str(item) for item in v)
|
|
107
|
+
elif isinstance(v, bool):
|
|
108
|
+
formatted[k] = "true" if v else "false"
|
|
109
|
+
else:
|
|
110
|
+
formatted[k] = str(v)
|
|
111
|
+
return formatted
|
|
112
|
+
|
|
113
|
+
async def request(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path: str,
|
|
117
|
+
*,
|
|
118
|
+
params: dict[str, Any] | None = None,
|
|
119
|
+
json: Any = None,
|
|
120
|
+
data: Any = None,
|
|
121
|
+
files: Any = None,
|
|
122
|
+
headers: dict[str, str] | None = None,
|
|
123
|
+
idempotency_key: str | None = None,
|
|
124
|
+
timeout: float | None = None,
|
|
125
|
+
) -> httpx.Response:
|
|
126
|
+
"""Send an HTTP request with automatic retries and rate-limit tracking."""
|
|
127
|
+
if path.startswith(("http://", "https://")):
|
|
128
|
+
url = path
|
|
129
|
+
else:
|
|
130
|
+
clean_path = "/" + path.lstrip("/")
|
|
131
|
+
url = f"{self.base_url}{clean_path}"
|
|
132
|
+
|
|
133
|
+
req_headers = {
|
|
134
|
+
"Accept": "application/json",
|
|
135
|
+
"User-Agent": f"actos-python/{actos.__version__}",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if self.api_key:
|
|
139
|
+
req_headers["Authorization"] = f"Bearer {self.api_key}"
|
|
140
|
+
|
|
141
|
+
if idempotency_key:
|
|
142
|
+
req_headers["Idempotency-Key"] = idempotency_key
|
|
143
|
+
|
|
144
|
+
if headers:
|
|
145
|
+
req_headers.update(headers)
|
|
146
|
+
|
|
147
|
+
formatted_params = self._format_params(params)
|
|
148
|
+
timeout_val = timeout if timeout is not None else self.timeout
|
|
149
|
+
|
|
150
|
+
is_post = method.upper() == "POST"
|
|
151
|
+
has_idempotency_key = bool(
|
|
152
|
+
req_headers.get("Idempotency-Key") or req_headers.get("idempotency-key")
|
|
153
|
+
)
|
|
154
|
+
can_retry_5xx = (not is_post) or has_idempotency_key
|
|
155
|
+
|
|
156
|
+
for attempt in range(self.max_retries + 1):
|
|
157
|
+
try:
|
|
158
|
+
response = await self._client.request(
|
|
159
|
+
method=method,
|
|
160
|
+
url=url,
|
|
161
|
+
params=formatted_params,
|
|
162
|
+
json=json,
|
|
163
|
+
data=data,
|
|
164
|
+
files=files,
|
|
165
|
+
headers=req_headers,
|
|
166
|
+
timeout=timeout_val,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
self._update_rate_limit(response.headers)
|
|
170
|
+
|
|
171
|
+
# 1. Successful response (< 400)
|
|
172
|
+
if response.status_code < 400:
|
|
173
|
+
return response
|
|
174
|
+
|
|
175
|
+
# 2. 429 Too Many Requests -> retry if attempt < max_retries
|
|
176
|
+
if response.status_code == 429 and attempt < self.max_retries:
|
|
177
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
178
|
+
delay = compute_backoff_delay(attempt, retry_after)
|
|
179
|
+
await asyncio.sleep(delay)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# 3. 5xx Server Error -> retry if can_retry_5xx and attempt < max_retries
|
|
183
|
+
if (
|
|
184
|
+
500 <= response.status_code <= 599
|
|
185
|
+
and can_retry_5xx
|
|
186
|
+
and attempt < self.max_retries
|
|
187
|
+
):
|
|
188
|
+
delay = compute_backoff_delay(attempt)
|
|
189
|
+
await asyncio.sleep(delay)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# 4. Other 4xx or retries exhausted -> raise specific ActosAPIError subclass
|
|
193
|
+
self._raise_for_status(response)
|
|
194
|
+
|
|
195
|
+
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
|
196
|
+
# Network/Timeout error retry: safe requests or POST with idempotency key
|
|
197
|
+
if can_retry_5xx and attempt < self.max_retries:
|
|
198
|
+
delay = compute_backoff_delay(attempt)
|
|
199
|
+
await asyncio.sleep(delay)
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
203
|
+
raise APITimeoutError(f"Request timed out after {timeout_val}s") from exc
|
|
204
|
+
else:
|
|
205
|
+
raise APIConnectionError(f"Connection error: {exc}") from exc
|
|
206
|
+
|
|
207
|
+
# Should be unreachable given the loop raises on final attempt, but to satisfy typing:
|
|
208
|
+
raise APIConnectionError("Request failed: maximum retries exceeded")
|
|
209
|
+
|
|
210
|
+
async def aclose(self) -> None:
|
|
211
|
+
"""Close the underlying HTTP client if created internally."""
|
|
212
|
+
if not self._custom_client:
|
|
213
|
+
await self._client.aclose()
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self) -> AsyncTransport:
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
async def __aexit__(
|
|
219
|
+
self,
|
|
220
|
+
exc_type: type[BaseException] | None,
|
|
221
|
+
exc_val: BaseException | None,
|
|
222
|
+
exc_tb: TracebackType | None,
|
|
223
|
+
) -> None:
|
|
224
|
+
await self.aclose()
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# BU DOSYA OTOMATİK OLARAK ÜRETİLMİŞTİR (unasync) — ELLE DÜZENLEMEYİNİZ.
|
|
2
|
+
|
|
3
|
+
"""Synchronous HTTP transport layer with retry, backoff, and rate-limit tracking."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
import actos
|
|
16
|
+
from actos.errors import (
|
|
17
|
+
APIConnectionError,
|
|
18
|
+
APITimeoutError,
|
|
19
|
+
create_api_error,
|
|
20
|
+
parse_retry_after,
|
|
21
|
+
)
|
|
22
|
+
from actos.types import RateLimit
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compute_backoff_delay(attempt: int, retry_after: float | None = None) -> float:
|
|
29
|
+
"""Calculate delay for retry with exponential backoff and full jitter.
|
|
30
|
+
|
|
31
|
+
If Retry-After is specified, it takes absolute precedence (§2.8).
|
|
32
|
+
"""
|
|
33
|
+
if retry_after is not None:
|
|
34
|
+
return retry_after
|
|
35
|
+
|
|
36
|
+
base_delay = 0.25
|
|
37
|
+
max_delay = 30.0
|
|
38
|
+
ceiling = min(max_delay, base_delay * (2**attempt))
|
|
39
|
+
return random.uniform(0.0, ceiling)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SyncTransport:
|
|
43
|
+
"""Handles HTTP requests, connection pooling, headers, retries, and rate limits."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
api_key: str | None = None,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
max_retries: int = 2,
|
|
51
|
+
client: httpx.Client | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
raw_base_url = (
|
|
54
|
+
base_url
|
|
55
|
+
if base_url is not None
|
|
56
|
+
else os.environ.get("ACTOS_BASE_URL", "http://127.0.0.1:3100")
|
|
57
|
+
)
|
|
58
|
+
self.base_url = raw_base_url.rstrip("/")
|
|
59
|
+
self.api_key = api_key if api_key is not None else os.environ.get("ACTOS_API_KEY")
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.max_retries = max_retries
|
|
62
|
+
self.rate_limit: RateLimit | None = None
|
|
63
|
+
|
|
64
|
+
self._custom_client = client is not None
|
|
65
|
+
self._client = client if client is not None else httpx.Client(timeout=timeout)
|
|
66
|
+
|
|
67
|
+
def _update_rate_limit(self, headers: httpx.Headers) -> None:
|
|
68
|
+
limit_str = headers.get("x-ratelimit-limit")
|
|
69
|
+
rem_str = headers.get("x-ratelimit-remaining")
|
|
70
|
+
reset_str = headers.get("x-ratelimit-reset")
|
|
71
|
+
|
|
72
|
+
if limit_str is not None and rem_str is not None and reset_str is not None:
|
|
73
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
74
|
+
self.rate_limit = RateLimit(
|
|
75
|
+
limit=int(limit_str),
|
|
76
|
+
remaining=int(rem_str),
|
|
77
|
+
reset=int(reset_str),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
81
|
+
data: Any = None
|
|
82
|
+
content_type = response.headers.get("content-type", "")
|
|
83
|
+
|
|
84
|
+
if "application/json" in content_type or "application/problem+json" in content_type:
|
|
85
|
+
with contextlib.suppress(Exception):
|
|
86
|
+
data = response.json()
|
|
87
|
+
|
|
88
|
+
if data is None and response.text:
|
|
89
|
+
data = response.text
|
|
90
|
+
|
|
91
|
+
error = create_api_error(
|
|
92
|
+
status=response.status_code,
|
|
93
|
+
data=data,
|
|
94
|
+
headers=response.headers,
|
|
95
|
+
rate_limit=self.rate_limit,
|
|
96
|
+
)
|
|
97
|
+
raise error
|
|
98
|
+
|
|
99
|
+
def _format_params(self, params: dict[str, Any] | None) -> dict[str, str] | None:
|
|
100
|
+
if not params:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
formatted: dict[str, str] = {}
|
|
104
|
+
for k, v in params.items():
|
|
105
|
+
if v is None:
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(v, (list, tuple, set)):
|
|
108
|
+
formatted[k] = ",".join(str(item) for item in v)
|
|
109
|
+
elif isinstance(v, bool):
|
|
110
|
+
formatted[k] = "true" if v else "false"
|
|
111
|
+
else:
|
|
112
|
+
formatted[k] = str(v)
|
|
113
|
+
return formatted
|
|
114
|
+
|
|
115
|
+
def request(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
params: dict[str, Any] | None = None,
|
|
121
|
+
json: Any = None,
|
|
122
|
+
data: Any = None,
|
|
123
|
+
files: Any = None,
|
|
124
|
+
headers: dict[str, str] | None = None,
|
|
125
|
+
idempotency_key: str | None = None,
|
|
126
|
+
timeout: float | None = None,
|
|
127
|
+
) -> httpx.Response:
|
|
128
|
+
"""Send an HTTP request with automatic retries and rate-limit tracking."""
|
|
129
|
+
if path.startswith(("http://", "https://")):
|
|
130
|
+
url = path
|
|
131
|
+
else:
|
|
132
|
+
clean_path = "/" + path.lstrip("/")
|
|
133
|
+
url = f"{self.base_url}{clean_path}"
|
|
134
|
+
|
|
135
|
+
req_headers = {
|
|
136
|
+
"Accept": "application/json",
|
|
137
|
+
"User-Agent": f"actos-python/{actos.__version__}",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if self.api_key:
|
|
141
|
+
req_headers["Authorization"] = f"Bearer {self.api_key}"
|
|
142
|
+
|
|
143
|
+
if idempotency_key:
|
|
144
|
+
req_headers["Idempotency-Key"] = idempotency_key
|
|
145
|
+
|
|
146
|
+
if headers:
|
|
147
|
+
req_headers.update(headers)
|
|
148
|
+
|
|
149
|
+
formatted_params = self._format_params(params)
|
|
150
|
+
timeout_val = timeout if timeout is not None else self.timeout
|
|
151
|
+
|
|
152
|
+
is_post = method.upper() == "POST"
|
|
153
|
+
has_idempotency_key = bool(
|
|
154
|
+
req_headers.get("Idempotency-Key") or req_headers.get("idempotency-key")
|
|
155
|
+
)
|
|
156
|
+
can_retry_5xx = (not is_post) or has_idempotency_key
|
|
157
|
+
|
|
158
|
+
for attempt in range(self.max_retries + 1):
|
|
159
|
+
try:
|
|
160
|
+
response = self._client.request(
|
|
161
|
+
method=method,
|
|
162
|
+
url=url,
|
|
163
|
+
params=formatted_params,
|
|
164
|
+
json=json,
|
|
165
|
+
data=data,
|
|
166
|
+
files=files,
|
|
167
|
+
headers=req_headers,
|
|
168
|
+
timeout=timeout_val,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
self._update_rate_limit(response.headers)
|
|
172
|
+
|
|
173
|
+
# 1. Successful response (< 400)
|
|
174
|
+
if response.status_code < 400:
|
|
175
|
+
return response
|
|
176
|
+
|
|
177
|
+
# 2. 429 Too Many Requests -> retry if attempt < max_retries
|
|
178
|
+
if response.status_code == 429 and attempt < self.max_retries:
|
|
179
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
180
|
+
delay = compute_backoff_delay(attempt, retry_after)
|
|
181
|
+
time.sleep(delay)
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# 3. 5xx Server Error -> retry if can_retry_5xx and attempt < max_retries
|
|
185
|
+
if (
|
|
186
|
+
500 <= response.status_code <= 599
|
|
187
|
+
and can_retry_5xx
|
|
188
|
+
and attempt < self.max_retries
|
|
189
|
+
):
|
|
190
|
+
delay = compute_backoff_delay(attempt)
|
|
191
|
+
time.sleep(delay)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# 4. Other 4xx or retries exhausted -> raise specific ActosAPIError subclass
|
|
195
|
+
self._raise_for_status(response)
|
|
196
|
+
|
|
197
|
+
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
|
198
|
+
# Network/Timeout error retry: safe requests or POST with idempotency key
|
|
199
|
+
if can_retry_5xx and attempt < self.max_retries:
|
|
200
|
+
delay = compute_backoff_delay(attempt)
|
|
201
|
+
time.sleep(delay)
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
205
|
+
raise APITimeoutError(f"Request timed out after {timeout_val}s") from exc
|
|
206
|
+
else:
|
|
207
|
+
raise APIConnectionError(f"Connection error: {exc}") from exc
|
|
208
|
+
|
|
209
|
+
# Should be unreachable given the loop raises on final attempt, but to satisfy typing:
|
|
210
|
+
raise APIConnectionError("Request failed: maximum retries exceeded")
|
|
211
|
+
|
|
212
|
+
def close(self) -> None:
|
|
213
|
+
"""Close the underlying HTTP client if created internally."""
|
|
214
|
+
if not self._custom_client:
|
|
215
|
+
self._client.close()
|
|
216
|
+
|
|
217
|
+
def __enter__(self) -> SyncTransport:
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
def __exit__(
|
|
221
|
+
self,
|
|
222
|
+
exc_type: type[BaseException] | None,
|
|
223
|
+
exc_val: BaseException | None,
|
|
224
|
+
exc_tb: TracebackType | None,
|
|
225
|
+
) -> None:
|
|
226
|
+
self.close()
|