Quickstart¶
Decorate a tool¶
import stripe
from once import Once
once = Once() # SQLite in ./once.db, or Once("path/to/once.db")
@once.tool(name="charge_card")
def charge_card(amount: int, customer: str, idempotency_key: str | None = None) -> dict:
charge = stripe.Charge.create(
amount=amount,
currency="usd",
customer=customer,
idempotency_key=idempotency_key,
)
return {"id": charge.id}
Register charge_card with your agent framework as a normal tool. Because the function has an idempotency_key parameter, once passes the computed key in, so you can forward it to an API that has its own idempotency keys.
Give it a scope¶
The key is built from the tool name, the arguments and a scope. The scope is usually a run, order or step id:
with once.scope("order_456"):
charge_card(1000, "cus_123") # charges
charge_card(1000, "cus_123") # returns the stored {"id": ...}, no second charge
Without a scope, the same call with the same arguments only ever runs once, for good. That is rarely what you want for something like "send a reminder", so set a scope or a ttl.
Call it explicitly¶
execute runs a function with your own key. It takes keyword arguments for the function:
from once import make_key
key = make_key("charge_card", {"amount": 1000, "customer": "cus_123"}, scope="order_456")
result = once.execute(charge_card, key=key, amount=1000, customer="cus_123")
Leave key out and execute computes it from tool_name, the arguments and scope=. When fn is a decorated tool, its name, verify and ttl are used unless you pass others.
Async¶
@once.tool works on async def functions too and gives you an async wrapper. For explicit calls use await once.aexecute(...), which takes the same arguments as execute and also accepts a plain function, run in a worker thread.
@once.tool(name="post_message", timeout=10)
async def post_message(channel: str, text: str, idempotency_key: str | None = None) -> dict:
resp = await http.post(
"/messages",
json={"channel": channel, "text": text},
headers={"Idempotency-Key": idempotency_key},
)
return resp.json()
async def run_step(run_id: str) -> None:
with once.scope(run_id):
await post_message("#ops", "deploy done")
Store calls run in worker threads with asyncio.to_thread, so the event loop is not blocked.
When something goes wrong¶
The first call re-raises whatever the function raised. Later calls with the same key raise an Effect* error that carries the stored .record:
from once import EffectFailed, EffectUnknown
try:
charge_card(1000, "cus_123")
except EffectUnknown as exc:
print("check Stripe for", exc.record.key)
except EffectFailed as exc:
print("failed earlier:", exc.record.error)
After you have checked what happened, once.reset(key) lets the call run again. Concepts has the details, and Verifiers shows how to automate the check.