Skip to content

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:

sh
pnpm install
pnpm check

The 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.

ts
/**
 * 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);
ts
/**
 * 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>;
ts
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 },
});
ts
/**
 * 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}`);