Call a method
Point the client at any node; nodes forward writes to the leader. Give it your module's type and every alias, argument and result is checked.
import { FlowerClient } from "@flower-js/sdk";
import type counter from "./counter.ts";
const client = new FlowerClient<typeof counter>("http://127.0.0.1:7101");
const { value, revision, duplicate } = await client.mutate("counter.increment",
{ id: "visits", by: 1 }, { requestId: "visit-002" });
const { value: now } = await client.query("counter.get", "visits");
console.log(value, revision, duplicate, now.count, now.doubled);curl -fsS http://127.0.0.1:7101/v1/call \
-H 'Content-Type: application/json' \
-d '{"name":"counter.increment","args":{"id":"visits","by":1},"requestId":"visit-003"}'| Endpoint | Body |
|---|---|
POST /v1/call | {name, args?, requestId?, expectedRevision?, credentials?}. Works for any exposed method. |
POST /v1/query | {name, args?, credentials?}. Queries only. |
POST /v1/mutate | {name, args?, requestId, expectedRevision?, credentials?}. Mutations and transactions only. |
import typeadds nothing to your bundle. Leave out the type parameter and calls are untyped.- All three routes return
{revision, value, duplicate}. - Over HTTP, mutations need a
requestId. The SDK makes one up if you omit it.
Retry safely
After a timeout, a dropped connection, an election or 503 UNAVAILABLE, send the same request ID with the same arguments, to any node. The client can do it for you:
const client = new FlowerClient<typeof counter>("http://127.0.0.1:7101", { retry: true });
await client.mutate("counter.increment", { id: "visits", by: 1 }, {
requestId: "visit-004", // Save it first if the process itself might restart.
retry: { attempts: 5, until: Date.now() + 30_000 },
});- If the first attempt committed, you get its original result with
duplicate: true. retry: trueretries network errors, timeouts, 408, 425, 429 and 5xx: eight attempts, 250 ms doubling with jitter up to 30 s, 20 s per attempt. A method's own failure is never retried.- Every attempt reuses one request ID, so a lost reply can't apply twice. Use a new ID only for a new operation.
- A timed-out mutation may still have committed. Never retry it under a new ID.
- For compare-and-set, pass
expectedRevision. For your own loops,isTransient(error)andbackoff(attempt)make the same decisions.
Handle failures
Every HTTP failure becomes a FlowerError with status and code. When your code, the access check or a transaction participant rejected the call, failure holds its { code, message, details? }.
| Response | What to do |
|---|---|
422 EVALUATION_FAILED | Your code failed; nothing committed. Read failure.code: yours, or INVALID_ARGUMENT, INVALID_RECORD, COMPUTE_ERROR… |
403 FORBIDDEN | Access was denied. failure.code is UNAUTHENTICATED, FORBIDDEN or your own. |
422 TRANSACTION_ABORTED | A participant failed; failure is its failure. Final for that request ID. |
404 METHOD_NOT_FOUND | Use a name the current deployment exposes. Removing a name also rejects old retries through it. |
409 REQUEST_ID_REUSED | That ID was already used with different arguments. |
409 REVISION_CONFLICT | Read the current state, then decide whether to send a new operation. |
503 UNAVAILABLE | Wait for the leader or queue to recover, then retry with the same request ID. |
Network errors and aborts throw ordinary errors, so don't assume every failure is a FlowerError.
Limit how long retry results are kept
Flower keeps results so retries can be answered. To bound that, operators use retry windows (epochs) and retire old ones.
- Initialize the history with
admin.controlRetention. - Create the client with
boundedRetries: true. - Save
await client.newRequestId()durably before sending. - Retire an epoch only after the retry window you promised has passed.
RETRY_WINDOW_EXPIREDmeans the mutation may have committed. Don't resend it under a new ID; check your own records.- Windows move only when an operator advances them. They aren't TTLs.
Long-running clients, work queues and restores
Long-running clients can use openRetrySession, and call acknowledgeRetrySession only after durably handling results up to that point. Acknowledged sequences never run again.
Queue claims then carry a history identity; pass it back unchanged. After a restore, old claims are rejected. External systems must check an increasing fencing token too; lease expiry can't stop a paused worker.
See the retention reference for budgets and restores.
Transactions across partitions
A transaction calls exposed methods in several partitions or Raft groups. All of them commit, or none do. participant types each call against the methods it targets.
import { collection, define, fail, mutation, participant, transaction, v } from "@flower-js/sdk";
const balances = collection("balances", v.int({ min: 0 }));
const entry = v.object({ id: v.string({ min: 1 }), cents: v.int({ min: 1 }) });
const debit = mutation("debit", { args: entry }, (ctx, { id, cents }) => {
const balance = ctx.get(balances, id) ?? 0;
if (balance < cents) fail("INSUFFICIENT_FUNDS", `${id} has ${balance}`, { balance });
ctx.set(balances, id, balance - cents);
return balance - cents;
});
const credit = mutation("credit", { args: entry }, (ctx, { id, cents }) => {
ctx.set(balances, id, (ctx.get(balances, id) ?? 0) + cents);
return ctx.get(balances, id);
});
const accounts = { debit, credit };
const transfer = transaction("transfer", {
args: v.object({ from: v.string(), to: v.string(), cents: v.int({ min: 1 }) }),
}, ({ from, to, cents }) => ({
calls: [
participant<typeof accounts>({ partition: "west" }).call("debit", { id: from, cents }),
participant<typeof accounts>({ partition: "east" }).call("credit", { id: to, cents }),
],
value: { cents },
}));
const app = define({ http: { ...accounts, transfer } });
export default app;- The plan sees only its arguments; business checks belong in the participant methods. A call returns
{ results }in plan order, plus the plan’svaluewhen it has one. - If any participant fails, nothing commits and the caller gets
422 TRANSACTION_ABORTEDwith that participant'sfailure, hereINSUFFICIENT_FUNDS. - Until it resolves, each partition involved blocks fresh reads and writes. Others stay available. Opt-in replica-local reads can still see the older state.
See the transaction reference and the transaction guide for setup and recovery.