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:
| Step | Critical? | On Failure |
|---|---|---|
| Charge user | Yes | Abort entire operation |
| Provision access | Yes | Refund charge, abort |
| Credit meter | Yes | Revoke access, refund, abort |
| Send email | No | Log error, continue |
| Audit log | Depends | Queue 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.