onceonly-sdk 1.2.0__py3-none-any.whl → 2.0.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.
- onceonly/__init__.py +2 -2
- onceonly/_http.py +109 -0
- onceonly/_util.py +46 -0
- onceonly/ai.py +195 -0
- onceonly/ai_models.py +97 -0
- onceonly/client.py +134 -162
- onceonly/decorators.py +120 -26
- onceonly/exceptions.py +5 -1
- onceonly/integrations/__init__.py +1 -0
- onceonly/integrations/langchain.py +168 -0
- onceonly/models.py +11 -0
- onceonly/version.py +1 -0
- onceonly_sdk-2.0.0.dist-info/METADATA +140 -0
- onceonly_sdk-2.0.0.dist-info/RECORD +17 -0
- onceonly_sdk-1.2.0.dist-info/METADATA +0 -153
- onceonly_sdk-1.2.0.dist-info/RECORD +0 -10
- {onceonly_sdk-1.2.0.dist-info → onceonly_sdk-2.0.0.dist-info}/WHEEL +0 -0
- {onceonly_sdk-1.2.0.dist-info → onceonly_sdk-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {onceonly_sdk-1.2.0.dist-info → onceonly_sdk-2.0.0.dist-info}/top_level.txt +0 -0
onceonly/client.py
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import Optional, Dict, Any
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
5
|
|
|
6
6
|
import httpx
|
|
7
7
|
|
|
8
8
|
from .models import CheckLockResult
|
|
9
|
-
from .
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
from ._http import (
|
|
10
|
+
parse_json_or_raise,
|
|
11
|
+
try_extract_detail,
|
|
12
|
+
error_text,
|
|
13
|
+
request_with_retries_sync,
|
|
14
|
+
request_with_retries_async,
|
|
15
|
+
_parse_retry_after,
|
|
15
16
|
)
|
|
17
|
+
from .exceptions import ApiError, UnauthorizedError, OverLimitError, RateLimitError, ValidationError
|
|
18
|
+
from .ai import AiClient
|
|
19
|
+
from .version import __version__
|
|
20
|
+
from ._util import to_metadata_dict, MetadataLike
|
|
16
21
|
|
|
17
22
|
logger = logging.getLogger("onceonly")
|
|
18
23
|
|
|
@@ -21,9 +26,12 @@ class OnceOnly:
|
|
|
21
26
|
"""
|
|
22
27
|
OnceOnly API client (sync + async).
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
29
|
+
For automation/agents:
|
|
30
|
+
- check_lock(...) is the idempotency primitive (fast, safe).
|
|
31
|
+
- ai.run_and_wait(...) is for long-running backend tasks keyed by an idempotency key.
|
|
32
|
+
|
|
33
|
+
Rate-limit auto-retry (429):
|
|
34
|
+
- optional; controlled by max_retries_429 + backoff params.
|
|
27
35
|
"""
|
|
28
36
|
|
|
29
37
|
def __init__(
|
|
@@ -31,8 +39,12 @@ class OnceOnly:
|
|
|
31
39
|
api_key: str,
|
|
32
40
|
base_url: str = "https://api.onceonly.tech/v1",
|
|
33
41
|
timeout: float = 5.0,
|
|
34
|
-
user_agent: str =
|
|
42
|
+
user_agent: Optional[str] = None,
|
|
35
43
|
fail_open: bool = True,
|
|
44
|
+
*,
|
|
45
|
+
max_retries_429: int = 0,
|
|
46
|
+
retry_backoff: float = 0.5,
|
|
47
|
+
retry_max_backoff: float = 5.0,
|
|
36
48
|
sync_client: Optional[httpx.Client] = None,
|
|
37
49
|
async_client: Optional[httpx.AsyncClient] = None,
|
|
38
50
|
transport: Optional[httpx.BaseTransport] = None,
|
|
@@ -43,6 +55,13 @@ class OnceOnly:
|
|
|
43
55
|
self.timeout = timeout
|
|
44
56
|
self.fail_open = fail_open
|
|
45
57
|
|
|
58
|
+
self._max_retries_429 = int(max_retries_429)
|
|
59
|
+
self._retry_backoff = float(retry_backoff)
|
|
60
|
+
self._retry_max_backoff = float(retry_max_backoff)
|
|
61
|
+
|
|
62
|
+
if user_agent is None:
|
|
63
|
+
user_agent = f"onceonly-python-sdk/{__version__}"
|
|
64
|
+
|
|
46
65
|
self.headers = {
|
|
47
66
|
"Authorization": f"Bearer {api_key}",
|
|
48
67
|
"Content-Type": "application/json",
|
|
@@ -61,36 +80,41 @@ class OnceOnly:
|
|
|
61
80
|
self._async_client = async_client # lazy
|
|
62
81
|
self._async_transport = async_transport
|
|
63
82
|
|
|
83
|
+
self.ai = AiClient(
|
|
84
|
+
self._sync_client,
|
|
85
|
+
self._get_async_client,
|
|
86
|
+
max_retries_429=self._max_retries_429,
|
|
87
|
+
retry_backoff=self._retry_backoff,
|
|
88
|
+
retry_max_backoff=self._retry_max_backoff,
|
|
89
|
+
)
|
|
90
|
+
|
|
64
91
|
# ---------- Public API ----------
|
|
65
92
|
|
|
66
93
|
def check_lock(
|
|
67
94
|
self,
|
|
68
95
|
key: str,
|
|
69
|
-
ttl: Optional[int] = None,
|
|
70
|
-
meta: Optional[
|
|
96
|
+
ttl: Optional[int] = None,
|
|
97
|
+
meta: Optional[MetadataLike] = None,
|
|
71
98
|
request_id: Optional[str] = None,
|
|
72
99
|
) -> CheckLockResult:
|
|
73
100
|
payload = self._make_payload(key, ttl, meta)
|
|
74
|
-
|
|
75
|
-
headers = {}
|
|
101
|
+
headers: Dict[str, str] = {}
|
|
76
102
|
if request_id:
|
|
77
103
|
headers["X-Request-Id"] = request_id
|
|
78
104
|
|
|
79
105
|
try:
|
|
80
|
-
resp =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
fallback_meta=meta,
|
|
106
|
+
resp = request_with_retries_sync(
|
|
107
|
+
lambda: self._sync_client.post("/check-lock", json=payload, headers=headers),
|
|
108
|
+
max_retries=self._max_retries_429,
|
|
109
|
+
base_backoff=self._retry_backoff,
|
|
110
|
+
max_backoff=self._retry_max_backoff,
|
|
86
111
|
)
|
|
87
|
-
|
|
112
|
+
return self._parse_check_lock_response(resp, fallback_key=key, fallback_ttl=int(ttl or 0), fallback_meta=meta)
|
|
88
113
|
except httpx.TimeoutException as e:
|
|
89
114
|
return self._maybe_fail_open("timeout", e, key, int(ttl or 0), meta=meta)
|
|
90
115
|
except httpx.RequestError as e:
|
|
91
116
|
return self._maybe_fail_open("request_error", e, key, int(ttl or 0), meta=meta)
|
|
92
117
|
except ApiError as e:
|
|
93
|
-
# fail-open ONLY for 5xx
|
|
94
118
|
if e.status_code is not None and e.status_code >= 500:
|
|
95
119
|
return self._maybe_fail_open("api_5xx", e, key, int(ttl or 0), meta=meta)
|
|
96
120
|
raise
|
|
@@ -99,25 +123,23 @@ class OnceOnly:
|
|
|
99
123
|
self,
|
|
100
124
|
key: str,
|
|
101
125
|
ttl: Optional[int] = None,
|
|
102
|
-
meta: Optional[
|
|
126
|
+
meta: Optional[MetadataLike] = None,
|
|
103
127
|
request_id: Optional[str] = None,
|
|
104
128
|
) -> CheckLockResult:
|
|
105
129
|
payload = self._make_payload(key, ttl, meta)
|
|
106
|
-
|
|
107
|
-
headers = {}
|
|
130
|
+
headers: Dict[str, str] = {}
|
|
108
131
|
if request_id:
|
|
109
132
|
headers["X-Request-Id"] = request_id
|
|
110
133
|
|
|
111
134
|
client = await self._get_async_client()
|
|
112
135
|
try:
|
|
113
|
-
resp = await
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
fallback_meta=meta,
|
|
136
|
+
resp = await request_with_retries_async(
|
|
137
|
+
lambda: client.post("/check-lock", json=payload, headers=headers),
|
|
138
|
+
max_retries=self._max_retries_429,
|
|
139
|
+
base_backoff=self._retry_backoff,
|
|
140
|
+
max_backoff=self._retry_max_backoff,
|
|
119
141
|
)
|
|
120
|
-
|
|
142
|
+
return self._parse_check_lock_response(resp, fallback_key=key, fallback_ttl=int(ttl or 0), fallback_meta=meta)
|
|
121
143
|
except httpx.TimeoutException as e:
|
|
122
144
|
return self._maybe_fail_open("timeout", e, key, int(ttl or 0), meta=meta)
|
|
123
145
|
except httpx.RequestError as e:
|
|
@@ -127,34 +149,50 @@ class OnceOnly:
|
|
|
127
149
|
return self._maybe_fail_open("api_5xx", e, key, int(ttl or 0), meta=meta)
|
|
128
150
|
raise
|
|
129
151
|
|
|
152
|
+
# thin wrapper for agent UX
|
|
153
|
+
def ai_run_and_wait(self, key: str, **kwargs):
|
|
154
|
+
return self.ai.run_and_wait(key=key, **kwargs)
|
|
155
|
+
|
|
156
|
+
async def ai_run_and_wait_async(self, key: str, **kwargs):
|
|
157
|
+
return await self.ai.run_and_wait_async(key=key, **kwargs)
|
|
158
|
+
|
|
130
159
|
def me(self) -> Dict[str, Any]:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
raise ApiError("Timeout", status_code=None, detail={})
|
|
139
|
-
except httpx.RequestError as e:
|
|
140
|
-
raise ApiError(f"Request error: {e}", status_code=None, detail={})
|
|
160
|
+
resp = request_with_retries_sync(
|
|
161
|
+
lambda: self._sync_client.get("/me"),
|
|
162
|
+
max_retries=self._max_retries_429,
|
|
163
|
+
base_backoff=self._retry_backoff,
|
|
164
|
+
max_backoff=self._retry_max_backoff,
|
|
165
|
+
)
|
|
166
|
+
return parse_json_or_raise(resp)
|
|
141
167
|
|
|
142
168
|
async def me_async(self) -> Dict[str, Any]:
|
|
143
169
|
client = await self._get_async_client()
|
|
144
|
-
resp = await
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
resp = await request_with_retries_async(
|
|
171
|
+
lambda: client.get("/me"),
|
|
172
|
+
max_retries=self._max_retries_429,
|
|
173
|
+
base_backoff=self._retry_backoff,
|
|
174
|
+
max_backoff=self._retry_max_backoff,
|
|
175
|
+
)
|
|
176
|
+
return parse_json_or_raise(resp)
|
|
177
|
+
|
|
178
|
+
def usage(self, kind: str = "make") -> Dict[str, Any]:
|
|
179
|
+
resp = request_with_retries_sync(
|
|
180
|
+
lambda: self._sync_client.get("/usage", params={"kind": kind}),
|
|
181
|
+
max_retries=self._max_retries_429,
|
|
182
|
+
base_backoff=self._retry_backoff,
|
|
183
|
+
max_backoff=self._retry_max_backoff,
|
|
184
|
+
)
|
|
185
|
+
return parse_json_or_raise(resp)
|
|
153
186
|
|
|
154
|
-
async def usage_async(self) -> Dict[str, Any]:
|
|
187
|
+
async def usage_async(self, kind: str = "make") -> Dict[str, Any]:
|
|
155
188
|
client = await self._get_async_client()
|
|
156
|
-
resp = await
|
|
157
|
-
|
|
189
|
+
resp = await request_with_retries_async(
|
|
190
|
+
lambda: client.get("/usage", params={"kind": kind}),
|
|
191
|
+
max_retries=self._max_retries_429,
|
|
192
|
+
base_backoff=self._retry_backoff,
|
|
193
|
+
max_backoff=self._retry_max_backoff,
|
|
194
|
+
)
|
|
195
|
+
return parse_json_or_raise(resp)
|
|
158
196
|
|
|
159
197
|
def close(self) -> None:
|
|
160
198
|
if self._own_sync:
|
|
@@ -191,34 +229,25 @@ class OnceOnly:
|
|
|
191
229
|
self._own_async = True
|
|
192
230
|
return self._async_client
|
|
193
231
|
|
|
194
|
-
def _make_payload(
|
|
195
|
-
self,
|
|
196
|
-
key: str,
|
|
197
|
-
ttl: Optional[int],
|
|
198
|
-
meta: Optional[Dict[str, Any]],
|
|
199
|
-
) -> Dict[str, Any]:
|
|
232
|
+
def _make_payload(self, key: str, ttl: Optional[int], meta: Optional[MetadataLike]) -> Dict[str, Any]:
|
|
200
233
|
payload: Dict[str, Any] = {"key": key}
|
|
201
234
|
if ttl is not None:
|
|
202
235
|
payload["ttl"] = int(ttl)
|
|
203
|
-
|
|
204
|
-
|
|
236
|
+
md = to_metadata_dict(meta)
|
|
237
|
+
if md is not None:
|
|
238
|
+
payload["metadata"] = md
|
|
205
239
|
return payload
|
|
206
240
|
|
|
207
|
-
def _maybe_fail_open(
|
|
208
|
-
self,
|
|
209
|
-
reason: str,
|
|
210
|
-
err: Exception,
|
|
211
|
-
key: str,
|
|
212
|
-
ttl: int,
|
|
213
|
-
meta: Optional[Dict[str, Any]] = None,
|
|
214
|
-
) -> CheckLockResult:
|
|
241
|
+
def _maybe_fail_open(self, reason: str, err: Exception, key: str, ttl: int, meta: Optional[MetadataLike] = None) -> CheckLockResult:
|
|
215
242
|
if not self.fail_open:
|
|
216
243
|
raise
|
|
217
244
|
|
|
218
245
|
logger.warning("onceonly fail-open (%s): %s", reason, err)
|
|
219
|
-
raw = {"fail_open": True, "reason": reason}
|
|
220
|
-
|
|
221
|
-
|
|
246
|
+
raw: Dict[str, Any] = {"fail_open": True, "reason": reason}
|
|
247
|
+
md = to_metadata_dict(meta)
|
|
248
|
+
if md is not None:
|
|
249
|
+
raw["metadata"] = md
|
|
250
|
+
|
|
222
251
|
return CheckLockResult(
|
|
223
252
|
locked=True,
|
|
224
253
|
duplicate=False,
|
|
@@ -233,37 +262,45 @@ class OnceOnly:
|
|
|
233
262
|
def _parse_check_lock_response(
|
|
234
263
|
self,
|
|
235
264
|
response: httpx.Response,
|
|
265
|
+
*,
|
|
236
266
|
fallback_key: str,
|
|
237
267
|
fallback_ttl: int,
|
|
238
|
-
fallback_meta: Optional[
|
|
268
|
+
fallback_meta: Optional[MetadataLike] = None,
|
|
239
269
|
) -> CheckLockResult:
|
|
240
270
|
request_id = response.headers.get("X-Request-Id")
|
|
241
271
|
oo_status = (response.headers.get("X-OnceOnly-Status") or "").strip().lower()
|
|
242
272
|
|
|
243
273
|
if response.status_code in (401, 403):
|
|
244
|
-
raise UnauthorizedError(
|
|
274
|
+
raise UnauthorizedError(error_text(response, "Invalid API Key (Unauthorized)."))
|
|
245
275
|
|
|
246
276
|
if response.status_code == 402:
|
|
247
|
-
detail =
|
|
277
|
+
detail = try_extract_detail(response)
|
|
248
278
|
raise OverLimitError(
|
|
249
279
|
"Usage limit reached. Please upgrade your plan.",
|
|
250
280
|
detail=detail if isinstance(detail, dict) else {},
|
|
251
281
|
)
|
|
252
282
|
|
|
253
283
|
if response.status_code == 429:
|
|
254
|
-
|
|
284
|
+
retry_after = _parse_retry_after(response)
|
|
285
|
+
raise RateLimitError(
|
|
286
|
+
error_text(response, "Rate limit exceeded. Please slow down."),
|
|
287
|
+
retry_after_sec=retry_after,
|
|
288
|
+
)
|
|
255
289
|
|
|
256
290
|
if response.status_code == 422:
|
|
257
|
-
raise ValidationError(
|
|
291
|
+
raise ValidationError(error_text(response, f"Validation Error: {response.text}"))
|
|
258
292
|
|
|
259
293
|
if response.status_code == 409:
|
|
260
294
|
first_seen_at = None
|
|
261
|
-
d =
|
|
295
|
+
d = try_extract_detail(response)
|
|
262
296
|
if isinstance(d, dict):
|
|
263
297
|
first_seen_at = d.get("first_seen_at")
|
|
264
|
-
|
|
265
|
-
if
|
|
266
|
-
|
|
298
|
+
|
|
299
|
+
raw: Dict[str, Any] = {"detail": d} if d is not None else {}
|
|
300
|
+
md = to_metadata_dict(fallback_meta)
|
|
301
|
+
if md is not None:
|
|
302
|
+
raw["metadata"] = md
|
|
303
|
+
|
|
267
304
|
return CheckLockResult(
|
|
268
305
|
locked=False,
|
|
269
306
|
duplicate=True,
|
|
@@ -275,26 +312,11 @@ class OnceOnly:
|
|
|
275
312
|
raw=raw,
|
|
276
313
|
)
|
|
277
314
|
|
|
278
|
-
if 500 <= response.status_code <= 599:
|
|
279
|
-
d = self._try_extract_detail(response)
|
|
280
|
-
raise ApiError(
|
|
281
|
-
self._error_text(response, f"Server error ({response.status_code})"),
|
|
282
|
-
status_code=response.status_code,
|
|
283
|
-
detail=d if isinstance(d, dict) else {},
|
|
284
|
-
)
|
|
285
|
-
|
|
286
315
|
if response.status_code < 200 or response.status_code >= 300:
|
|
287
|
-
|
|
288
|
-
raise ApiError(
|
|
289
|
-
self._error_text(response, f"API Error ({response.status_code}): {response.text}"),
|
|
290
|
-
status_code=response.status_code,
|
|
291
|
-
detail=d if isinstance(d, dict) else {},
|
|
292
|
-
)
|
|
316
|
+
parse_json_or_raise(response) # raises typed ApiError/...
|
|
317
|
+
raise ApiError("Unexpected non-2xx response", status_code=response.status_code)
|
|
293
318
|
|
|
294
|
-
|
|
295
|
-
data = response.json()
|
|
296
|
-
except Exception:
|
|
297
|
-
data = {}
|
|
319
|
+
data = parse_json_or_raise(response)
|
|
298
320
|
|
|
299
321
|
status = str(data.get("status") or "").strip().lower()
|
|
300
322
|
success = data.get("success")
|
|
@@ -303,8 +325,9 @@ class OnceOnly:
|
|
|
303
325
|
duplicate = (oo_status == "duplicate") or (status == "duplicate") or (success is False)
|
|
304
326
|
|
|
305
327
|
raw = data if isinstance(data, dict) else {}
|
|
306
|
-
|
|
307
|
-
|
|
328
|
+
md = to_metadata_dict(fallback_meta)
|
|
329
|
+
if md is not None and "metadata" not in raw:
|
|
330
|
+
raw["metadata"] = md
|
|
308
331
|
|
|
309
332
|
return CheckLockResult(
|
|
310
333
|
locked=locked,
|
|
@@ -317,71 +340,17 @@ class OnceOnly:
|
|
|
317
340
|
raw=raw,
|
|
318
341
|
)
|
|
319
342
|
|
|
320
|
-
def _try_extract_detail(self, response: httpx.Response) -> Optional[Union[Dict[str, Any], str]]:
|
|
321
|
-
try:
|
|
322
|
-
j = response.json()
|
|
323
|
-
if isinstance(j, dict) and "detail" in j:
|
|
324
|
-
return j.get("detail")
|
|
325
|
-
return j
|
|
326
|
-
except Exception:
|
|
327
|
-
return None
|
|
328
|
-
|
|
329
|
-
def _error_text(self, response: httpx.Response, default: str) -> str:
|
|
330
|
-
d = self._try_extract_detail(response)
|
|
331
|
-
if isinstance(d, dict):
|
|
332
|
-
return d.get("error") or d.get("message") or default
|
|
333
|
-
if isinstance(d, str) and d.strip():
|
|
334
|
-
return d
|
|
335
|
-
return default
|
|
336
|
-
|
|
337
|
-
def _parse_json_or_raise(self, response: httpx.Response) -> Dict[str, Any]:
|
|
338
|
-
# auth / limits
|
|
339
|
-
if response.status_code in (401, 403):
|
|
340
|
-
raise UnauthorizedError(self._error_text(response, "Invalid API Key (Unauthorized)."))
|
|
341
|
-
|
|
342
|
-
if response.status_code == 402:
|
|
343
|
-
detail = self._try_extract_detail(response)
|
|
344
|
-
raise OverLimitError(
|
|
345
|
-
"Usage limit reached. Please upgrade your plan.",
|
|
346
|
-
detail=detail if isinstance(detail, dict) else {},
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
if response.status_code == 429:
|
|
350
|
-
raise RateLimitError(self._error_text(response, "Rate limit exceeded. Please slow down."))
|
|
351
|
-
|
|
352
|
-
if response.status_code == 422:
|
|
353
|
-
raise ValidationError(self._error_text(response, f"Validation Error: {response.text}"))
|
|
354
|
-
|
|
355
|
-
if 500 <= response.status_code <= 599:
|
|
356
|
-
d = self._try_extract_detail(response)
|
|
357
|
-
raise ApiError(
|
|
358
|
-
self._error_text(response, f"Server error ({response.status_code})"),
|
|
359
|
-
status_code=response.status_code,
|
|
360
|
-
detail=d if isinstance(d, dict) else {},
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
if response.status_code < 200 or response.status_code >= 300:
|
|
364
|
-
d = self._try_extract_detail(response)
|
|
365
|
-
raise ApiError(
|
|
366
|
-
self._error_text(response, f"API Error ({response.status_code}): {response.text}"),
|
|
367
|
-
status_code=response.status_code,
|
|
368
|
-
detail=d if isinstance(d, dict) else {},
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
try:
|
|
372
|
-
data = response.json()
|
|
373
|
-
except Exception:
|
|
374
|
-
data = {}
|
|
375
|
-
|
|
376
|
-
return data if isinstance(data, dict) else {"data": data}
|
|
377
|
-
|
|
378
343
|
|
|
379
344
|
def create_client(
|
|
380
345
|
api_key: str,
|
|
381
346
|
base_url: str = "https://api.onceonly.tech/v1",
|
|
382
347
|
timeout: float = 5.0,
|
|
383
|
-
user_agent: str =
|
|
348
|
+
user_agent: Optional[str] = None,
|
|
384
349
|
fail_open: bool = True,
|
|
350
|
+
*,
|
|
351
|
+
max_retries_429: int = 0,
|
|
352
|
+
retry_backoff: float = 0.5,
|
|
353
|
+
retry_max_backoff: float = 5.0,
|
|
385
354
|
) -> OnceOnly:
|
|
386
355
|
return OnceOnly(
|
|
387
356
|
api_key=api_key,
|
|
@@ -389,4 +358,7 @@ def create_client(
|
|
|
389
358
|
timeout=timeout,
|
|
390
359
|
user_agent=user_agent,
|
|
391
360
|
fail_open=fail_open,
|
|
361
|
+
max_retries_429=max_retries_429,
|
|
362
|
+
retry_backoff=retry_backoff,
|
|
363
|
+
retry_max_backoff=retry_max_backoff,
|
|
392
364
|
)
|
onceonly/decorators.py
CHANGED
|
@@ -1,80 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import functools
|
|
2
4
|
import inspect
|
|
3
5
|
import json
|
|
4
6
|
import hashlib
|
|
5
|
-
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Callable, Optional, TypeVar
|
|
9
|
+
|
|
10
|
+
from .client import OnceOnly
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("onceonly")
|
|
6
13
|
|
|
7
14
|
T = TypeVar("T")
|
|
8
15
|
|
|
9
16
|
|
|
17
|
+
def _truncate(s: str, max_len: int = 2048) -> str:
|
|
18
|
+
return s if len(s) <= max_len else (s[:max_len] + "…")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pydantic_to_json(obj: Any) -> Optional[str]:
|
|
22
|
+
# Pydantic v2
|
|
23
|
+
mdj = getattr(obj, "model_dump_json", None)
|
|
24
|
+
if callable(mdj):
|
|
25
|
+
try:
|
|
26
|
+
return mdj()
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
# Pydantic v1
|
|
31
|
+
j = getattr(obj, "json", None)
|
|
32
|
+
if callable(j):
|
|
33
|
+
try:
|
|
34
|
+
return j()
|
|
35
|
+
except Exception:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dataclass_to_json(obj: Any) -> Optional[str]:
|
|
42
|
+
# Avoid importing dataclasses unless needed
|
|
43
|
+
if not hasattr(obj, "__dataclass_fields__"):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
import dataclasses
|
|
47
|
+
|
|
48
|
+
d = dataclasses.asdict(obj)
|
|
49
|
+
return json.dumps(d, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
10
54
|
def _default_json(obj: Any) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Best-effort stable serialization for idempotency key generation.
|
|
57
|
+
|
|
58
|
+
Priority:
|
|
59
|
+
1) Pydantic models (v2/v1)
|
|
60
|
+
2) dataclasses
|
|
61
|
+
3) plain JSON types via json.dumps
|
|
62
|
+
4) fallback to str(obj) (NOT repr) to avoid memory addresses
|
|
63
|
+
"""
|
|
64
|
+
pj = _pydantic_to_json(obj)
|
|
65
|
+
if pj is not None:
|
|
66
|
+
return _truncate(pj)
|
|
67
|
+
|
|
68
|
+
dj = _dataclass_to_json(obj)
|
|
69
|
+
if dj is not None:
|
|
70
|
+
return _truncate(dj)
|
|
71
|
+
|
|
11
72
|
try:
|
|
12
|
-
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
73
|
+
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
|
13
74
|
except Exception:
|
|
14
|
-
|
|
75
|
+
try:
|
|
76
|
+
return _truncate(str(obj))
|
|
77
|
+
except Exception:
|
|
78
|
+
return _truncate("<unserializable>")
|
|
79
|
+
|
|
15
80
|
|
|
81
|
+
def _stable_hash(payload: Any) -> str:
|
|
82
|
+
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
83
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
16
84
|
|
|
17
|
-
|
|
18
|
-
|
|
85
|
+
|
|
86
|
+
def _generate_key(
|
|
87
|
+
func: Callable[..., Any],
|
|
88
|
+
args: tuple,
|
|
89
|
+
kwargs: dict,
|
|
90
|
+
*,
|
|
91
|
+
key_version: str,
|
|
92
|
+
key_id: Optional[str],
|
|
93
|
+
) -> str:
|
|
94
|
+
fn_id = key_id or f"{func.__module__}.{func.__qualname__}"
|
|
19
95
|
payload = {
|
|
20
|
-
"
|
|
96
|
+
"v": str(key_version),
|
|
97
|
+
"fn": fn_id,
|
|
21
98
|
"args": [_default_json(a) for a in args],
|
|
22
99
|
"kwargs": {k: _default_json(v) for k, v in sorted(kwargs.items())},
|
|
23
100
|
}
|
|
24
|
-
|
|
25
|
-
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
101
|
+
return _stable_hash(payload)
|
|
26
102
|
|
|
27
103
|
|
|
28
104
|
def idempotent(
|
|
29
|
-
client:
|
|
105
|
+
client: OnceOnly,
|
|
106
|
+
*,
|
|
30
107
|
key_prefix: str = "func",
|
|
31
108
|
ttl: int = 86400,
|
|
32
109
|
key_func: Optional[Callable[..., str]] = None,
|
|
110
|
+
key_version: str = "v1",
|
|
111
|
+
key_id: Optional[str] = None,
|
|
33
112
|
on_duplicate: Optional[Callable[..., Any]] = None,
|
|
34
113
|
return_value_on_duplicate: Any = None,
|
|
35
114
|
):
|
|
36
115
|
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
- calls
|
|
42
|
-
- if duplicate:
|
|
43
|
-
- if on_duplicate provided -> return on_duplicate(*args, **kwargs)
|
|
44
|
-
- else return return_value_on_duplicate
|
|
116
|
+
Idempotency decorator for sync + async functions.
|
|
117
|
+
|
|
118
|
+
- Computes deterministic idempotency key
|
|
119
|
+
- Calls OnceOnly.check_lock / check_lock_async
|
|
120
|
+
- If duplicate: calls on_duplicate or returns return_value_on_duplicate
|
|
45
121
|
"""
|
|
46
122
|
|
|
47
|
-
def decorator(func: Callable[...,
|
|
123
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T]:
|
|
48
124
|
is_async = inspect.iscoroutinefunction(func)
|
|
49
125
|
|
|
50
126
|
def make_full_key(args: tuple, kwargs: dict) -> str:
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
if key_func:
|
|
128
|
+
k = key_func(*args, **kwargs)
|
|
129
|
+
else:
|
|
130
|
+
k = _generate_key(func, args, kwargs, key_version=key_version, key_id=key_id)
|
|
131
|
+
full = f"{key_prefix}:{k}"
|
|
132
|
+
logger.debug(
|
|
133
|
+
"idempotent key=%s key_prefix=%s key_version=%s key_id=%s",
|
|
134
|
+
full,
|
|
135
|
+
key_prefix,
|
|
136
|
+
key_version,
|
|
137
|
+
key_id,
|
|
138
|
+
)
|
|
139
|
+
return full
|
|
53
140
|
|
|
54
141
|
if is_async:
|
|
142
|
+
|
|
55
143
|
@functools.wraps(func)
|
|
56
|
-
async def
|
|
144
|
+
async def async_wrapper(*args, **kwargs): # type: ignore[misc]
|
|
57
145
|
full_key = make_full_key(args, kwargs)
|
|
58
146
|
res = await client.check_lock_async(key=full_key, ttl=ttl)
|
|
147
|
+
logger.debug("idempotent async key=%s locked=%s duplicate=%s", full_key, res.locked, res.duplicate)
|
|
148
|
+
|
|
59
149
|
if res.duplicate:
|
|
60
150
|
if on_duplicate is not None:
|
|
61
151
|
v = on_duplicate(*args, **kwargs)
|
|
62
152
|
return await v if inspect.isawaitable(v) else v
|
|
63
|
-
return return_value_on_duplicate
|
|
153
|
+
return return_value_on_duplicate # type: ignore[return-value]
|
|
154
|
+
|
|
64
155
|
return await func(*args, **kwargs)
|
|
65
156
|
|
|
66
|
-
return
|
|
157
|
+
return async_wrapper # type: ignore[return-value]
|
|
67
158
|
|
|
68
159
|
@functools.wraps(func)
|
|
69
|
-
def
|
|
160
|
+
def sync_wrapper(*args, **kwargs): # type: ignore[misc]
|
|
70
161
|
full_key = make_full_key(args, kwargs)
|
|
71
162
|
res = client.check_lock(key=full_key, ttl=ttl)
|
|
163
|
+
logger.debug("idempotent sync key=%s locked=%s duplicate=%s", full_key, res.locked, res.duplicate)
|
|
164
|
+
|
|
72
165
|
if res.duplicate:
|
|
73
166
|
if on_duplicate is not None:
|
|
74
167
|
return on_duplicate(*args, **kwargs)
|
|
75
|
-
return return_value_on_duplicate
|
|
168
|
+
return return_value_on_duplicate # type: ignore[return-value]
|
|
169
|
+
|
|
76
170
|
return func(*args, **kwargs)
|
|
77
171
|
|
|
78
|
-
return
|
|
172
|
+
return sync_wrapper # type: ignore[return-value]
|
|
79
173
|
|
|
80
174
|
return decorator
|
onceonly/exceptions.py
CHANGED
|
@@ -12,7 +12,7 @@ class UnauthorizedError(OnceOnlyError):
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class OverLimitError(OnceOnlyError):
|
|
15
|
-
"""402
|
|
15
|
+
"""402 Plan usage limit reached."""
|
|
16
16
|
|
|
17
17
|
def __init__(self, message: str, detail: Optional[Dict[str, Any]] = None):
|
|
18
18
|
super().__init__(message)
|
|
@@ -22,6 +22,10 @@ class OverLimitError(OnceOnlyError):
|
|
|
22
22
|
class RateLimitError(OnceOnlyError):
|
|
23
23
|
"""429 Rate limit exceeded."""
|
|
24
24
|
|
|
25
|
+
def __init__(self, message: str, retry_after_sec: Optional[float] = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.retry_after_sec = retry_after_sec
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
class ValidationError(OnceOnlyError):
|
|
27
31
|
"""422 validation error."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# optional integrations live here
|