Reference · Requests and pages

idempotent

signature
idempotent(map | fn, fn=) -> any

What it does

Runs the function once for a request, however many times the request is sent. A request with an Idempotency-Key header runs it and keeps the answer; the same request again with the same key gets that answer back, with Idempotent-Replayed: true, without running again. A client that sent a payment and got no answer can then send it again safely. The function runs in a transaction on the program's database, and its answer is written in that transaction, so the work and its answer are kept together or not at all. The options come first: max_age (how long answers are kept, 24 hours unless given, up to 366 days), user, required, transaction, isolation and retries. See Idempotent requests.

Examples

in a program
route POST /payments { json: { to: str, amount: dec } } = idempotent(() => {    let id = pay(to, amount)?    respond(201, { id })})server fn transfer(from: str, to: str, amount: str) =    idempotent({ isolation: "serializable", retries: 5 }, () => move(from, to, amount))

The client sends the key with the request, and again with the retry:

curl localhost:8080/payments -H "Idempotency-Key: $(uuidgen)" --json '{"to": "bob", "amount": "12.50"}'

A plain form, with no script to send a header: idempotent on the <form> writes a hidden _idempotency_key into it as the page is rendered, and the route reads that key where there is no header. However many times the form is sent, the row is inserted once:

in a program
db.open("app.db")db.exec("create table if not exists donations (id integer primary key, name text, cents integer)")route GET /donate =    <form method="post" action="/donate" idempotent>        <input name="name" required/>        <input name="cents" type="number" required/>        <button>Give</button>    </form>route POST /donate { form: { name: str, cents: int } } = idempotent(() => {    db.exec("insert into donations (name, cents) values (?, ?)", name, cents)?    let n = db.one("select count(*) as n from donations").n    `Thank you, ${name}: donation ${n} recorded`})

The page goes out as <form method="post" action="/donate"><input type="hidden" name="_idempotency_key" value="7125baaf-...">.... Sent twice with that key, the form inserts one row: both answers are Thank you, Ann: donation 1 recorded, the second with Idempotent-Replayed: true. The same key with the form edited (another amount) is another request, and runs. A post without the key runs every time.

A script, or any client that can set headers, makes up a key and sends the same one when it sends the request again:

in a program
let headers = { "content-type": "application/x-www-form-urlencoded", "idempotency-key": uuid() }let first = http.post("http://localhost:8080/donate", "name=Dee&cents=250", headers)let retry = http.post("http://localhost:8080/donate", "name=Dee&cents=250", headers)print(first.body)                                     // Thank you, Dee: donation 2 recordedprint(retry.body, retry.headers["idempotent-replayed"])   // the same answer, and true

With a header, the same key and another body is refused with 422 {"error":"this Idempotency-Key was used with another request"}, and nothing runs. A page calling transfer above needs nothing more: a server fn whose body calls idempotent gets a key from the page with each call, and the page sends it again with a retry.

Outside a request, and in a request without the header, the function simply runs and its value is the result:

print(idempotent(() => 42))print(idempotent({ max_age: "7d" }, () => "paid"))
output
42paid

Edge cases

print(idempotent({ transaction: false }, () => "no transaction"))print(idempotent({ required: true, max_age: 3600 }, () => "runs outside a request"))print(is_err(idempotent(() => err("not enough money"))))
output
no transactionruns outside a requesttrue

Errors

An unknown option, a max_age that is not seconds or a duration, transaction that is not a boolean, retries or isolation together with transaction: false, and a call without a function (or with the function before the options) are runtime errors. Calling it inside db.transaction is a runtime error too: it begins a transaction of its own. A database that cannot be reached when the key is recorded answers the request 503.

this one fails
print(idempotent({ nope: 1 }, () => 1))
output
line 1: `idempotent` has no option `nope` (max_age, user, required, transaction, isolation, retries)
this one fails
print(idempotent(() => 1, { max_age: "7d" }))
output
line 1: `idempotent` expects a function, like idempotent(() => pay(order)), with options before it if any

Chaining

idempotent wraps the whole of a route's work; the pipeline goes inside the function, and the value the function ends with is what the client gets, now and on a retry:

in a program
route POST /orders { json: { items: list } } = idempotent(() =>    items | map(reserve) | place_order | (id) => respond(201, { id }))

See also

db.transaction, respond, session, tenant

Thread
9Useful? Like it, or add to its thread below.

Thread 0 posts

On idempotent(): questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.

    Edit this page on GitHub · every example's output was made by running it (tools/refcheck.mjs) · questions on Discord or r/quillo