Guide · 05

Call methods safely.

Call queries and mutations from typed TypeScript, the CLI or plain HTTP. A request ID makes retries safe.

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.

client.ts
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);
The same mutation over HTTP
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"}'
HTTP method routes
EndpointBody
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 type adds 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:

Automatic retries
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: true retries 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) and backoff(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? }.

Common errors
ResponseWhat to do
422 EVALUATION_FAILEDYour code failed; nothing committed. Read failure.code: yours, or INVALID_ARGUMENT, INVALID_RECORD, COMPUTE_ERROR…
403 FORBIDDENAccess was denied. failure.code is UNAUTHENTICATED, FORBIDDEN or your own.
422 TRANSACTION_ABORTEDA participant failed; failure is its failure. Final for that request ID.
404 METHOD_NOT_FOUNDUse a name the current deployment exposes. Removing a name also rejects old retries through it.
409 REQUEST_ID_REUSEDThat ID was already used with different arguments.
409 REVISION_CONFLICTRead the current state, then decide whether to send a new operation.
503 UNAVAILABLEWait 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.

Authorize callers

Send tokens in credentials, never in arguments, so a refreshed token doesn't break a retry. The app decides what they mean:

Credentials on every call
const client = new FlowerClient<typeof counter>("https://flower.example", {
  credentials: async () => `Bearer ${await session.token()}`,
});

Access control shows how an app authenticates callers and sets access per method.

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.

  1. Initialize the history with admin.controlRetention.
  2. Create the client with boundedRetries: true.
  3. Save await client.newRequestId() durably before sending.
  4. Retire an epoch only after the retry window you promised has passed.
  • RETRY_WINDOW_EXPIRED means 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.

bank.ts
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’s value when it has one.
  • If any participant fails, nothing commits and the caller gets 422 TRANSACTION_ABORTED with that participant's failure, here INSUFFICIENT_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.