Nobody has started this yet — be first.
Business impact
Stripe explicitly documents that webhook delivery is at-least-once, not exactly-once -- it retries on timeouts and non-2xx responses, and it can also, rarely, deliver the same event twice close together under completely normal operation. This project's answer to that is ProcessedWebhookEvent.stripe_event_id being unique plus a "have we seen this already?" check. That check has a race window: two near-simultaneous deliveries of the same event can both pass the "not seen yet" check before either has committed, and the loser of that race now hits a database uniqueness violation with nothing catching it -- an unhandled exception, a 500 back to Stripe, and a retry storm that keeps happening every time delivery happens to overlap like this.
Problem
process_event checks whether an event id has already been processed and, later, separately commits the new ProcessedWebhookEvent row, with no locking between the two round-trips -- and nothing catches the unique-constraint violation when two concurrent deliveries both pass the check.
Current behavior
The already = ProcessedWebhookEvent.query.filter_by(stripe_event_id=event_id).first() check and the later db.session.commit() are two separate round-trips; if two concurrent transactions both run the SELECT before either has committed its INSERT, both see "not seen yet" and both proceed -- one succeeds, and the other's commit() raises an uncaught IntegrityError from the unique constraint on stripe_event_id.
Expected behavior
When two deliveries of the identical event race like this, exactly one succeeds cleanly ("processed"), and the other resolves as "already_processed" (or an equivalent success outcome) -- not an unhandled exception, not a 500. Exactly one ProcessedWebhookEvent row ends up persisted, and the event's effect (e.g. a subscription activation) is applied exactly once regardless of which request won.
Steps to reproduce
Why this matters
This is a textbook check-then-act (TOCTOU) race, and the fix is not "check harder" -- no amount of re-reading the SELECT closes the window between two independent connections. The database's own unique constraint is already the real safety net here (that is why stripe_event_id is unique=True on the model); what is missing is handling the constraint violation as the expected, benign outcome it is, rather than letting it propagate as an unhandled error.
Suggested approach
Wrap db.session.commit() in a try/except for exactly this constraint violation, treat it as "someone else already recorded this event id," roll back the session, and return the same "already_processed" outcome the pre-check path returns. sqlalchemy.exc has the relevant exception type.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_13_webhook_idempotency_race.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b fix/webhook-idempotency-raceFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/webhook-idempotency-raceSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inacmeglobexmake seed
*.localhost subdomains resolve to 127.0.0.1 out of the box in curl and every modern browser -- no /etc/hosts editing needed:
curl -s -X POST http://acme.localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
--cookie-jar /tmp/acme.cookies --cookie /tmp/acme.cookies \
-d '{"email": "owner@acme.test", "password": "owner-acme"}'
curl -s http://acme.localhost:5000/api/tenant/settings --cookie /tmp/acme.cookies
For tooling that cannot fake subdomains (some HTTP clients, CI), send X-Tenant-Slug instead -- honored only when TRUST_TENANT_HEADER=true (the Docker dev setup enables it; production does not):
curl -s http://localhost:5000/api/tenant/settings -H "X-Tenant-Slug: acme"
You do not strictly need Docker to work the tickets, but -- unlike a project that can fake its backing services -- this one genuinely needs a real Postgres and Redis, because the whole point is proving isolation against the real database, not a mock. Without Docker: cp .env.example .env, point DATABASE_URL/ADMIN_DATABASE_URL/TEST_DATABASE_URL/TEST_ADMIN_DATABASE_URL/REDIS_URL at a local Postgres/Redis (docker/init-db.sql documents the exact roles/databases the app expects -- notably the saas_admin/BYPASSRLS role migrations need and the plain saas role every request uses), then:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.venv/bin/flask db upgrade
.venv/bin/pytest practice_tickets/tests/test_ticket_07_webhook_signature_raw_body.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
./practice_tickets/run_tickets.sh 07 12 13 # just a few, by number
All 15 tests fail at the start; as you fix each ticket in app/ (or, for TICKET-12, in migrations/versions/0002_row_level_security.py, plus re-applying it), its test -- and only its test -- should flip to passing. make test runs the project's own main suite; several of its tests (RBAC, tenant-spoofing, and billing-webhook tests among them) fail at the start for the same underlying reasons as specific tickets -- fixing those tickets fixes those failures too, not a separate task.
Level 1
Fix a bug
Read existing behaviour, correct it.