onceonly-sdk 2.0.2__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1031 @@
1
+ Metadata-Version: 2.4
2
+ Name: onceonly-sdk
3
+ Version: 3.0.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://docs.onceonly.tech
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
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
21
+ Requires-Dist: anyio>=4.0; extra == "test"
22
+ Provides-Extra: langchain
23
+ Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
24
+ Dynamic: license-file
25
+
26
+ # OnceOnly Python SDK
27
+
28
+ <div align="center">
29
+
30
+ **AI Agent Execution & Governance Layer**
31
+
32
+ Exactly-once execution + runtime safety + agent control plane.
33
+
34
+ [![PyPI version](https://img.shields.io/pypi/v/onceonly-sdk.svg)](https://pypi.org/project/onceonly-sdk/)
35
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
+
38
+ [Website](https://onceonly.tech/) • [Docs](https://docs.onceonly.tech) • [API Reference](https://docs.onceonly.tech/api) • [Examples](./examples/)
39
+
40
+ </div>
41
+
42
+ ---
43
+
44
+ ## 🎯 What is OnceOnly?
45
+
46
+ **The problem:** AI agents are non-deterministic. They retry failed calls, re-run tools, crash mid-execution, and replay events. This causes duplicate payments, repeated emails, and inconsistent state.
47
+
48
+ **The solution:** OnceOnly sits between your AI and the real world, guaranteeing:
49
+
50
+ ✅ **Exactly-once execution** - Same input = same result, always
51
+ ✅ **Crash safety** - Worker dies? Pick up where you left off
52
+ ✅ **Retry safety** - Agent retries? We deduplicate automatically
53
+ ✅ **Budget control** - Cap spending per agent/hour/day
54
+ ✅ **Permission enforcement** - Whitelist/blacklist tools
55
+ ✅ **Kill switch** - Disable rogue agents instantly
56
+ ✅ **Forensic audit** - Complete action history
57
+
58
+ This isn't just idempotency. **This is an AI Agent Control Plane.**
59
+
60
+ ---
61
+
62
+ ## ⚡ Quick Start (30 seconds)
63
+
64
+ ```bash
65
+ pip install onceonly-sdk
66
+ ```
67
+
68
+ ```python
69
+ from onceonly import OnceOnly
70
+
71
+ client = OnceOnly(api_key="once_live_...")
72
+
73
+ # Prevent duplicate webhook processing
74
+ result = client.check_lock(key="webhook:stripe:evt_123", ttl=3600)
75
+
76
+ if result.duplicate:
77
+ return {"status": "already_processed"}
78
+
79
+ # Process webhook (runs exactly once)
80
+ process_payment(webhook_data)
81
+ ```
82
+
83
+ **That's it.** You just made your webhook idempotent.
84
+
85
+ ---
86
+
87
+ ## 🚀 5-Minute Tutorial
88
+
89
+ ### 1️⃣ Basic Deduplication (Webhooks, Cron Jobs, Workers)
90
+
91
+ ```python
92
+ from onceonly import OnceOnly
93
+
94
+ client = OnceOnly(api_key="once_live_...")
95
+
96
+ # Stripe webhook
97
+ @app.post("/webhooks/stripe")
98
+ def stripe_webhook(event_id: str):
99
+ result = client.check_lock(
100
+ key=f"stripe:{event_id}",
101
+ ttl=7200 # 2 hours
102
+ )
103
+
104
+ if result.duplicate:
105
+ return {"status": "ok"} # Already processed
106
+
107
+ # Process event (guaranteed exactly-once)
108
+ handle_payment_succeeded(event_id)
109
+ return {"status": "processed"}
110
+ ```
111
+
112
+ ### 2️⃣ AI Agent with Budget & Permissions
113
+
114
+ ```python
115
+ from onceonly import OnceOnly
116
+
117
+ client = OnceOnly(api_key="once_live_...")
118
+
119
+ # Set policy (one-time setup)
120
+ client.gov.upsert_policy({
121
+ "agent_id": "billing-agent",
122
+ "max_actions_per_hour": 200,
123
+ "max_spend_usd_per_day": 50.0,
124
+ "allowed_tools": ["stripe.charge", "send_email"],
125
+ "blocked_tools": ["delete_user"]
126
+ })
127
+
128
+ # Execute tool with enforcement
129
+ result = client.ai.run_tool(
130
+ agent_id="billing-agent",
131
+ tool="stripe.charge",
132
+ args={"amount": 9999, "currency": "usd"},
133
+ spend_usd=0.5, # Track API cost
134
+ )
135
+
136
+ if result.allowed:
137
+ print(f"Charged: {result.result}")
138
+ elif result.decision == "blocked":
139
+ print(f"Agent blocked: {result.policy_reason}")
140
+ ```
141
+
142
+ ### 3️⃣ Exactly-Once Function Execution
143
+
144
+ ```python
145
+ from onceonly import OnceOnly, idempotent_ai
146
+
147
+ client = OnceOnly(api_key="once_live_...")
148
+
149
+ @idempotent_ai(
150
+ client,
151
+ key_fn=lambda user_id: f"welcome:email:{user_id}",
152
+ ttl=86400 # 24 hours
153
+ )
154
+ def send_welcome_email(user_id: str):
155
+ # This runs exactly ONCE per user_id
156
+ # Even if called 1000 times concurrently
157
+ email_service.send(
158
+ to=get_user_email(user_id),
159
+ template="welcome"
160
+ )
161
+ return {"sent": True}
162
+
163
+ # All these calls get the same result
164
+ send_welcome_email("user_123") # Sends email
165
+ send_welcome_email("user_123") # Returns cached result
166
+ send_welcome_email("user_123") # Returns cached result
167
+ ```
168
+
169
+ ---
170
+
171
+ ## ✅ Cheat-Sheet (Pick The Right Call)
172
+
173
+ **I want…**
174
+ - **Idempotent webhook/cron/job:** `check_lock(key, ttl, meta)`
175
+ - **Long-running server job:** `ai.run_and_wait(key, ttl, metadata)`
176
+ - **Governed tool call (agent + tool):** `ai.run_tool(agent_id, tool, args, spend_usd)`
177
+ - **Local side-effect exactly once:** `ai.run_fn(key, fn, ttl)`
178
+ - **Decorator version:** `@idempotent` or `@idempotent_ai`
179
+
180
+ **Async equivalents**
181
+ - `check_lock_async`
182
+ - `ai.run_and_wait_async`
183
+ - `ai.run_tool_async`
184
+ - `ai.run_fn_async`
185
+
186
+ ---
187
+
188
+ ## 🤖 Full LLM Agent Flow (No OnceOnly vs OnceOnly)
189
+
190
+ These two examples show why OnceOnly matters in production.
191
+
192
+ ### Without OnceOnly (duplicates + money loss)
193
+ ```python
194
+ # examples/ai/agent_full_flow_no_onceonly.py
195
+ decision = llm_decide()
196
+ payload = {"tool": decision["tool"], "args": decision["args"]}
197
+
198
+ # A retry or crash can re-run this call
199
+ call_tool(payload)
200
+ call_tool(payload) # duplicate charge
201
+ ```
202
+
203
+ ### With OnceOnly (deduped + governed)
204
+ ```python
205
+ # examples/ai/agent_full_flow_onceonly.py
206
+ res = client.ai.run_tool(
207
+ agent_id="billing-agent",
208
+ tool="stripe.charge",
209
+ args={"amount": 9999, "currency": "usd", "user_id": "u_42"},
210
+ spend_usd=0.5
211
+ )
212
+
213
+ if res.allowed:
214
+ print(res.result)
215
+ else:
216
+ print("Blocked:", res.policy_reason)
217
+ ```
218
+
219
+ **Why this matters**
220
+ - Prevents **duplicate charges** on retries
221
+ - Enforces **budgets** and **permissions**
222
+ - Gives **audit trails** for every tool call
223
+
224
+ **Cost impact (simple example)**
225
+ - Without OnceOnly: 1 retry on a $99 charge = **$198**
226
+ - With OnceOnly: 1 retry on a $99 charge = **$99**
227
+
228
+ **Flow diagram (simplified)**
229
+ ```
230
+ LLM -> Tool Call -> External System
231
+ | | |
232
+ | |__ retry ____| (duplicate charge)
233
+ |
234
+ OnceOnly in between
235
+ |
236
+ LLM -> OnceOnly -> Tool Call -> External System
237
+ |
238
+ |__ duplicate detected -> blocked
239
+ ```
240
+
241
+ ---
242
+
243
+ ## 📚 Complete Feature Matrix
244
+
245
+ | Feature | Description | Use Case |
246
+ |---------|-------------|----------|
247
+ | **check_lock()** | Fast idempotency primitive | Webhooks, cron jobs, workers |
248
+ | **ai.run_and_wait()** | Long-running AI jobs | Image gen, video processing, reports |
249
+ | **ai.run_tool()** | Governance tool runner | Tool calls with budgets/permissions |
250
+ | **ai.run_fn()** | Local exactly-once execution | Payments, emails, database writes |
251
+ | **@idempotent_ai** | Decorator for functions | Simple exactly-once guarantee |
252
+ | **gov.upsert_policy()** | Set agent limits | Budget caps, tool permissions |
253
+ | **gov.disable_agent()** | Kill switch | Emergency stop |
254
+ | **gov.agent_logs()** | Audit trail | Forensics, compliance |
255
+ | **gov.agent_metrics()** | Usage stats | Monitoring, alerting |
256
+
257
+ ---
258
+
259
+ ## 🧠 Architecture Layers
260
+
261
+ OnceOnly provides **5 layers** of safety:
262
+
263
+ ```
264
+ ┌─────────────────────────────────────────────────────────┐
265
+ │ L5: Agent Governance (policies, kill switch, audit) │
266
+ ├─────────────────────────────────────────────────────────┤
267
+ │ L4: Decorator Runtime (@idempotent_ai) │
268
+ ├─────────────────────────────────────────────────────────┤
269
+ │ L3: Local Side-Effects (ai.run_fn) │
270
+ ├─────────────────────────────────────────────────────────┤
271
+ │ L2: AI Job Orchestration (ai.run_and_wait) │
272
+ ├─────────────────────────────────────────────────────────┤
273
+ │ L1: Idempotency Primitive (check_lock) │
274
+ └─────────────────────────────────────────────────────────┘
275
+ ```
276
+
277
+ Pick the layer that fits your use case. They compose cleanly.
278
+
279
+ ---
280
+
281
+ ## 💎 Golden Example (Payment with Full Safety)
282
+
283
+ This example shows **complete** runtime + governance safety:
284
+
285
+ ```python
286
+ from onceonly import OnceOnly, idempotent_ai
287
+ import stripe
288
+
289
+ client = OnceOnly(api_key="once_live_...")
290
+
291
+ # 1. Set governance policy (one-time)
292
+ client.gov.upsert_policy({
293
+ "agent_id": "billing-agent",
294
+ "max_actions_per_hour": 100,
295
+ "max_spend_usd_per_day": 25.0,
296
+ "allowed_tools": ["stripe.charge", "send_receipt"],
297
+ "blocked_tools": ["delete_user", "refund_all"]
298
+ })
299
+
300
+ # 2. Define exactly-once payment function
301
+ @idempotent_ai(
302
+ client,
303
+ key_fn=lambda user_id, amount: f"charge:{user_id}:{amount}",
304
+ ttl=300, # 5 minutes
305
+ metadata_fn=lambda u, a: {
306
+ "user_id": u,
307
+ "amount_cents": a,
308
+ "agent": "billing-agent"
309
+ }
310
+ )
311
+ def charge_user(user_id: str, amount_cents: int):
312
+ """Charge user - guaranteed exactly once"""
313
+ return stripe.Charge.create(
314
+ amount=amount_cents,
315
+ currency="usd",
316
+ customer=get_stripe_customer_id(user_id)
317
+ )
318
+
319
+ # 3. Execute with full safety
320
+ result = charge_user("user_42", 9999)
321
+
322
+ if result.status == "completed":
323
+ charge_id = result.result["data"]["id"]
324
+ print(f"✅ Charged: {charge_id}")
325
+ else:
326
+ print(f"❌ Failed: {result.error_code}")
327
+ ```
328
+
329
+ **Guarantees:**
330
+ - ✅ Charged **exactly once** (even if retried 1000x)
331
+ - ✅ Budget enforced (won't exceed $25/day)
332
+ - ✅ Tool allowed (stripe.charge in whitelist)
333
+ - ✅ Crash safe (worker dies? resumes automatically)
334
+ - ✅ Audit logged (forensic trail for compliance)
335
+
336
+ ---
337
+
338
+ ## 🛡️ Governance & Safety
339
+
340
+ ### Agent Policies
341
+
342
+ Control what agents can do:
343
+
344
+ ```python
345
+ # Strict policy (whitelist only)
346
+ client.gov.upsert_policy({
347
+ "agent_id": "readonly-agent",
348
+ "max_actions_per_hour": 500,
349
+ "allowed_tools": ["get_user", "search", "list_items"],
350
+ "blocked_tools": [] # Everything else blocked
351
+ })
352
+
353
+ # Moderate policy (blacklist dangerous tools)
354
+ client.gov.upsert_policy({
355
+ "agent_id": "support-agent",
356
+ "max_actions_per_hour": 200,
357
+ "max_spend_usd_per_day": 50.0,
358
+ "blocked_tools": ["delete_user", "stripe.charge"]
359
+ })
360
+
361
+ # Per-tool limits
362
+ client.gov.upsert_policy({
363
+ "agent_id": "billing-agent",
364
+ "max_calls_per_tool": {
365
+ "stripe.refund": 5, # Max 5 refunds/day
366
+ "send_email": 100 # Max 100 emails/day
367
+ }
368
+ })
369
+ ```
370
+
371
+ ### Policy Templates
372
+
373
+ Use pre-configured templates:
374
+
375
+ ```python
376
+ # Quick setup with sensible defaults
377
+ policy = client.gov.policy_from_template(
378
+ agent_id="new-agent",
379
+ template="moderate", # strict|moderate|permissive|read_only|support_bot
380
+ overrides={
381
+ "max_actions_per_hour": 300,
382
+ "blocked_tools": ["delete_user"]
383
+ }
384
+ )
385
+ ```
386
+
387
+ **Available templates (server defaults):**
388
+ - `strict`
389
+ - `moderate`
390
+ - `permissive`
391
+ - `read_only`
392
+ - `support_bot`
393
+
394
+ ### Kill Switch
395
+
396
+ Instantly disable rogue agents:
397
+
398
+ ```python
399
+ # Emergency stop
400
+ client.gov.disable_agent(
401
+ "rogue-agent",
402
+ reason="Suspicious behavior detected"
403
+ )
404
+
405
+ # Re-enable after investigation
406
+ client.gov.enable_agent("rogue-agent")
407
+ ```
408
+
409
+ ### Audit & Forensics
410
+
411
+ Complete action history:
412
+
413
+ ```python
414
+ # Get recent actions
415
+ logs = client.gov.agent_logs("billing-agent", limit=100)
416
+
417
+ for log in logs:
418
+ print(f"{log.ts}: {log.tool} - {log.decision}")
419
+ print(f" Reason: {log.policy_reason or log.reason}")
420
+ print(f" Risk: {log.risk_level}")
421
+ print(f" Cost: ${log.spend_usd}")
422
+
423
+ # Get metrics
424
+ metrics = client.gov.agent_metrics("billing-agent", period="day")
425
+ print(f"Actions: {metrics.total_actions}")
426
+ print(f"Blocked: {metrics.blocked_actions}")
427
+ print(f"Spend: ${metrics.total_spend_usd}")
428
+ print(f"Top tools: {metrics.top_tools}")
429
+ ```
430
+
431
+ ---
432
+
433
+ ## 🔌 Framework Integrations
434
+
435
+ ### LangChain
436
+
437
+ ```python
438
+ from langchain_core.tools import tool
439
+ from onceonly import OnceOnly
440
+ from onceonly.integrations.langchain import make_idempotent_tool
441
+
442
+ client = OnceOnly(api_key="once_live_...")
443
+
444
+ @tool
445
+ def send_email(to: str, subject: str, body: str) -> str:
446
+ """Send email to user"""
447
+ email_service.send(to=to, subject=subject, body=body)
448
+ return f"Email sent to {to}"
449
+
450
+ # Wrap with idempotency
451
+ idempotent_send_email = make_idempotent_tool(
452
+ send_email,
453
+ client=client,
454
+ key_prefix="agent:email",
455
+ ttl=3600
456
+ )
457
+
458
+ # Use in agent
459
+ from langchain.agents import AgentExecutor, create_react_agent
460
+
461
+ agent = create_react_agent(llm, tools=[idempotent_send_email], prompt)
462
+ executor = AgentExecutor(agent=agent, tools=[idempotent_send_email])
463
+
464
+ # Agent can retry - we guarantee exactly-once execution
465
+ result = executor.invoke({"input": "Send welcome email to new@user.com"})
466
+ ```
467
+
468
+ ### FastAPI
469
+
470
+ ```python
471
+ from fastapi import FastAPI, Depends, HTTPException
472
+ from onceonly import OnceOnly
473
+ import os
474
+
475
+ app = FastAPI()
476
+
477
+ def get_onceonly() -> OnceOnly:
478
+ return OnceOnly(api_key=os.environ["ONCEONLY_API_KEY"])
479
+
480
+ @app.post("/webhooks/stripe")
481
+ async def stripe_webhook(
482
+ event: dict,
483
+ client: OnceOnly = Depends(get_onceonly)
484
+ ):
485
+ result = await client.check_lock_async(
486
+ key=f"stripe:{event['id']}",
487
+ ttl=7200,
488
+ meta={"type": event["type"]}
489
+ )
490
+
491
+ if result.duplicate:
492
+ return {"status": "duplicate"}
493
+
494
+ await process_stripe_event(event)
495
+ return {"status": "processed"}
496
+ ```
497
+
498
+ ---
499
+
500
+ ## 🧰 Tools Registry (User-Owned Tools)
501
+
502
+ Register your own tools (URLs) and enforce permissions per agent.
503
+
504
+ ```python
505
+ # Register a tool (requires Pro or Agency)
506
+ tool = client.gov.create_tool({
507
+ "name": "send_email",
508
+ "url": "https://example.com/tools/send_email",
509
+ "scope_id": "global",
510
+ "auth": {"type": "hmac_sha256", "secret": "your_shared_secret"},
511
+ "timeout_ms": 15000,
512
+ "max_retries": 2,
513
+ "enabled": True,
514
+ "description": "Send email to user"
515
+ })
516
+
517
+ # Toggle a tool
518
+ client.gov.toggle_tool("send_email", enabled=False)
519
+
520
+ # List tools
521
+ tools = client.gov.list_tools(scope_id="global")
522
+ ```
523
+
524
+ **Tools registry limits by plan**
525
+ - Pro: 10 tools
526
+ - Agency: 500 tools
527
+
528
+ Note: Tools registry is **not available** on Free/Starter.
529
+
530
+ **Rules & expectations (important)**
531
+ - `name` must be unique per `scope_id` and match `^[a-zA-Z0-9_.:-]+$`
532
+ - `scope_id` lets you namespace tools (e.g. `global` or `agent:billing-agent`)
533
+ - `auth.type` currently supports `hmac_sha256` (use a shared secret)
534
+ - Your tool endpoint should verify HMAC and be idempotent on its side
535
+
536
+ ---
537
+
538
+ ## 📖 API Reference
539
+
540
+ ### Core Client
541
+
542
+ ```python
543
+ from onceonly import OnceOnly
544
+
545
+ client = OnceOnly(
546
+ api_key="once_live_...",
547
+ base_url="https://api.onceonly.tech/v1", # optional
548
+ timeout=5.0, # HTTP timeout
549
+ fail_open=True, # graceful degradation
550
+ max_retries_429=3, # auto-retry on rate limit
551
+ retry_backoff=0.5, # initial backoff (seconds)
552
+ retry_max_backoff=10.0 # max backoff (seconds)
553
+ )
554
+ ```
555
+
556
+ ### API Endpoints Map (Public)
557
+
558
+ Use this map to find the correct endpoint category quickly:
559
+
560
+ - **Core**: `GET /v1/me`, `GET /v1/usage`, `GET /v1/usage/all`, `GET /v1/events`, `GET /v1/metrics`
561
+ - **Idempotency**: `POST /v1/check-lock`
562
+ - **AI Jobs**: `POST /v1/ai/run`, `GET /v1/ai/status`, `GET /v1/ai/result`
563
+ - **AI Lease (local side-effects)**: `POST /v1/ai/lease`, `POST /v1/ai/extend`, `POST /v1/ai/complete`, `POST /v1/ai/fail`, `POST /v1/ai/cancel`
564
+ - **Governance (policies)**: `POST /v1/policies/{agent_id}`, `POST /v1/policies/{agent_id}/from-template`, `GET /v1/policies`, `GET /v1/policies/{agent_id}`
565
+ - **Governance (agents)**: `POST /v1/agents/{agent_id}/disable`, `POST /v1/agents/{agent_id}/enable`, `GET /v1/agents/{agent_id}/logs`, `GET /v1/agents/{agent_id}/metrics`
566
+ - **Tools Registry**: `POST /v1/tools`, `GET /v1/tools`, `GET /v1/tools/{tool}`, `POST /v1/tools/{tool}/toggle`, `DELETE /v1/tools/{tool}`
567
+
568
+ ### Idempotency
569
+
570
+ ```python
571
+ # Sync
572
+ result = client.check_lock(
573
+ key="order:12345",
574
+ ttl=3600, # Lock duration (seconds)
575
+ meta={"user_id": 42} # Optional metadata
576
+ )
577
+
578
+ # Async
579
+ result = await client.check_lock_async(key="order:12345", ttl=3600)
580
+
581
+ # Check result
582
+ if result.duplicate:
583
+ print(f"Duplicate! First seen: {result.first_seen_at}")
584
+ else:
585
+ print("First time - proceed with action")
586
+ ```
587
+
588
+ ### AI Execution
589
+
590
+ ```python
591
+ # Long-running job (server-side)
592
+ result = client.ai.run_and_wait(
593
+ key="report:monthly:2024-01",
594
+ ttl=1800, # Job timeout (seconds)
595
+ timeout=120.0, # Polling timeout
596
+ poll_min=1.0, # Min poll interval
597
+ poll_max=10.0, # Max poll interval
598
+ metadata={"month": "2024-01"}
599
+ )
600
+
601
+ # Governance tool runner (agent + tool)
602
+ tool_res = client.ai.run_tool(
603
+ agent_id="billing-agent",
604
+ tool="stripe.charge",
605
+ args={"amount": 9999, "currency": "usd"},
606
+ spend_usd=0.5
607
+ )
608
+ if tool_res.allowed:
609
+ print(tool_res.result)
610
+ else:
611
+ print(f"Blocked: {tool_res.policy_reason}")
612
+
613
+ # Async tool runner
614
+ tool_res = await client.ai.run_tool_async(
615
+ agent_id="billing-agent",
616
+ tool="stripe.charge",
617
+ args={"amount": 9999, "currency": "usd"},
618
+ spend_usd=0.5
619
+ )
620
+ if tool_res.allowed:
621
+ print(tool_res.result)
622
+ else:
623
+ print(f"Blocked: {tool_res.policy_reason}")
624
+
625
+ # Local function execution
626
+ result = client.ai.run_fn(
627
+ key="email:welcome:user123",
628
+ fn=lambda: send_email(...),
629
+ ttl=300,
630
+ wait_on_conflict=True, # Wait if another process executing
631
+ timeout=60.0,
632
+ error_code="email_failed"
633
+ )
634
+
635
+ # Check status only (no polling)
636
+ status = client.ai.status("report:monthly:2024-01")
637
+ print(f"Status: {status.status}, TTL: {status.ttl_left}s")
638
+
639
+ # Get result
640
+ result = client.ai.result("report:monthly:2024-01")
641
+ if result.status == "completed":
642
+ print(result.result)
643
+
644
+ ### AI Modes (Choose One)
645
+
646
+ | Mode | Use When | Call | Result Type |
647
+ |------|----------|------|-------------|
648
+ | **Job (server-side)** | Long-running tasks | `ai.run_and_wait(key=...)` | `AiResult` |
649
+ | **Tool (governed)** | Agent tool execution | `ai.run_tool(agent_id=..., tool=...)` | `AiToolResult` |
650
+ | **Local side-effects** | Your code does the work | `ai.run_fn(key=..., fn=...)` | `AiResult` |
651
+
652
+ ### AI Result Shapes (AI-friendly)
653
+
654
+ ```python
655
+ # Tool result (governance)
656
+ AiToolResult = {
657
+ "ok": bool,
658
+ "allowed": bool,
659
+ "decision": str, # "executed" | "blocked" | "dedup"
660
+ "policy_reason": str | None,
661
+ "risk_level": str | None,
662
+ "result": dict | None,
663
+ }
664
+
665
+ # Job result (run_and_wait / result)
666
+ AiResult = {
667
+ "ok": bool,
668
+ "status": str, # "completed" | "failed" | "in_progress"
669
+ "key": str,
670
+ "result": dict | None,
671
+ "error_code": str | None,
672
+ "done_at": str | None,
673
+ }
674
+ ```
675
+
676
+ ### Tool: Happy vs Blocked
677
+
678
+ ```python
679
+ res = client.ai.run_tool(
680
+ agent_id="billing-agent",
681
+ tool="stripe.refund",
682
+ args={"charge_id": "ch_123", "amount": 500},
683
+ spend_usd=0.2
684
+ )
685
+
686
+ if res.allowed:
687
+ print("OK", res.result)
688
+ else:
689
+ print("BLOCKED", res.policy_reason)
690
+ ```
691
+ ```
692
+
693
+ ### Decorators
694
+
695
+ ```python
696
+ from onceonly import idempotent, idempotent_ai
697
+
698
+ # Basic idempotency
699
+ @idempotent(client, key_prefix="payment", ttl=3600)
700
+ def process_payment(order_id: str):
701
+ # Runs once per order_id
702
+ stripe.charge(...)
703
+
704
+ # AI lease execution
705
+ @idempotent_ai(
706
+ client,
707
+ key_fn=lambda user_id: f"onboard:{user_id}",
708
+ ttl=600,
709
+ metadata_fn=lambda uid: {"user": uid}
710
+ )
711
+ def onboard_user(user_id: str):
712
+ # Exactly-once, even across multiple workers
713
+ create_account(user_id)
714
+ send_welcome_email(user_id)
715
+ return {"onboarded": True}
716
+ ```
717
+
718
+ ### Governance
719
+
720
+ ```python
721
+ # Set policy
722
+ policy = client.gov.upsert_policy({
723
+ "agent_id": "my-agent",
724
+ "max_actions_per_hour": 200,
725
+ "max_spend_usd_per_day": 50.0,
726
+ "allowed_tools": ["tool_a", "tool_b"],
727
+ "blocked_tools": ["dangerous_tool"],
728
+ "max_calls_per_tool": {"tool_a": 10}
729
+ })
730
+
731
+ # From template
732
+ policy = client.gov.policy_from_template(
733
+ agent_id="my-agent",
734
+ template="moderate",
735
+ overrides={"max_actions_per_hour": 300}
736
+ )
737
+
738
+ # Kill switch
739
+ status = client.gov.disable_agent("my-agent", reason="Testing")
740
+ status = client.gov.enable_agent("my-agent")
741
+
742
+ # Audit
743
+ logs = client.gov.agent_logs("my-agent", limit=100)
744
+ metrics = client.gov.agent_metrics("my-agent", period="day")
745
+ ```
746
+
747
+ ---
748
+
749
+ ## ⚙️ Configuration
750
+
751
+ ### Environment Variables
752
+
753
+ ```bash
754
+ export ONCEONLY_API_KEY="once_live_..."
755
+ export ONCEONLY_BASE_URL="https://api.onceonly.tech/v1" # optional
756
+ ```
757
+
758
+ ### Fail-Open Behavior
759
+
760
+ Network/server failures don't break your app (graceful degradation):
761
+
762
+ ```python
763
+ client = OnceOnly(
764
+ api_key="...",
765
+ fail_open=True # default: allows execution on timeout/5xx
766
+ )
767
+ ```
768
+
769
+ **Fail-open NEVER applies to:**
770
+ - 401/403 (auth errors) → Always blocks
771
+ - 402 (usage limit) → Always blocks
772
+ - 422 (validation) → Always blocks
773
+ - 429 (rate limit) → Retries with backoff
774
+
775
+ ### Connection Pooling
776
+
777
+ ```python
778
+ import httpx
779
+ from onceonly import OnceOnly
780
+
781
+ # Reuse HTTP connections
782
+ sync_client = httpx.Client(
783
+ timeout=10.0,
784
+ limits=httpx.Limits(max_keepalive_connections=20)
785
+ )
786
+
787
+ client = OnceOnly(
788
+ api_key="...",
789
+ sync_client=sync_client
790
+ )
791
+
792
+ # Close when done
793
+ client.close()
794
+ ```
795
+
796
+ ### Context Managers
797
+
798
+ ```python
799
+ # Auto-cleanup
800
+ with OnceOnly(api_key="...") as client:
801
+ result = client.check_lock(key="task", ttl=300)
802
+
803
+ # Async
804
+ async with OnceOnly(api_key="...") as client:
805
+ result = await client.check_lock_async(key="task", ttl=300)
806
+ ```
807
+
808
+ ---
809
+
810
+ ## 🚨 Common Patterns & Best Practices
811
+
812
+ ### ✅ DO
813
+
814
+ ```python
815
+ # ✅ Use specific, deterministic keys
816
+ key = f"payment:{order_id}:{user_id}"
817
+
818
+ # ✅ Set appropriate TTLs
819
+ ttl = 3600 # 1 hour for webhooks
820
+ ttl = 86400 # 24 hours for daily jobs
821
+
822
+ # ✅ Add metadata for debugging
823
+ meta = {"user_id": 123, "amount": 9999, "source": "web"}
824
+
825
+ # ✅ Handle duplicates gracefully
826
+ if result.duplicate:
827
+ logger.info(f"Duplicate detected: {result.key}")
828
+ return cached_response
829
+
830
+ # ✅ Use decorators for simplicity
831
+ @idempotent_ai(client, key_fn=lambda x: f"task:{x}")
832
+ def my_task(x): ...
833
+ ```
834
+
835
+ ### ❌ DON'T
836
+
837
+ ```python
838
+ # ❌ Don't use random/timestamp in keys
839
+ key = f"payment:{uuid.uuid4()}" # Every call is "unique"
840
+ key = f"task:{time.time()}" # Never deduplicates
841
+
842
+ # ❌ Don't set TTL too short
843
+ ttl = 1 # Retries will leak through
844
+
845
+ # ❌ Don't ignore duplicate status
846
+ result = client.check_lock(...)
847
+ process_payment() # Always runs!
848
+
849
+ # ❌ Don't catch and swallow errors silently
850
+ try:
851
+ client.check_lock(...)
852
+ except: pass # Lose safety guarantees
853
+ ```
854
+
855
+ ---
856
+
857
+ ## 🐛 Troubleshooting
858
+
859
+ ### "Unauthorized" (401/403)
860
+
861
+ **Cause:** Invalid API key
862
+
863
+ ```python
864
+ # ❌ Wrong
865
+ client = OnceOnly(api_key="sk_test_...")
866
+
867
+ # ✅ Correct
868
+ client = OnceOnly(api_key="once_live_...")
869
+ ```
870
+
871
+ ### "Usage limit reached" (402)
872
+
873
+ **Cause:** Exceeded monthly quota for your plan
874
+
875
+ **Solution:** Upgrade at https://onceonly.tech/pricing
876
+
877
+ ```python
878
+ # Check current usage
879
+ usage = client.usage(kind="make")
880
+ print(f"Used: {usage['usage']} / {usage['limit']}")
881
+ ```
882
+
883
+ ### "Rate limit exceeded" (429)
884
+
885
+ **Cause:** Too many requests per second
886
+
887
+ **Solution:** Enable auto-retry:
888
+
889
+ ```python
890
+ client = OnceOnly(
891
+ api_key="...",
892
+ max_retries_429=3, # Auto-retry up to 3 times
893
+ retry_backoff=0.5, # Start with 0.5s delay
894
+ retry_max_backoff=10.0 # Cap at 10s
895
+ )
896
+ ```
897
+
898
+ ### Duplicates not being detected
899
+
900
+ **Cause:** Key is not deterministic
901
+
902
+ ```python
903
+ # ❌ Wrong: random UUID
904
+ key = f"order:{uuid.uuid4()}"
905
+
906
+ # ✅ Correct: stable identifier
907
+ key = f"order:{order_id}"
908
+ ```
909
+
910
+ ### Agent blocked by policy
911
+
912
+ **Cause:** Policy restrictions
913
+
914
+ ```python
915
+ # Check what happened
916
+ logs = client.gov.agent_logs("my-agent", limit=10)
917
+ for log in logs:
918
+ if log.decision == "blocked":
919
+ print(f"Blocked: {log.tool} - {log.policy_reason or log.reason}")
920
+
921
+ # Adjust policy
922
+ client.gov.upsert_policy({
923
+ "agent_id": "my-agent",
924
+ "allowed_tools": ["tool_a", "tool_b", "tool_c"], # Add tool_c
925
+ })
926
+ ```
927
+
928
+ ## 📊 Feature Availability
929
+
930
+ | Feature | Free | Starter | Pro | Agency |
931
+ |---------|------|---------|-----|--------|
932
+ | **Core Idempotency** |||||
933
+ | `check_lock()` | 1K/mo | 20K/mo | 200K/mo | 2M/mo |
934
+ | `ai.run_and_wait()` | 3K/mo | 100K/mo | 1M/mo | 10M/mo |
935
+ | **Agent Governance** |||||
936
+ | `gov.upsert_policy()` | ❌ | ❌ | ✅ Limited | ✅ Full |
937
+ | `gov.agent_logs()` | ❌ | ❌ | ✅ | ✅ |
938
+ | `gov.agent_metrics()` | ❌ | ❌ | ✅ | ✅ |
939
+ | `gov.disable_agent()` (Kill switch) | ❌ | ❌ | ❌ | ✅ |
940
+ | `gov.enable_agent()` | ❌ | ❌ | ❌ | ✅ |
941
+ | **Policy Features** |||||
942
+ | Budget limits (`max_spend_usd_per_day`) | ❌ | ❌ | ✅ | ✅ |
943
+ | Tool blocklist (`blocked_tools`) | ❌ | ❌ | ✅ | ✅ |
944
+ | Tool whitelist (`allowed_tools`) | ❌ | ❌ | ❌ | ✅ |
945
+ | Per-tool limits (`max_calls_per_tool`) | ❌ | ❌ | ✅ | ✅ |
946
+
947
+ > **Pro Plan**: Limited governance (no `allowed_tools` whitelist, no kill switch)
948
+ > **Agency Plan**: Full governance (whitelist, kill switch, anomaly detection)
949
+
950
+ ## 📈 Plan Limits (Defaults)
951
+
952
+ These are the default limits enforced by the API (may be configured by the server):
953
+
954
+ | Plan | `check_lock` (make) | `ai` (runs) | Default TTL | Max TTL | Tools Registry Limit |
955
+ |------|---------------------|------------|-------------|---------|----------------------|
956
+ | Free | 1K / month | 3K / month | 60s | 1h | Not available |
957
+ | Starter | 20K / month | 100K / month | 1h | 24h | Not available |
958
+ | Pro | 200K / month | 1M / month | 6h | 7d | 10 tools |
959
+ | Agency | 2M / month | 10M / month | 24h | 30d | 500 tools |
960
+
961
+ **Pro vs Agency differences (important):**
962
+ - **Pro**: Governance is limited (no `allowed_tools` whitelist, no kill switch).
963
+ - **Agency**: Full governance, including tool whitelist + kill switch.
964
+
965
+ ---
966
+
967
+ ## 📊 Production Checklist
968
+
969
+ Before going live:
970
+
971
+ - [ ] **Use production API key** (`once_live_...`)
972
+ - [ ] **Set appropriate TTLs** (not too short, not too long)
973
+ - [ ] **Enable auto-retry** (`max_retries_429=3`)
974
+ - [ ] **Add metadata** for debugging (`meta={"user": ...}`)
975
+ - [ ] **Monitor usage** (check `client.usage()` regularly)
976
+ - [ ] **Set up governance** for AI agents
977
+ - [ ] **Test fail-open** behavior (simulate API downtime)
978
+ - [ ] **Review audit logs** periodically
979
+ - [ ] **Set up alerts** for blocked actions
980
+
981
+ ---
982
+
983
+ ## 🔗 Links
984
+
985
+ - **Website:** https://onceonly.tech
986
+ - **Documentation:** https://docs.onceonly.tech
987
+ - **API Reference:** https://docs.onceonly.tech/api
988
+ - **Python SDK Docs:** https://docs.onceonly.tech/sdk/python
989
+ - **GitHub:** https://github.com/onceonly-tech/onceonly-python
990
+ - **PyPI:** https://pypi.org/project/onceonly-sdk/
991
+ - **Support:** support@onceonly.tech
992
+
993
+ ---
994
+
995
+ ## 📄 License
996
+
997
+ MIT License - see [LICENSE](LICENSE) file for details.
998
+
999
+ ---
1000
+
1001
+ ## 🤝 Contributing
1002
+
1003
+ We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1004
+
1005
+ ## ✅ Tests
1006
+
1007
+ ```bash
1008
+ pytest -q
1009
+ ```
1010
+
1011
+ Integration smoke tests (live API):
1012
+
1013
+ ```bash
1014
+ export TEST_API_KEY="once_live_..."
1015
+ export TEST_BASE_URL="https://api.onceonly.tech"
1016
+ pytest -q -m integration
1017
+ ```
1018
+
1019
+ ---
1020
+
1021
+ ## ⭐ Support
1022
+
1023
+ If OnceOnly helps your project, give us a star on [GitHub](https://github.com/onceonly-tech/onceonly-python)!
1024
+
1025
+ Questions? Open an issue or email support@onceonly.tech
1026
+
1027
+ ---
1028
+
1029
+ <div align="center">
1030
+ <sub>Built with ❤️ by the OnceOnly team</sub>
1031
+ </div>