Example: two Workers over a service binding
A checkout Worker charges orders through a payments Worker, reached over a service binding by RPC (its Payments entrypoint). The payments Worker keeps its promise: a charge is idempotent per key, and a second call with the same key gets the first charge back. The checkout draws a fresh key for each charge. Clients resend a payment request that got no answer.
Run the check
From examples/service-binding in the repository:
pnpm install
pnpm checkThe scenario in checkout.sim.ts names the payments Worker with callee(), so both Workers' real code runs in one simulation, as Cloudflare runs them on one thread: the RPC call is one effect that hosts the payments Worker's invocation, and the payments database is payments/DB. The check reports:
VIOLATED checkout (seed checkout)
Invariant one-charge-per-order is violated: 2 rows of charges in D1:payments/DB share order_id = "o-2"
Schedule (run seed ...), events kept: req-2
crash_after at req-2#1 effect #0 (Service:PAYMENTS charge)The payments Worker charged the order, and the checkout invocation died before it answered. The client resent the request, the checkout drew a new key, and the payments Worker, idempotent per key as it promises, charged the order again. Each Worker is right by its own contract; the two together are not, which only a run of both Workers' code shows.
The fix
worker.fixed.ts derives the key from the order (order:<id>), so a resend carries the key of the first attempt and gets its charge back. pnpm check:fixed reports BOUNDED.
Using it in your own project
callee(binding, { entry, config }) runs the Worker behind a service binding for real; eutaxy check reads its wrangler config and adds its bindings under its name (payments/DB), and the entrypoint and props the caller's binding names reach it. To check a caller against a Worker you do not want to run, service(binding, …) scripts its answers instead.
The code
The complete project is examples/service-binding in the repository.
/**
* A checkout Worker that charges an order through the payments Worker,
* reached over a service binding by RPC. The payments Worker is idempotent
* per key, so the checkout sends a fresh key with each charge. Clients
* resend a payment request that got no answer. Run `eutaxy check
* ./checkout.sim.ts` to see both Workers run together, and what the key
* protects against and what it does not.
*/
import type { Payments } from "./payments/worker.ts";
export interface Env {
readonly PAYMENTS: Service<Payments>;
}
/** The key the payments Worker deduplicates by; the planted checkout draws one per request. */
export function chargeKey(_orderId: string): string {
return crypto.randomUUID();
}
export function checkout(key: (orderId: string) => string): ExportedHandler<Env> {
return {
async fetch(request: Request, env: Env): Promise<Response> {
const match = /^\/orders\/([\w-]+)\/pay$/.exec(new URL(request.url).pathname);
if (request.method !== "POST" || !match?.[1]) {
return new Response("not found", { status: 404 });
}
const orderId = match[1];
const { amountCents } = (await request.json()) as { amountCents: number };
const charge = await env.PAYMENTS.charge(key(orderId), orderId, amountCents);
return Response.json({ orderId, chargeId: charge.chargeId });
},
};
}
export default checkout(chargeKey);/**
* The payments Worker: other Workers charge through its `Payments`
* entrypoint over a service binding. A charge is idempotent per key, as
* payment APIs promise: a second call with the same key gets the first
* charge back and charges nothing more.
*/
import { WorkerEntrypoint } from "cloudflare:workers";
export interface Env {
readonly DB: D1Database;
}
export interface Charge {
readonly chargeId: number;
}
export class Payments extends WorkerEntrypoint<Env> {
async charge(idempotencyKey: string, orderId: string, amountCents: number): Promise<Charge> {
await this.env.DB.prepare(
"INSERT INTO charges (idempotency_key, order_id, amount_cents) VALUES (?1, ?2, ?3) ON CONFLICT (idempotency_key) DO NOTHING",
)
.bind(idempotencyKey, orderId, amountCents)
.run();
const row = await this.env.DB.prepare(
"SELECT charge_id FROM charges WHERE idempotency_key = ?1",
)
.bind(idempotencyKey)
.first<{ charge_id: number }>();
if (!row) throw new Error(`no charge for ${idempotencyKey}`);
return { chargeId: row.charge_id };
}
}
export default {
async fetch(): Promise<Response> {
return new Response("payments is reached through its entrypoint", { status: 404 });
},
} satisfies ExportedHandler<Env>;import { callee, d1Rows, d1Schema, defineScenario, http, unique } from "eutaxy/cloudflare";
export default defineScenario({
name: "checkout",
worker: { entry: "./worker.ts", config: "./wrangler.jsonc" },
setup: [
// The payments Worker runs for real in the same simulation; its database is `payments/DB`.
callee("PAYMENTS", { entry: "./payments/worker.ts", config: "./payments/wrangler.jsonc" }),
d1Schema("payments/DB", "./payments/schema.sql"),
],
// A client pays two orders and resends a request it got no answer for, up to three times.
drive: http(
[
{ path: "/orders/o-1/pay", body: { amountCents: 2500 } },
{ path: "/orders/o-2/pay", body: { amountCents: 900 } },
],
{ retries: 3 },
),
invariants: [
// An order is charged once, whatever the keys.
unique("one-charge-per-order", d1Rows("payments/DB", "charges"), "order_id"),
],
bounds: { maxFaultsPerRun: 1, maxRedeliveries: 3, maxConcurrentInvocations: 1, budgetMs: 2000 },
});/**
* The same checkout, made safe to resend: the key is derived from the
* order, so a resend after a lost answer carries the key of the first
* attempt and the payments Worker hands that charge back.
* `eutaxy check ./checkout.fixed.sim.ts` reports BOUNDED.
*/
import { checkout } from "./worker.ts";
export default checkout((orderId) => `order:${orderId}`);