onceonly-sdk 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mykola Demianov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy...
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: onceonly-sdk
3
+ Version: 1.2.0
4
+ Summary: Python SDK for OnceOnly idempotency API
5
+ Author-email: OnceOnly <support@onceonly.tech>
6
+ License: MIT
7
+ Project-URL: Homepage, https://onceonly.tech/
8
+ Project-URL: Documentation, https://onceonly.tech/docs/
9
+ Project-URL: Repository, https://github.com/mykolademyanov/onceonly-python
10
+ Keywords: idempotency,automation,zapier,make,ai-agents
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.25
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7.0; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # OnceOnly Python SDK
23
+
24
+ **The Idempotency Layer for AI Agents, Webhooks, and Distributed Systems.**
25
+
26
+ OnceOnly is a high-performance Python SDK designed to ensure **exactly-once execution**.
27
+ It prevents duplicate actions (payments, emails, tool calls) in unstable environments like
28
+ AI agents, webhooks, or background workers.
29
+
30
+ Website - https://onceonly.tech/ai/
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/onceonly.svg)](https://pypi.org/project/onceonly-sdk/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+
35
+ ---
36
+
37
+ ## Features
38
+
39
+ - **Sync + Async Client** — built on httpx for modern Python stacks
40
+ - **Connection Pooling** — high performance under heavy load
41
+ - **Fail-Open Mode** — business logic keeps running even if API is unreachable
42
+ - **Smart Decorator** — automatic idempotency based on function arguments
43
+ - **Typed Results & Exceptions**
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install onceonly-sdk
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from onceonly import OnceOnly
59
+
60
+ client = OnceOnly(api_key="once_live_...")
61
+
62
+ result = client.check_lock(
63
+ key="order:123",
64
+ ttl=300, # 300 seconds = 5 minutes (clamped by your plan)
65
+ )
66
+
67
+ if result.duplicate:
68
+ print("Duplicate blocked")
69
+ else:
70
+ print("First execution")
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Async Usage
76
+
77
+ ```python
78
+ async def handler():
79
+ result = await client.check_lock_async("order:123")
80
+ if result.locked:
81
+ print("Locked")
82
+ ```
83
+
84
+ ---
85
+
86
+ ## TTL Behavior
87
+
88
+ - TTL is specified in seconds
89
+ - If ttl is not provided, the server applies the plan default TTL
90
+ - If ttl is provided, it is automatically clamped to your plan limits
91
+
92
+ ---
93
+
94
+ ## Metadata
95
+
96
+ You can optionally attach metadata to each check-lock call.
97
+ Metadata is useful for debugging, tracing, and server-side analytics.
98
+
99
+ Rules:
100
+ - JSON-serializable only
101
+ - Size-limited
102
+ - Safely logged on the server
103
+
104
+ ---
105
+
106
+ ## Decorator
107
+
108
+ The SDK provides an optional decorator that automatically generates
109
+ an idempotency key based on the **function name and arguments**.
110
+
111
+ This allows you to add exactly-once guarantees to existing code
112
+ with zero manual key management.
113
+
114
+ ```python
115
+ from onceonly.decorators import idempotent
116
+
117
+ @idempotent(client, ttl=3600)
118
+ def process_order(order_id):
119
+ ...
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Fail-Open Mode
125
+
126
+ Enabled by default.
127
+
128
+ If a network error, timeout, or server error (5xx) occurs, the SDK returns a locked result
129
+ instead of breaking your application.
130
+
131
+ Fail-open never triggers for:
132
+ - Authentication errors (401 / 403)
133
+ - Plan limits (402)
134
+ - Validation errors (422)
135
+ - Rate limits (429)
136
+
137
+ ---
138
+
139
+ ## Exceptions
140
+
141
+ | Exception | HTTP Status | Description |
142
+ |--------------------|------------|------------------------------------------|
143
+ | UnauthorizedError | 401 / 403 | Invalid or disabled API key |
144
+ | OverLimitError | 402 | Plan limit reached |
145
+ | RateLimitError | 429 | Too many requests |
146
+ | ValidationError | 422 | Invalid input |
147
+ | ApiError | 5xx / other| Server or unexpected API error |
148
+
149
+ ---
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,132 @@
1
+ # OnceOnly Python SDK
2
+
3
+ **The Idempotency Layer for AI Agents, Webhooks, and Distributed Systems.**
4
+
5
+ OnceOnly is a high-performance Python SDK designed to ensure **exactly-once execution**.
6
+ It prevents duplicate actions (payments, emails, tool calls) in unstable environments like
7
+ AI agents, webhooks, or background workers.
8
+
9
+ Website - https://onceonly.tech/ai/
10
+
11
+ [![PyPI version](https://img.shields.io/pypi/v/onceonly.svg)](https://pypi.org/project/onceonly-sdk/)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - **Sync + Async Client** — built on httpx for modern Python stacks
19
+ - **Connection Pooling** — high performance under heavy load
20
+ - **Fail-Open Mode** — business logic keeps running even if API is unreachable
21
+ - **Smart Decorator** — automatic idempotency based on function arguments
22
+ - **Typed Results & Exceptions**
23
+
24
+ ---
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ pip install onceonly-sdk
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Quick Start
35
+
36
+ ```python
37
+ from onceonly import OnceOnly
38
+
39
+ client = OnceOnly(api_key="once_live_...")
40
+
41
+ result = client.check_lock(
42
+ key="order:123",
43
+ ttl=300, # 300 seconds = 5 minutes (clamped by your plan)
44
+ )
45
+
46
+ if result.duplicate:
47
+ print("Duplicate blocked")
48
+ else:
49
+ print("First execution")
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Async Usage
55
+
56
+ ```python
57
+ async def handler():
58
+ result = await client.check_lock_async("order:123")
59
+ if result.locked:
60
+ print("Locked")
61
+ ```
62
+
63
+ ---
64
+
65
+ ## TTL Behavior
66
+
67
+ - TTL is specified in seconds
68
+ - If ttl is not provided, the server applies the plan default TTL
69
+ - If ttl is provided, it is automatically clamped to your plan limits
70
+
71
+ ---
72
+
73
+ ## Metadata
74
+
75
+ You can optionally attach metadata to each check-lock call.
76
+ Metadata is useful for debugging, tracing, and server-side analytics.
77
+
78
+ Rules:
79
+ - JSON-serializable only
80
+ - Size-limited
81
+ - Safely logged on the server
82
+
83
+ ---
84
+
85
+ ## Decorator
86
+
87
+ The SDK provides an optional decorator that automatically generates
88
+ an idempotency key based on the **function name and arguments**.
89
+
90
+ This allows you to add exactly-once guarantees to existing code
91
+ with zero manual key management.
92
+
93
+ ```python
94
+ from onceonly.decorators import idempotent
95
+
96
+ @idempotent(client, ttl=3600)
97
+ def process_order(order_id):
98
+ ...
99
+ ```
100
+
101
+ ---
102
+
103
+ ## Fail-Open Mode
104
+
105
+ Enabled by default.
106
+
107
+ If a network error, timeout, or server error (5xx) occurs, the SDK returns a locked result
108
+ instead of breaking your application.
109
+
110
+ Fail-open never triggers for:
111
+ - Authentication errors (401 / 403)
112
+ - Plan limits (402)
113
+ - Validation errors (422)
114
+ - Rate limits (429)
115
+
116
+ ---
117
+
118
+ ## Exceptions
119
+
120
+ | Exception | HTTP Status | Description |
121
+ |--------------------|------------|------------------------------------------|
122
+ | UnauthorizedError | 401 / 403 | Invalid or disabled API key |
123
+ | OverLimitError | 402 | Plan limit reached |
124
+ | RateLimitError | 429 | Too many requests |
125
+ | ValidationError | 422 | Invalid input |
126
+ | ApiError | 5xx / other| Server or unexpected API error |
127
+
128
+ ---
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,24 @@
1
+ from .client import OnceOnly, create_client
2
+ from .models import CheckLockResult
3
+ from .exceptions import (
4
+ OnceOnlyError,
5
+ UnauthorizedError,
6
+ OverLimitError,
7
+ RateLimitError,
8
+ ValidationError,
9
+ ApiError,
10
+ )
11
+
12
+ __version__ = "1.2.0"
13
+
14
+ __all__ = [
15
+ "OnceOnly",
16
+ "create_client",
17
+ "CheckLockResult",
18
+ "OnceOnlyError",
19
+ "UnauthorizedError",
20
+ "OverLimitError",
21
+ "RateLimitError",
22
+ "ValidationError",
23
+ "ApiError",
24
+ ]
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Optional, Dict, Any, Union, Mapping
5
+
6
+ import httpx
7
+
8
+ from .models import CheckLockResult
9
+ from .exceptions import (
10
+ UnauthorizedError,
11
+ OverLimitError,
12
+ RateLimitError,
13
+ ValidationError,
14
+ ApiError,
15
+ )
16
+
17
+ logger = logging.getLogger("onceonly")
18
+
19
+
20
+ class OnceOnly:
21
+ """
22
+ OnceOnly API client (sync + async).
23
+
24
+ - connection pooling via httpx.Client / httpx.AsyncClient
25
+ - optional fail-open for network/timeout/5xx
26
+ - close/aclose + context managers
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ api_key: str,
32
+ base_url: str = "https://api.onceonly.tech/v1",
33
+ timeout: float = 5.0,
34
+ user_agent: str = "onceonly-python-sdk/1.0.0",
35
+ fail_open: bool = True,
36
+ sync_client: Optional[httpx.Client] = None,
37
+ async_client: Optional[httpx.AsyncClient] = None,
38
+ transport: Optional[httpx.BaseTransport] = None,
39
+ async_transport: Optional[httpx.AsyncBaseTransport] = None,
40
+ ):
41
+ self.api_key = api_key
42
+ self.base_url = base_url.rstrip("/")
43
+ self.timeout = timeout
44
+ self.fail_open = fail_open
45
+
46
+ self.headers = {
47
+ "Authorization": f"Bearer {api_key}",
48
+ "Content-Type": "application/json",
49
+ "User-Agent": user_agent,
50
+ }
51
+
52
+ self._own_sync = sync_client is None
53
+ self._own_async = async_client is None
54
+
55
+ self._sync_client = sync_client or httpx.Client(
56
+ base_url=self.base_url,
57
+ headers=self.headers,
58
+ timeout=self.timeout,
59
+ transport=transport,
60
+ )
61
+ self._async_client = async_client # lazy
62
+ self._async_transport = async_transport
63
+
64
+ # ---------- Public API ----------
65
+
66
+ def check_lock(
67
+ self,
68
+ key: str,
69
+ ttl: Optional[int] = None, # IMPORTANT: None => server uses plan default TTL
70
+ meta: Optional[Dict[str, Any]] = None,
71
+ request_id: Optional[str] = None,
72
+ ) -> CheckLockResult:
73
+ payload = self._make_payload(key, ttl, meta)
74
+
75
+ headers = {}
76
+ if request_id:
77
+ headers["X-Request-Id"] = request_id
78
+
79
+ 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,
86
+ )
87
+
88
+ except httpx.TimeoutException as e:
89
+ return self._maybe_fail_open("timeout", e, key, int(ttl or 0), meta=meta)
90
+ except httpx.RequestError as e:
91
+ return self._maybe_fail_open("request_error", e, key, int(ttl or 0), meta=meta)
92
+ except ApiError as e:
93
+ # fail-open ONLY for 5xx
94
+ if e.status_code is not None and e.status_code >= 500:
95
+ return self._maybe_fail_open("api_5xx", e, key, int(ttl or 0), meta=meta)
96
+ raise
97
+
98
+ async def check_lock_async(
99
+ self,
100
+ key: str,
101
+ ttl: Optional[int] = None,
102
+ meta: Optional[Dict[str, Any]] = None,
103
+ request_id: Optional[str] = None,
104
+ ) -> CheckLockResult:
105
+ payload = self._make_payload(key, ttl, meta)
106
+
107
+ headers = {}
108
+ if request_id:
109
+ headers["X-Request-Id"] = request_id
110
+
111
+ client = await self._get_async_client()
112
+ 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,
119
+ )
120
+
121
+ except httpx.TimeoutException as e:
122
+ return self._maybe_fail_open("timeout", e, key, int(ttl or 0), meta=meta)
123
+ except httpx.RequestError as e:
124
+ return self._maybe_fail_open("request_error", e, key, int(ttl or 0), meta=meta)
125
+ except ApiError as e:
126
+ if e.status_code is not None and e.status_code >= 500:
127
+ return self._maybe_fail_open("api_5xx", e, key, int(ttl or 0), meta=meta)
128
+ raise
129
+
130
+ 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={})
141
+
142
+ async def me_async(self) -> Dict[str, Any]:
143
+ 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)
153
+
154
+ async def usage_async(self) -> Dict[str, Any]:
155
+ client = await self._get_async_client()
156
+ resp = await client.get("/usage")
157
+ return self._parse_json_or_raise(resp)
158
+
159
+ def close(self) -> None:
160
+ if self._own_sync:
161
+ self._sync_client.close()
162
+
163
+ async def aclose(self) -> None:
164
+ if self._own_async and self._async_client is not None:
165
+ await self._async_client.aclose()
166
+ self._async_client = None
167
+
168
+ def __enter__(self) -> "OnceOnly":
169
+ return self
170
+
171
+ def __exit__(self, exc_type, exc, tb) -> None:
172
+ self.close()
173
+
174
+ async def __aenter__(self) -> "OnceOnly":
175
+ await self._get_async_client()
176
+ return self
177
+
178
+ async def __aexit__(self, exc_type, exc, tb) -> None:
179
+ await self.aclose()
180
+
181
+ # ---------- Internal ----------
182
+
183
+ async def _get_async_client(self) -> httpx.AsyncClient:
184
+ if self._async_client is None:
185
+ self._async_client = httpx.AsyncClient(
186
+ base_url=self.base_url,
187
+ headers=self.headers,
188
+ timeout=self.timeout,
189
+ transport=self._async_transport,
190
+ )
191
+ self._own_async = True
192
+ return self._async_client
193
+
194
+ def _make_payload(
195
+ self,
196
+ key: str,
197
+ ttl: Optional[int],
198
+ meta: Optional[Dict[str, Any]],
199
+ ) -> Dict[str, Any]:
200
+ payload: Dict[str, Any] = {"key": key}
201
+ if ttl is not None:
202
+ payload["ttl"] = int(ttl)
203
+ if meta is not None:
204
+ payload["meta"] = meta
205
+ return payload
206
+
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:
215
+ if not self.fail_open:
216
+ raise
217
+
218
+ 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
222
+ return CheckLockResult(
223
+ locked=True,
224
+ duplicate=False,
225
+ key=key,
226
+ ttl=ttl,
227
+ first_seen_at=None,
228
+ request_id="fail-open",
229
+ status_code=0,
230
+ raw=raw,
231
+ )
232
+
233
+ def _parse_check_lock_response(
234
+ self,
235
+ response: httpx.Response,
236
+ fallback_key: str,
237
+ fallback_ttl: int,
238
+ fallback_meta: Optional[Dict[str, Any]] = None,
239
+ ) -> CheckLockResult:
240
+ request_id = response.headers.get("X-Request-Id")
241
+ oo_status = (response.headers.get("X-OnceOnly-Status") or "").strip().lower()
242
+
243
+ if response.status_code in (401, 403):
244
+ raise UnauthorizedError(self._error_text(response, "Invalid API Key (Unauthorized)."))
245
+
246
+ if response.status_code == 402:
247
+ detail = self._try_extract_detail(response)
248
+ raise OverLimitError(
249
+ "Usage limit reached. Please upgrade your plan.",
250
+ detail=detail if isinstance(detail, dict) else {},
251
+ )
252
+
253
+ if response.status_code == 429:
254
+ raise RateLimitError(self._error_text(response, "Rate limit exceeded. Please slow down."))
255
+
256
+ if response.status_code == 422:
257
+ raise ValidationError(self._error_text(response, f"Validation Error: {response.text}"))
258
+
259
+ if response.status_code == 409:
260
+ first_seen_at = None
261
+ d = self._try_extract_detail(response)
262
+ if isinstance(d, dict):
263
+ 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
267
+ return CheckLockResult(
268
+ locked=False,
269
+ duplicate=True,
270
+ key=fallback_key,
271
+ ttl=fallback_ttl,
272
+ first_seen_at=first_seen_at,
273
+ request_id=request_id,
274
+ status_code=response.status_code,
275
+ raw=raw,
276
+ )
277
+
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
+ 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
+ )
293
+
294
+ try:
295
+ data = response.json()
296
+ except Exception:
297
+ data = {}
298
+
299
+ status = str(data.get("status") or "").strip().lower()
300
+ success = data.get("success")
301
+
302
+ locked = (oo_status == "locked") or (status == "locked") or (success is True)
303
+ duplicate = (oo_status == "duplicate") or (status == "duplicate") or (success is False)
304
+
305
+ raw = data if isinstance(data, dict) else {}
306
+ if fallback_meta is not None and "meta" not in raw:
307
+ raw["meta"] = fallback_meta
308
+
309
+ return CheckLockResult(
310
+ locked=locked,
311
+ duplicate=duplicate,
312
+ key=str(data.get("key") or fallback_key),
313
+ ttl=int(data.get("ttl") or fallback_ttl),
314
+ first_seen_at=data.get("first_seen_at"),
315
+ request_id=request_id,
316
+ status_code=response.status_code,
317
+ raw=raw,
318
+ )
319
+
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
+
379
+ def create_client(
380
+ api_key: str,
381
+ base_url: str = "https://api.onceonly.tech/v1",
382
+ timeout: float = 5.0,
383
+ user_agent: str = "onceonly-python-sdk/1.0.0",
384
+ fail_open: bool = True,
385
+ ) -> OnceOnly:
386
+ return OnceOnly(
387
+ api_key=api_key,
388
+ base_url=base_url,
389
+ timeout=timeout,
390
+ user_agent=user_agent,
391
+ fail_open=fail_open,
392
+ )
@@ -0,0 +1,80 @@
1
+ import functools
2
+ import inspect
3
+ import json
4
+ import hashlib
5
+ from typing import Any, Callable, Optional, TypeVar, Union, Awaitable
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ def _default_json(obj: Any) -> str:
11
+ try:
12
+ return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
13
+ except Exception:
14
+ return repr(obj)
15
+
16
+
17
+ def _generate_key(func: Callable[..., Any], args: tuple, kwargs: dict) -> str:
18
+ # canonical payload
19
+ payload = {
20
+ "fn": f"{func.__module__}.{func.__qualname__}",
21
+ "args": [_default_json(a) for a in args],
22
+ "kwargs": {k: _default_json(v) for k, v in sorted(kwargs.items())},
23
+ }
24
+ raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
25
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
26
+
27
+
28
+ def idempotent(
29
+ client: "OnceOnly",
30
+ key_prefix: str = "func",
31
+ ttl: int = 86400,
32
+ key_func: Optional[Callable[..., str]] = None,
33
+ on_duplicate: Optional[Callable[..., Any]] = None,
34
+ return_value_on_duplicate: Any = None,
35
+ ):
36
+ """
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
45
+ """
46
+
47
+ def decorator(func: Callable[..., Any]):
48
+ is_async = inspect.iscoroutinefunction(func)
49
+
50
+ 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}"
53
+
54
+ if is_async:
55
+ @functools.wraps(func)
56
+ async def awrapper(*args, **kwargs):
57
+ full_key = make_full_key(args, kwargs)
58
+ res = await client.check_lock_async(key=full_key, ttl=ttl)
59
+ if res.duplicate:
60
+ if on_duplicate is not None:
61
+ v = on_duplicate(*args, **kwargs)
62
+ return await v if inspect.isawaitable(v) else v
63
+ return return_value_on_duplicate
64
+ return await func(*args, **kwargs)
65
+
66
+ return awrapper
67
+
68
+ @functools.wraps(func)
69
+ def swrapper(*args, **kwargs):
70
+ full_key = make_full_key(args, kwargs)
71
+ res = client.check_lock(key=full_key, ttl=ttl)
72
+ if res.duplicate:
73
+ if on_duplicate is not None:
74
+ return on_duplicate(*args, **kwargs)
75
+ return return_value_on_duplicate
76
+ return func(*args, **kwargs)
77
+
78
+ return swrapper
79
+
80
+ return decorator
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class OnceOnlyError(Exception):
7
+ """Base exception class for OnceOnly SDK."""
8
+
9
+
10
+ class UnauthorizedError(OnceOnlyError):
11
+ """401/403 Invalid or disabled API key."""
12
+
13
+
14
+ class OverLimitError(OnceOnlyError):
15
+ """402 Free plan limit reached."""
16
+
17
+ def __init__(self, message: str, detail: Optional[Dict[str, Any]] = None):
18
+ super().__init__(message)
19
+ self.detail = detail or {}
20
+
21
+
22
+ class RateLimitError(OnceOnlyError):
23
+ """429 Rate limit exceeded."""
24
+
25
+
26
+ class ValidationError(OnceOnlyError):
27
+ """422 validation error."""
28
+
29
+
30
+ class ApiError(OnceOnlyError):
31
+ """Non-2xx API errors (except those mapped to typed errors)."""
32
+
33
+ def __init__(
34
+ self,
35
+ message: str,
36
+ *,
37
+ status_code: Optional[int] = None,
38
+ detail: Optional[Dict[str, Any]] = None,
39
+ ):
40
+ super().__init__(message)
41
+ self.status_code = status_code
42
+ self.detail = detail or {}
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class CheckLockResult:
9
+ locked: bool
10
+ duplicate: bool
11
+ key: str
12
+ ttl: int
13
+ first_seen_at: Optional[str]
14
+ request_id: Optional[str]
15
+ status_code: int
16
+ raw: Dict[str, Any]
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: onceonly-sdk
3
+ Version: 1.2.0
4
+ Summary: Python SDK for OnceOnly idempotency API
5
+ Author-email: OnceOnly <support@onceonly.tech>
6
+ License: MIT
7
+ Project-URL: Homepage, https://onceonly.tech/
8
+ Project-URL: Documentation, https://onceonly.tech/docs/
9
+ Project-URL: Repository, https://github.com/mykolademyanov/onceonly-python
10
+ Keywords: idempotency,automation,zapier,make,ai-agents
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.25
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7.0; extra == "test"
20
+ Dynamic: license-file
21
+
22
+ # OnceOnly Python SDK
23
+
24
+ **The Idempotency Layer for AI Agents, Webhooks, and Distributed Systems.**
25
+
26
+ OnceOnly is a high-performance Python SDK designed to ensure **exactly-once execution**.
27
+ It prevents duplicate actions (payments, emails, tool calls) in unstable environments like
28
+ AI agents, webhooks, or background workers.
29
+
30
+ Website - https://onceonly.tech/ai/
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/onceonly.svg)](https://pypi.org/project/onceonly-sdk/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+
35
+ ---
36
+
37
+ ## Features
38
+
39
+ - **Sync + Async Client** — built on httpx for modern Python stacks
40
+ - **Connection Pooling** — high performance under heavy load
41
+ - **Fail-Open Mode** — business logic keeps running even if API is unreachable
42
+ - **Smart Decorator** — automatic idempotency based on function arguments
43
+ - **Typed Results & Exceptions**
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install onceonly-sdk
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from onceonly import OnceOnly
59
+
60
+ client = OnceOnly(api_key="once_live_...")
61
+
62
+ result = client.check_lock(
63
+ key="order:123",
64
+ ttl=300, # 300 seconds = 5 minutes (clamped by your plan)
65
+ )
66
+
67
+ if result.duplicate:
68
+ print("Duplicate blocked")
69
+ else:
70
+ print("First execution")
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Async Usage
76
+
77
+ ```python
78
+ async def handler():
79
+ result = await client.check_lock_async("order:123")
80
+ if result.locked:
81
+ print("Locked")
82
+ ```
83
+
84
+ ---
85
+
86
+ ## TTL Behavior
87
+
88
+ - TTL is specified in seconds
89
+ - If ttl is not provided, the server applies the plan default TTL
90
+ - If ttl is provided, it is automatically clamped to your plan limits
91
+
92
+ ---
93
+
94
+ ## Metadata
95
+
96
+ You can optionally attach metadata to each check-lock call.
97
+ Metadata is useful for debugging, tracing, and server-side analytics.
98
+
99
+ Rules:
100
+ - JSON-serializable only
101
+ - Size-limited
102
+ - Safely logged on the server
103
+
104
+ ---
105
+
106
+ ## Decorator
107
+
108
+ The SDK provides an optional decorator that automatically generates
109
+ an idempotency key based on the **function name and arguments**.
110
+
111
+ This allows you to add exactly-once guarantees to existing code
112
+ with zero manual key management.
113
+
114
+ ```python
115
+ from onceonly.decorators import idempotent
116
+
117
+ @idempotent(client, ttl=3600)
118
+ def process_order(order_id):
119
+ ...
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Fail-Open Mode
125
+
126
+ Enabled by default.
127
+
128
+ If a network error, timeout, or server error (5xx) occurs, the SDK returns a locked result
129
+ instead of breaking your application.
130
+
131
+ Fail-open never triggers for:
132
+ - Authentication errors (401 / 403)
133
+ - Plan limits (402)
134
+ - Validation errors (422)
135
+ - Rate limits (429)
136
+
137
+ ---
138
+
139
+ ## Exceptions
140
+
141
+ | Exception | HTTP Status | Description |
142
+ |--------------------|------------|------------------------------------------|
143
+ | UnauthorizedError | 401 / 403 | Invalid or disabled API key |
144
+ | OverLimitError | 402 | Plan limit reached |
145
+ | RateLimitError | 429 | Too many requests |
146
+ | ValidationError | 422 | Invalid input |
147
+ | ApiError | 5xx / other| Server or unexpected API error |
148
+
149
+ ---
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ onceonly/__init__.py
5
+ onceonly/client.py
6
+ onceonly/decorators.py
7
+ onceonly/exceptions.py
8
+ onceonly/models.py
9
+ onceonly_sdk.egg-info/PKG-INFO
10
+ onceonly_sdk.egg-info/SOURCES.txt
11
+ onceonly_sdk.egg-info/dependency_links.txt
12
+ onceonly_sdk.egg-info/requires.txt
13
+ onceonly_sdk.egg-info/top_level.txt
14
+ tests/test_check_lock.py
@@ -0,0 +1,4 @@
1
+ httpx>=0.25
2
+
3
+ [test]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ onceonly
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "onceonly-sdk"
7
+ version = "1.2.0"
8
+ description = "Python SDK for OnceOnly idempotency API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "OnceOnly", email = "support@onceonly.tech" }]
13
+
14
+ dependencies = [
15
+ "httpx>=0.25"
16
+ ]
17
+
18
+ keywords = [
19
+ "idempotency",
20
+ "automation",
21
+ "zapier",
22
+ "make",
23
+ "ai-agents"
24
+ ]
25
+
26
+ classifiers = [
27
+ "Programming Language :: Python :: 3",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Operating System :: OS Independent"
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ test = ["pytest>=7.0"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://onceonly.tech/"
37
+ Documentation = "https://onceonly.tech/docs/"
38
+ Repository = "https://github.com/mykolademyanov/onceonly-python"
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,127 @@
1
+ import pytest
2
+ import httpx
3
+
4
+ from onceonly.client import OnceOnly
5
+ from onceonly.exceptions import (
6
+ OverLimitError,
7
+ RateLimitError,
8
+ UnauthorizedError,
9
+ ValidationError,
10
+ ApiError,
11
+ )
12
+
13
+
14
+ def mk_response(status_code: int, json_data=None, headers=None, text=None):
15
+ req = httpx.Request("POST", "https://api.onceonly.tech/v1/check-lock")
16
+
17
+ if json_data is not None:
18
+ return httpx.Response(
19
+ status_code=status_code,
20
+ json=json_data,
21
+ headers=headers or {},
22
+ request=req,
23
+ )
24
+
25
+ return httpx.Response(
26
+ status_code=status_code,
27
+ text=text or "",
28
+ headers=headers or {},
29
+ request=req,
30
+ )
31
+
32
+
33
+ def test_200_locked_header():
34
+ c = OnceOnly("k", base_url="https://api.onceonly.tech/v1")
35
+ resp = mk_response(
36
+ 200,
37
+ json_data={"success": True, "status": "locked", "key": "a", "ttl": 60},
38
+ headers={"X-OnceOnly-Status": "locked", "X-Request-Id": "rid1"},
39
+ )
40
+ r = c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
41
+ assert r.locked is True
42
+ assert r.duplicate is False
43
+ assert r.request_id == "rid1"
44
+
45
+
46
+ def test_200_duplicate_json():
47
+ c = OnceOnly("k")
48
+ resp = mk_response(
49
+ 200,
50
+ json_data={
51
+ "success": False,
52
+ "status": "duplicate",
53
+ "key": "a",
54
+ "ttl": 60,
55
+ "first_seen_at": "2026-01-06T10:00:00Z",
56
+ },
57
+ headers={"X-OnceOnly-Status": "duplicate", "X-Request-Id": "rid2"},
58
+ )
59
+ r = c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
60
+ assert r.locked is False
61
+ assert r.duplicate is True
62
+ assert r.first_seen_at == "2026-01-06T10:00:00Z"
63
+
64
+
65
+ def test_409_duplicate_conflict_mode():
66
+ c = OnceOnly("k")
67
+ resp = mk_response(
68
+ 409,
69
+ json_data={"detail": {"error": "Duplicate blocked", "first_seen_at": "2026-01-06T10:00:00Z"}},
70
+ headers={"X-Request-Id": "rid3"},
71
+ )
72
+ r = c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
73
+ assert r.duplicate is True
74
+ assert r.locked is False
75
+ assert r.first_seen_at == "2026-01-06T10:00:00Z"
76
+ assert r.request_id == "rid3"
77
+
78
+
79
+ def test_402_over_limit():
80
+ c = OnceOnly("k")
81
+ resp = mk_response(
82
+ 402,
83
+ json_data={"detail": {"error": "Free plan limit reached", "plan": "free", "limit": 1000, "usage": 1001}},
84
+ )
85
+ with pytest.raises(OverLimitError) as e:
86
+ c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
87
+
88
+ # message
89
+ assert "upgrade" in str(e.value).lower() or "limit" in str(e.value).lower()
90
+ # detail dict preserved
91
+ assert isinstance(e.value.detail, dict)
92
+ assert e.value.detail.get("plan") == "free"
93
+
94
+
95
+ def test_429_rate_limit():
96
+ c = OnceOnly("k")
97
+ resp = mk_response(429, json_data={"detail": "Rate limit exceeded"})
98
+ with pytest.raises(RateLimitError):
99
+ c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
100
+
101
+
102
+ def test_401_unauthorized():
103
+ c = OnceOnly("k")
104
+ resp = mk_response(401, json_data={"detail": "Missing Authorization Bearer token"})
105
+ with pytest.raises(UnauthorizedError):
106
+ c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
107
+
108
+
109
+ def test_403_unauthorized():
110
+ c = OnceOnly("k")
111
+ resp = mk_response(403, json_data={"detail": "Invalid API key"})
112
+ with pytest.raises(UnauthorizedError):
113
+ c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
114
+
115
+
116
+ def test_422_validation():
117
+ c = OnceOnly("k")
118
+ resp = mk_response(422, json_data={"detail": "Validation Error"})
119
+ with pytest.raises(ValidationError):
120
+ c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)
121
+
122
+
123
+ def test_other_api_error():
124
+ c = OnceOnly("k")
125
+ resp = mk_response(500, json_data={"detail": "boom"})
126
+ with pytest.raises(ApiError):
127
+ c._parse_check_lock_response(resp, fallback_key="a", fallback_ttl=60)