fix(stripe): finalize webhook idempotency after handler runs (BUNYIP-210) #233
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/bunyip-210-webhook-idempotency-ordering"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Fixes BUNYIP-210: a successful Stripe payment could leave the user with no application/entitlement access.
Root cause
The BUNYIP-89 idempotency fence in
stripe_webhookrecordedevent.idbefore running the matched handler, on a connection separate from the handler's work. If a handler then failed, Stripe retried, the fence saw the recorded row, returned200, and the handler never ran again. Membership activation (subscription_status) and per-product entitlement grants were silently lost.The common trigger:
customer.subscription.createdoften arrives before the user'sstripe_customer_idis linked, sofind_by_stripe_customer_idreturnsnot_foundon first delivery. First delivery errored, the fence swallowed every retry. Because per-application entitlements are granted only by the subscription handlers (sync_stripe_entitlements), the user ended up with active membership status but zero entitlements: "payment succeeded, no access to the application."Fix
Replace record-before-process with a claim/finalize/release lifecycle, so an event is treated as "already handled" only once its handler actually succeeded:
status = 'processing'.done(finalize_webhook_event).release_webhook_event); the original error still propagates as non-2xx.processingclaim left by a crashed handler is reclaimable after a 15-minute lease. The replacement re-runs the handler, whose DB writes (status updates) and entitlement sync (revoke-all-then-grant) are idempotent.409so Stripe retries after backoff instead of double-running.Migration
20260625000020_stripe_webhook_events_processing_status.sqladds astatuscolumn defaulting todone(legacy rows, recorded by the old inline-handler code, are treated as already-processed) plus a(status, received_at)index for the claim lookup. Queries are runtime sqlx, so no.sqlx/offline cache regen is required.Testing
just check-migrationspasses (versions unique and strictly increasing).cargo clippy -p bunyip-api --all-targetsclean;cargo fmt --checkclean.Out of scope (separate ticket suggested)
handle_checkout_completedreads the locked price fromsession["line_items"](webhook.rs), which Stripe omits from thecheckout.session.completedpayload unless expanded, so price-locking stores placeholder data. Noted in BUNYIP-210; worth its own ticket.