Ignoring Partial Failures

Treating multi-step operations as atomic when each step can fail independently leads to inconsistent state and frustrated users.

The Wrong Way

async function purchaseSubscription(user, plan) {
  await stripe.charges.create({ amount: plan.price, customer: user.stripeId });
  await solenoid.gate.override('premium_access', { userId: user.id }, true);
  await solenoid.meter.credit(plan.credits);
  await sendEmail(user.email, 'Welcome to Premium!');
  await solenoid.witness.stamp('subscription.created', { userId: user.id });
  return { success: true };
}

This assumes all five steps either succeed together or fail together. They do not.

What Goes Wrong

Inconsistent state. If step 3 fails, the user is charged and has premium features but zero credits. They paid but cannot use what they bought.

Blind catch blocks. A generic catch does not tell you which steps completed. Telling the user “purchase failed, please try again” risks double-charging them.

Race conditions. User clicks “Purchase” twice. Both requests charge the user. Now they are billed double.

Example failure at step 3:

Step 1: Charge user      -- $99 charged
Step 2: Provision access  -- premium_access = true
Step 3: Update credits    -- network timeout
Step 4: Send email        -- never reached
Step 5: Audit log         -- never reached

Result: user paid, has features, but 0 credits

The Right Way

Saga with explicit state tracking

Track which steps completed. Implement compensation for each one.

async function purchaseSubscription(user, plan) {
  const sagaId = crypto.randomUUID();
  const completed = [];

  try {
    const charge = await stripe.charges.create({
      amount: plan.price,
      customer: user.stripeId,
      idempotencyKey: `${sagaId}:charge`
    });
    completed.push('charge');

    await provisionAccess(user, plan);
    completed.push('provision');

    await creditMeter(user, plan.credits, sagaId);
    completed.push('credit');

    // Non-critical: log but do not abort
    try { await sendEmail(user.email, 'Welcome!'); } catch {}

    await witnessStamp('subscription.created', { sagaId, userId: user.id });
    return { success: true, sagaId };

  } catch (error) {
    await compensate(completed, user, plan, sagaId);
    return { success: false, sagaId, error: error.message };
  }
}

Rollback in reverse order based on what actually completed:

async function compensate(completed, user, plan, sagaId) {
  if (completed.includes('credit'))
    await debitMeter(user, plan.credits, `${sagaId}:rollback`);
  if (completed.includes('provision'))
    await revokeAccess(user);
  if (completed.includes('charge'))
    await stripe.refunds.create({ charge: sagaId, idempotencyKey: `${sagaId}:refund` });
}

Latch for exactly-once processing

Prevent duplicate purchases with distributed locks:

const lock = await latch.acquire(`purchase:${user.id}:${plan.id}`, { ttl: 300 });
if (lock.status === 409) return { status: 'in_progress' };

try {
  return await executePurchase(user, plan);
} finally {
  await latch.release(`purchase:${user.id}:${plan.id}`);
}

Witness for audit trail

Record each step with cryptographic proof for debugging and compliance:

await witness.stamp('purchase.charged', { transactionId, chargeId, amount });
await witness.stamp('purchase.provisioned', { transactionId, features });
await witness.stamp('purchase.completed', { transactionId });

When something goes wrong, you have a tamper-proof record of exactly what happened.

Critical vs Non-Critical Steps

Not all failures are equal. Classify your steps:

StepCritical?On Failure
Charge userYesAbort entire operation
Provision accessYesRefund charge, abort
Credit meterYesRevoke access, refund, abort
Send emailNoLog error, continue
Audit logDependsQueue for retry

Critical steps must all succeed or roll back. Non-critical steps log failures and continue.

Checklist

Before deploying multi-step operations:

  • Do you know which steps completed when an error is thrown?
  • Can you rollback each step individually?
  • Are critical steps distinguished from non-critical steps?
  • Are operations idempotent (safe to retry)?
  • Do you have locks preventing duplicate processing?
  • Can support reconstruct the state from logs?

If any answer is no, you are ignoring partial failures.