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/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, Union, Mapping
4
+ from typing import Optional, Dict, Any
5
5
 
6
6
  import httpx
7
7
 
8
8
  from .models import CheckLockResult
9
- from .exceptions import (
10
- UnauthorizedError,
11
- OverLimitError,
12
- RateLimitError,
13
- ValidationError,
14
- ApiError,
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
- - connection pooling via httpx.Client / httpx.AsyncClient
25
- - optional fail-open for network/timeout/5xx
26
- - close/aclose + context managers
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 = "onceonly-python-sdk/1.0.0",
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, # IMPORTANT: None => server uses plan default TTL
70
- meta: Optional[Dict[str, Any]] = None,
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 = self._sync_client.post("/check-lock", json=payload, headers=headers)
81
- return self._parse_check_lock_response(
82
- resp,
83
- fallback_key=key,
84
- fallback_ttl=int(ttl or 0),
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[Dict[str, Any]] = None,
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 client.post("/check-lock", json=payload, headers=headers)
114
- return self._parse_check_lock_response(
115
- resp,
116
- fallback_key=key,
117
- fallback_ttl=int(ttl or 0),
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
- Get info about the current API key (plan, active status, period end, etc).
133
- """
134
- try:
135
- resp = self._sync_client.get("/me")
136
- return self._parse_json_or_raise(resp)
137
- except httpx.TimeoutException as e:
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 client.get("/me")
145
- return self._parse_json_or_raise(resp)
146
-
147
- def usage(self) -> Dict[str, Any]:
148
- """
149
- Get current usage counters and limits for this API key.
150
- """
151
- resp = self._sync_client.get("/usage")
152
- return self._parse_json_or_raise(resp)
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 client.get("/usage")
157
- return self._parse_json_or_raise(resp)
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
- if meta is not None:
204
- payload["meta"] = meta
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
- if meta is not None:
221
- raw["meta"] = meta
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[Dict[str, Any]] = None,
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(self._error_text(response, "Invalid API Key (Unauthorized)."))
274
+ raise UnauthorizedError(error_text(response, "Invalid API Key (Unauthorized)."))
245
275
 
246
276
  if response.status_code == 402:
247
- detail = self._try_extract_detail(response)
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
- raise RateLimitError(self._error_text(response, "Rate limit exceeded. Please slow down."))
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(self._error_text(response, f"Validation Error: {response.text}"))
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 = self._try_extract_detail(response)
295
+ d = try_extract_detail(response)
262
296
  if isinstance(d, dict):
263
297
  first_seen_at = d.get("first_seen_at")
264
- raw = {"detail": d} if d is not None else {}
265
- if fallback_meta is not None:
266
- raw["meta"] = fallback_meta
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
- d = self._try_extract_detail(response)
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
- try:
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
- if fallback_meta is not None and "meta" not in raw:
307
- raw["meta"] = fallback_meta
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 = "onceonly-python-sdk/1.0.0",
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
- from typing import Any, Callable, Optional, TypeVar, Union, Awaitable
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
- return repr(obj)
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
- def _generate_key(func: Callable[..., Any], args: tuple, kwargs: dict) -> str:
18
- # canonical payload
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
- "fn": f"{func.__module__}.{func.__qualname__}",
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
- raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
25
- return hashlib.sha256(raw.encode("utf-8")).hexdigest()
101
+ return _stable_hash(payload)
26
102
 
27
103
 
28
104
  def idempotent(
29
- client: "OnceOnly",
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
- Decorator for sync + async funcs.
38
-
39
- Behavior:
40
- - computes full_key = f"{key_prefix}:{key}"
41
- - calls OnceOnly check_lock
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[..., Any]):
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
- k = key_func(*args, **kwargs) if key_func else _generate_key(func, args, kwargs)
52
- return f"{key_prefix}:{k}"
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 awrapper(*args, **kwargs):
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 awrapper
157
+ return async_wrapper # type: ignore[return-value]
67
158
 
68
159
  @functools.wraps(func)
69
- def swrapper(*args, **kwargs):
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 swrapper
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 Free plan limit reached."""
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