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.
- onceonly_sdk-1.2.0/LICENSE +5 -0
- onceonly_sdk-1.2.0/PKG-INFO +153 -0
- onceonly_sdk-1.2.0/README.md +132 -0
- onceonly_sdk-1.2.0/onceonly/__init__.py +24 -0
- onceonly_sdk-1.2.0/onceonly/client.py +392 -0
- onceonly_sdk-1.2.0/onceonly/decorators.py +80 -0
- onceonly_sdk-1.2.0/onceonly/exceptions.py +42 -0
- onceonly_sdk-1.2.0/onceonly/models.py +16 -0
- onceonly_sdk-1.2.0/onceonly_sdk.egg-info/PKG-INFO +153 -0
- onceonly_sdk-1.2.0/onceonly_sdk.egg-info/SOURCES.txt +14 -0
- onceonly_sdk-1.2.0/onceonly_sdk.egg-info/dependency_links.txt +1 -0
- onceonly_sdk-1.2.0/onceonly_sdk.egg-info/requires.txt +4 -0
- onceonly_sdk-1.2.0/onceonly_sdk.egg-info/top_level.txt +1 -0
- onceonly_sdk-1.2.0/pyproject.toml +41 -0
- onceonly_sdk-1.2.0/setup.cfg +4 -0
- onceonly_sdk-1.2.0/tests/test_check_lock.py +127 -0
|
@@ -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
|
+
[](https://pypi.org/project/onceonly-sdk/)
|
|
33
|
+
[](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
|
+
[](https://pypi.org/project/onceonly-sdk/)
|
|
12
|
+
[](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
|
+
[](https://pypi.org/project/onceonly-sdk/)
|
|
33
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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,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)
|