Skip to content

Answering once

A client that never hears back has to try again, and a server that runs the handler again places the order twice. The Idempotency-Key header is the answer every payment API settled on: the client makes up a key, sends it with the request, and sends the same key on every retry; the server runs the handler once per key and answers the retries with what it already said.

In nilo that is one argument, and the handler is otherwise the one you were going to write:

(For a GET whose answer is the same for everybody for a minute, the sibling is Cached: the same record, keyed on the request line instead of a header.)

const cache = @import("nilo_cache");

const Replays = cache.Space("orders-replay", []const u8, .{
    .ttl_s = 86_400,          // a day: how long a client may keep retrying
    .max_bytes = 16 << 10,    // the largest answer kept
});

const NewOrder = struct { sku: Str, qty: u32 };
const Placed = struct { id: u64, sku: Str };
fn account(c: *nilo.Ctx) ?Str {
    return c.header("X-Account");
}

fn placeOrder(key: nilo.Idempotent(Replays, .{ .by = account }), body: NewOrder) !nilo.Status(201, Placed) {
    _ = key;
    // …charge the card, insert the row…
    return .{ .value = .{ .id = 7, .sku = body.sku } };
}
POST /orders                      Idempotency-Key: 4c1e…    → 201 {"id":7,"sku":"A1"}
POST /orders  (the retry)         Idempotency-Key: 4c1e…    → 201 {"id":7,"sku":"A1"}   Idempotent-Replayed: true
POST /orders  (a different body)  Idempotency-Key: 4c1e…    → 422
POST /orders  (no key)                                      → 400

The first request runs the handler and keeps what it returned — the status, the body, and a Response(T)'s own headers, a Location say. Every later request with the same key gets that back, byte for byte, with Idempotent-Replayed: true on it, and the handler does not run. The card is charged once. The row is inserted once.

Wiring it up

Replays is a nilo_cache Space holding bytes, opened on a Store and handed to the App as a service:

store = try cache.open(gpa, .{ .bytes = 16 << 20 });
var replays = Replays.open(&store);
try app.provide(&replays);

Then try app.post("/orders", placeOrder), like any other route.

A Space rather than a table of nilo's own, because a cache is exactly what a kept answer is: bounded, forgotten after a while, and allowed to miss — a miss here is a retry that runs the handler again, which is what would have happened without the feature. ttl_s is how long a client may keep retrying against the same key; max_bytes is the largest answer kept, and one larger is sent and not kept, with a line in the log saying so.

The Space is a shape, not a dependency. nilo_http names no cache; what it asks of Replays is getInto, putIfAbsent, put, del, max_bytes and Held, which a nilo_cache bytes Space has and a type of your own over Redis could — for a key that has to survive a restart or be shared between instances.

Whose key it is

.by is the question the header cannot answer on its own. Two clients that both pick 1 as their first key are two clients, and a key kept without saying whose would hand the second one the first one's order. So .by is a function of one *Ctx answering ?Str — the account, the tenant, the API key, whatever tells callers apart — and the kept answer is filed under both. A resolved value is the ordinary source:

const CurrentUser = struct {
    pub const nilo_resolve = whoIsThis;
    id: Str,
};

fn whoIsThis(c: *nilo.Ctx) !CurrentUser {
    const auth = try c.authorization(.bearer);
    return .{ .id = auth.value };            // …after verifying it, in a real one
}

fn caller(c: *nilo.Ctx) ?Str {
    const user = c.resolve(CurrentUser) catch return null;
    return user.id;
}

Worked out once per request, so a handler that also takes CurrentUser does not authenticate twice. Null from the function is a 403: an endpoint that keeps answers per caller cannot keep one for nobody. Leave .by off only on an endpoint with a single caller — an internal webhook receiver, say.

The two refusals that keep a key honest

A key is for retrying one request. Two things a client can do with it are mistakes, and both are answered before the handler runs, with the header named:

409 the same key is still being answered. The first request is in flight and the second arrived before it finished — a client retrying too soon. It waits by asking again
422 the same key on a different request: another body, path, query or method. The four are fingerprinted with the key, because answering the old order to a new body is the wrong order shipped

And a 400 for no key at all, or one over 255 bytes. In the OpenAPI document the route carries the header as a required parameter and the two extra answers.

What is kept, and what is not

What the handler returned is kept, whatever the status. A Status(201, Order), a Response(T) with a Location, a Status(409, Problem) the handler chose — all kept, all replayed. So is an answer a type wrote itself with nilo_write: the record carries its nilo_content_type, and the replay goes out under the same label (ADR 157). What the handler failed with is not. A fail.conflict(…), a fail.unprocessable(…), an error.Disconnected from the database: the answer goes out, the key is released, and the next retry runs the handler again — which is what a retry after a failure is for.

Two things cannot be kept, and asking for them is a Refusal rather than a surprise on the first replay: a handler that holds the Ctx and writes its own response has nothing nilo can send again, and a FileBody or a Redirect is a file on disk or a status with a Location, not a body. Answer with the thing that was made and let the client follow it.

What it costs

On the route that asks, and nowhere else. A fresh request: one cache claim, one arena allocation to encode the answer, one cache write, and the JSON buffer the answer was going to take anyway. A replay: one cache read into an arena allocation of max_bytes. Nothing on the stack — a Held there would be max_bytes per idle connection for the life of it (ADR 062), which is why the cache grew getInto.

The claim is the part worth knowing about: putIfAbsent takes the shard's lock around the scan and the write, so two requests racing for one key get one handler run between them wherever their threads are. A get followed by a put would have run both (ADR 155).

Testing

A handler with an Idempotent argument is still an ordinary function: placeOrder(.{ .key = .static("k") }, .{ .sku = .static("A1"), .qty = 1 }) in a test, with no cache anywhere. The replay is tested through the test client — send twice with one key, and assert the second answer carries Idempotent-Replayed and the counter moved once.

See also