Nobody has started this yet — be first.
Business impact
The entire security pitch of refresh-token rotation rests on "exactly one refresh token is ever valid at a time per session." If two requests presenting the same refresh token can both succeed -- which a flaky mobile client silently retrying a slow request, a misbehaving proxy replaying a request, or a genuine attacker racing a legitimate client can all trigger -- the system silently ends up with two live, independent refresh-token chains descending from one login, with no record anywhere that this happened and no alert.
Problem
rotate_refresh_token() reads a RefreshToken row, validates it, issues a new child token, then sets row.revoked_at / row.replaced_by and commits -- with no conditional guard tying that final UPDATE to the exact state that was read (no WHERE replaced_by IS NULL, no SELECT ... FOR UPDATE, no optimistic-locking version column). Two concurrent calls that both read the row while it is still unrotated will both pass validation, both create a child token, and both commit.
Current behavior
Two concurrent calls to rotate the exact same refresh token (synchronized with a threading.Barrier so both are guaranteed to pass validation before either writes) both succeed.
Expected behavior
When two requests race to rotate the same refresh token, exactly one succeeds. The other is rejected as a conflict (a new or reused exception type is fine -- RefreshTokenInvalid is a reasonable choice) rather than silently "also succeeding."
Steps to reproduce
Why this matters
This is one of the hardest tickets in this batch for a reason: it is not a wrong comparison operator or a missing line, it is an absence -- the kind of gap that is invisible by inspection because every individual statement in rotate_refresh_token() is correct in isolation. It only becomes a bug in the context of two callers running at once, which is exactly the class of defect that ships to production, passes every existing test, and only shows up under real concurrent load or a retry storm.
Suggested approach
Think about what SQLAlchemy primitive would make the final write conditional on the row's state not having changed since it was read. Two standard approaches: (a) a WHERE-guarded UPDATE (e.g. via RefreshToken.query.filter_by(id=row.id, replaced_by=None).update(...)) and checking how many rows it actually affected, or (b) pessimistic locking (with_for_update() on the initial SELECT, inside a transaction). Either is defensible; consider what each costs under SQLite (used in this project's tests) versus Postgres (used in production).
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_13.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/refresh-rotation-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/refresh-rotation-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 inhttp://localhost:5000make seedpyotpmake docker-prod-upYou do not need Docker running at all to work the tickets -- the test suite uses an in-memory SQLite database (via StaticPool, an isolated DB per test function) and fakeredis (TestingConfig.USE_FAKE_REDIS), so everything runs against a plain virtualenv:
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pytest practice_tickets/tests/test_ticket_01.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
All 15 tests fail at the start; as you fix each ticket in app/, its test (and only its test) should flip to passing. make test (pytest -q) runs the project's own main suite -- as of writing it shows 9 pre-existing failures caused by ticket bugs 02, 03, 07, 11, and 15; fixing those tickets fixes those failures too, not a separate task. Do not look at git diff / version control history to find the bugs if this repo is ever put under source control -- the point is to find them the way you would find a real bug: from a failing test and a description of broken behavior, not a diff.
Level 1
Fix a bug
Read existing behaviour, correct it.