Retry with backoff

Defer a failed operation to a durable retry with exponential backoff. Worked examples for Stripe (idempotency-key handling for timeouts and custom dunning); same shape applies to CRM syncs, webhook deliveries, and any external call that occasionally fails.

Use this to
  • Retry a Stripe payment that timed out using the same idempotency key (Stripe deduplicates against the original)
  • Run a custom invoice dunning sequence beyond Stripe Smart Retries with a new key per attempt
  • Retry a CRM sync that timed out during signup
  • Retry a 500-failing webhook delivery with backoff over the next day
Code
Stripe timeout: reuse the idempotency key on retry
// the call timed out, so you don't know whether Stripe processed it.
// retry with the SAME idempotency key. Stripe returns the cached
// result if the original succeeded, so the customer is never charged
// twice. don't auto-retry card_declined — the bank already said no
// and the cached error will come back unchanged.
try {
  await stripe.paymentIntents.create(
    { amount, customer, confirm: true },
    { idempotencyKey: `charge-${order.id}` },
  );
} catch (err) {
  if (isTransientError(err)) {
    await dk.schedule("retry-charge", { key: order.id, delay: "30s" });
  }
}

dk.handle("retry-charge", {
  handler: async ({ key }) => {
    const order = await db.orders.find(key);
    if (order.status === "paid") return; // resolved by another path

    await stripe.paymentIntents.create(
      { amount: order.amount, customer: order.customerId, confirm: true },
      { idempotencyKey: `charge-${key}` }, // SAME key as the original
    );
  },
  retry: { attempts: 3, backoff: "exponential" },
});
Stripe invoice dunning: a new idempotency key per attempt
// after Smart Retries exhaust (or if you've turned it off),
// run your own dunning schedule. each attempt is a NEW payment,
// so each gets its own idempotency key. reusing the key would
// dedupe back to the first failure and never actually retry.
dk.handle("retry-invoice", async ({ key }) => {
  const [invoiceId, attempt] = key.split(":");
  const invoice = await stripe.invoices.retrieve(invoiceId);
  if (invoice.status !== "open") return; // paid or voided

  await stripe.invoices.pay(invoiceId, undefined, {
    idempotencyKey: `dunning-${invoiceId}-${attempt}`,
  });
});
npm install delaykit
bun add delaykit