onceonly-sdk 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
onceonly/__init__.py ADDED
@@ -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
+ ]
onceonly/client.py ADDED
@@ -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
+ )
onceonly/decorators.py ADDED
@@ -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
onceonly/exceptions.py ADDED
@@ -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 {}
onceonly/models.py ADDED
@@ -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,10 @@
1
+ onceonly/__init__.py,sha256=_SwJ4Y2345WJKfpW2IsOVfgNaUzjZqTSSOnPQBroW-M,452
2
+ onceonly/client.py,sha256=1RMTkGDpalHfx-xEH8DZsn9ZJ3VeGomG6PKAQQjPdZQ,13361
3
+ onceonly/decorators.py,sha256=9eBhRUQDBGTUXVyRYlpYy77y-EHF-udX3ERkrfIt9Kg,2651
4
+ onceonly/exceptions.py,sha256=RP556LUtO54TPFTZybF4dVA9n3TByFn35esz7EbcrpY,994
5
+ onceonly/models.py,sha256=xhPfP1kpyHYfKJNiac2REeXTpPLzrEWJeqn-p6B6taQ,329
6
+ onceonly_sdk-1.2.0.dist-info/licenses/LICENSE,sha256=YQQ8IT_P7hcGmmLFFuOy3eKDZ90e1cqef_okg85oAiQ,129
7
+ onceonly_sdk-1.2.0.dist-info/METADATA,sha256=dvml-Mhrd_n0xFEhk7-rvShxiYfaLcl_Pq2vyGD0XRo,3891
8
+ onceonly_sdk-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ onceonly_sdk-1.2.0.dist-info/top_level.txt,sha256=lvz-sHerZcTwlZW-uYoda_wgx62kY07GdtzIdw89hnU,9
10
+ onceonly_sdk-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ onceonly