racetrap 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ name: publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ pypi:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v7
16
+ - uses: astral-sh/setup-uv@v10.3.3
17
+ - run: uv build
18
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,33 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ include:
15
+ - { python: "3.11", postgres: "14" }
16
+ - { python: "3.14", postgres: "18" }
17
+ services:
18
+ postgres:
19
+ image: postgres:${{ matrix.postgres }}
20
+ env:
21
+ POSTGRES_PASSWORD: postgres
22
+ ports: ["5432:5432"]
23
+ options: >-
24
+ --health-cmd pg_isready
25
+ --health-interval 2s
26
+ --health-retries 30
27
+ env:
28
+ RACETRAP_TEST_DATABASE: postgresql://postgres:postgres@localhost:5432/postgres
29
+ steps:
30
+ - uses: actions/checkout@v7
31
+ - uses: astral-sh/setup-uv@v10.3.3
32
+ - run: uv sync --python ${{ matrix.python }}
33
+ - run: uv run pytest -q
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ .venv/
3
+ .pytest_cache/
4
+ dist/
5
+ racetrap-report.html
racetrap-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rahul Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: racetrap
3
+ Version: 0.1.0
4
+ Summary: Finds race conditions in web APIs by forcing the worst order of their SQL statements.
5
+ Project-URL: Repository, https://github.com/w-rahul/racetrap
6
+ Author: Rahul Gupta
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: concurrency,isolation,postgres,race condition,testing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Database
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: psycopg[binary]>=3.2
18
+ Description-Content-Type: text/markdown
19
+
20
+ <img src="https://raw.githubusercontent.com/w-rahul/racetrap/main/docs/icon.svg" width="72" alt="">
21
+
22
+ # racetrap
23
+
24
+ Finds race conditions in web APIs by forcing the worst order of their SQL statements.
25
+
26
+ ![racetrap catching a double booking, then passing once the fix is in](https://raw.githubusercontent.com/w-rahul/racetrap/main/docs/demo.gif)
27
+
28
+ ## Why it exists
29
+
30
+ ```python
31
+ async with conn.transaction():
32
+ seats = await conn.fetchval("SELECT seats_left FROM shows WHERE id = $1", show_id)
33
+ if seats < 1:
34
+ raise HTTPException(409, "sold out")
35
+ await conn.execute("UPDATE shows SET seats_left = $1 WHERE id = $2", seats - 1, show_id)
36
+ await conn.execute("INSERT INTO bookings (show_id, user_name) VALUES ($1, $2)", show_id, user)
37
+ ```
38
+
39
+ This passes every test and runs inside a transaction. Still, when two people book the last seat at the same moment, both get it. At Postgres's default isolation level both requests read `seats_left = 1` before either one writes.
40
+
41
+ Tests send one request at a time, so they never see it. In production it shows up rarely, under load, and nobody can reproduce it. racetrap makes it show up on demand, in a test, with the exact order of statements that caused it.
42
+
43
+ ## What it does
44
+
45
+ racetrap sits between your app and Postgres as a proxy. For every pair of requests:
46
+
47
+ 1. It runs them one at a time, in both orders. Those are the correct outcomes.
48
+ 2. It runs them again, holding the first request right before each of its SQL statements in turn while the second one runs.
49
+ 3. Any result that matches neither one at a time run is a race, reported with the order that caused it.
50
+
51
+ Each forced order gives the same result on every run. There is no load test and no luck involved.
52
+
53
+ ```
54
+ $ racetrap examples/tickets/buggy.toml
55
+ racetrap 0.1.0
56
+ proxy 127.0.0.1:6499 -> localhost:5432
57
+ app http://127.0.0.1:8765
58
+
59
+ alice books vs alice books 5 orders RACE
60
+ alice books vs bob books 5 orders RACE
61
+ bob books vs alice books 5 orders RACE
62
+ bob books vs bob books 5 orders RACE
63
+
64
+ 4 of 4 pairs race. 34 runs in 4.7s.
65
+ Report: racetrap-report.html
66
+ ```
67
+
68
+ ![The report for one race: both requests as lanes, the hold, the outcome and the fix](https://raw.githubusercontent.com/w-rahul/racetrap/main/docs/report.png)
69
+
70
+ ## What it does not do
71
+
72
+ - **It does not replace transactions, locks or tests.** You keep writing normal code. racetrap shows where the transaction you have is not enough, and the fix stays a line you write: `FOR UPDATE`, a conditional `UPDATE ... WHERE`, a unique constraint, or `SERIALIZABLE` with a retry.
73
+ - **It never changes your code, and it is not for production.** Point it at a test database you can wipe. It only watches and delays SQL.
74
+ - **It is not a load test.** It does not fire thousands of requests and hope. It forces each order once.
75
+ - **It does not see everything.** Postgres only, two requests at a time, and locks held outside Postgres, like Redis or an in-process mutex, are invisible to it.
76
+
77
+ ## Benchmark
78
+
79
+ `benchmarks/burst.py` does what people try without racetrap: it fires alice and bob at the same instant, 1,000 times, on the ticket example. On one Windows machine it caught the double booking in 68.5%, 81.6% and 79.8% of bursts across three runs, and the rate changed every time. racetrap caught it on every run, in all 4 pairs, named the exact order of statements, and checked the fix against every order it forced.
80
+
81
+ ## Try it
82
+
83
+ You need Python 3.11 or newer, [uv](https://docs.astral.sh/uv/) and a Postgres you can wipe.
84
+
85
+ ```sh
86
+ git clone https://github.com/w-rahul/racetrap
87
+ cd racetrap
88
+ uv sync
89
+ export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
90
+ uv run racetrap examples/tickets/buggy.toml
91
+ uv run racetrap examples/tickets/fixed.toml
92
+ ```
93
+
94
+ The first run finds the race. The second one has `FOR UPDATE` on the read and passes.
95
+
96
+ To run it on your own app, install the command with `uv tool install git+https://github.com/w-rahul/racetrap` and write a scenario file.
97
+
98
+ ## Scenario file
99
+
100
+ ```toml
101
+ app = "http://127.0.0.1:8000"
102
+ database = "${DATABASE_URL}"
103
+ command = "uvicorn shop:app --port 8000"
104
+
105
+ setup = [
106
+ "TRUNCATE orders, stock RESTART IDENTITY",
107
+ "INSERT INTO stock VALUES (1, 1)",
108
+ ]
109
+
110
+ [[request]]
111
+ name = "alice buys"
112
+ path = "/items/1/buy"
113
+ json = { user = "alice" }
114
+
115
+ [[request]]
116
+ name = "bob buys"
117
+ path = "/items/1/buy"
118
+ json = { user = "bob" }
119
+ ```
120
+
121
+ | Key | Meaning |
122
+ | --- | --- |
123
+ | `app` | Base URL of the app. |
124
+ | `database` | The real Postgres, used directly for setup and snapshots. |
125
+ | `setup` | SQL run before every order. It must reset the data. |
126
+ | `[[request]]` | `name`, `path`, and optional `method`, `json` and `headers`. |
127
+ | `command` | Optional. Starts the app with `DATABASE_URL` pointing at the proxy. |
128
+ | `init` | Optional SQL run once before the app starts. |
129
+ | `invariants` | Optional SQL that must return true after every order. |
130
+ | `ignore` | Optional `table.column` entries to leave out of the comparison. |
131
+ | `refs` | Optional id references without a foreign key, like `{ "orders.user_id" = "users.id" }`. |
132
+ | `proxy`, `database_env`, `schema`, `stall_ms` | Defaults `127.0.0.1:6499`, `DATABASE_URL`, `public` and `1000`. |
133
+
134
+ `${NAME}` is read from the environment. Each request is also paired with itself, which catches double submits.
135
+
136
+ ## How it decides
137
+
138
+ The check is serializability. Every outcome must match running the two requests one after the other, in some order. racetrap compares the HTTP status of each request and the rows of every table.
139
+
140
+ Values the database fills in differ between correct runs, so ids, timestamps and random values are left out, and a reference to a generated id is compared by the row it points to. A deadlock or an unretried serialization failure that turns into a 500 counts as a race. racetrap stops instead of passing if the app dies, and the exit code is 1 when any pair races, so it can fail a CI job.
141
+
142
+ ## Prior work
143
+
144
+ Finding these bugs from database traces comes from [ACIDRain](https://www.bailis.org/papers/acidrain-sigmod2017.pdf) (SIGMOD 2017), which found 22 attacks in 12 popular e-commerce apps. [RaceDB](https://par.nsf.gov/biblio/10617180) (IEEE S&P 2025) confirms candidates by replaying them. [ChaosSQL](https://github.com/bregaldahq/chaossql) and [Deadheat](https://github.com/sanjayrawatt/deadheat) also proxy the database, but check a rule you write and randomize the timing. racetrap needs no rule and gives the same answer on every run.
145
+
146
+ ## License
147
+
148
+ MIT
@@ -0,0 +1,129 @@
1
+ <img src="https://raw.githubusercontent.com/w-rahul/racetrap/main/docs/icon.svg" width="72" alt="">
2
+
3
+ # racetrap
4
+
5
+ Finds race conditions in web APIs by forcing the worst order of their SQL statements.
6
+
7
+ ![racetrap catching a double booking, then passing once the fix is in](https://raw.githubusercontent.com/w-rahul/racetrap/main/docs/demo.gif)
8
+
9
+ ## Why it exists
10
+
11
+ ```python
12
+ async with conn.transaction():
13
+ seats = await conn.fetchval("SELECT seats_left FROM shows WHERE id = $1", show_id)
14
+ if seats < 1:
15
+ raise HTTPException(409, "sold out")
16
+ await conn.execute("UPDATE shows SET seats_left = $1 WHERE id = $2", seats - 1, show_id)
17
+ await conn.execute("INSERT INTO bookings (show_id, user_name) VALUES ($1, $2)", show_id, user)
18
+ ```
19
+
20
+ This passes every test and runs inside a transaction. Still, when two people book the last seat at the same moment, both get it. At Postgres's default isolation level both requests read `seats_left = 1` before either one writes.
21
+
22
+ Tests send one request at a time, so they never see it. In production it shows up rarely, under load, and nobody can reproduce it. racetrap makes it show up on demand, in a test, with the exact order of statements that caused it.
23
+
24
+ ## What it does
25
+
26
+ racetrap sits between your app and Postgres as a proxy. For every pair of requests:
27
+
28
+ 1. It runs them one at a time, in both orders. Those are the correct outcomes.
29
+ 2. It runs them again, holding the first request right before each of its SQL statements in turn while the second one runs.
30
+ 3. Any result that matches neither one at a time run is a race, reported with the order that caused it.
31
+
32
+ Each forced order gives the same result on every run. There is no load test and no luck involved.
33
+
34
+ ```
35
+ $ racetrap examples/tickets/buggy.toml
36
+ racetrap 0.1.0
37
+ proxy 127.0.0.1:6499 -> localhost:5432
38
+ app http://127.0.0.1:8765
39
+
40
+ alice books vs alice books 5 orders RACE
41
+ alice books vs bob books 5 orders RACE
42
+ bob books vs alice books 5 orders RACE
43
+ bob books vs bob books 5 orders RACE
44
+
45
+ 4 of 4 pairs race. 34 runs in 4.7s.
46
+ Report: racetrap-report.html
47
+ ```
48
+
49
+ ![The report for one race: both requests as lanes, the hold, the outcome and the fix](https://raw.githubusercontent.com/w-rahul/racetrap/main/docs/report.png)
50
+
51
+ ## What it does not do
52
+
53
+ - **It does not replace transactions, locks or tests.** You keep writing normal code. racetrap shows where the transaction you have is not enough, and the fix stays a line you write: `FOR UPDATE`, a conditional `UPDATE ... WHERE`, a unique constraint, or `SERIALIZABLE` with a retry.
54
+ - **It never changes your code, and it is not for production.** Point it at a test database you can wipe. It only watches and delays SQL.
55
+ - **It is not a load test.** It does not fire thousands of requests and hope. It forces each order once.
56
+ - **It does not see everything.** Postgres only, two requests at a time, and locks held outside Postgres, like Redis or an in-process mutex, are invisible to it.
57
+
58
+ ## Benchmark
59
+
60
+ `benchmarks/burst.py` does what people try without racetrap: it fires alice and bob at the same instant, 1,000 times, on the ticket example. On one Windows machine it caught the double booking in 68.5%, 81.6% and 79.8% of bursts across three runs, and the rate changed every time. racetrap caught it on every run, in all 4 pairs, named the exact order of statements, and checked the fix against every order it forced.
61
+
62
+ ## Try it
63
+
64
+ You need Python 3.11 or newer, [uv](https://docs.astral.sh/uv/) and a Postgres you can wipe.
65
+
66
+ ```sh
67
+ git clone https://github.com/w-rahul/racetrap
68
+ cd racetrap
69
+ uv sync
70
+ export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
71
+ uv run racetrap examples/tickets/buggy.toml
72
+ uv run racetrap examples/tickets/fixed.toml
73
+ ```
74
+
75
+ The first run finds the race. The second one has `FOR UPDATE` on the read and passes.
76
+
77
+ To run it on your own app, install the command with `uv tool install git+https://github.com/w-rahul/racetrap` and write a scenario file.
78
+
79
+ ## Scenario file
80
+
81
+ ```toml
82
+ app = "http://127.0.0.1:8000"
83
+ database = "${DATABASE_URL}"
84
+ command = "uvicorn shop:app --port 8000"
85
+
86
+ setup = [
87
+ "TRUNCATE orders, stock RESTART IDENTITY",
88
+ "INSERT INTO stock VALUES (1, 1)",
89
+ ]
90
+
91
+ [[request]]
92
+ name = "alice buys"
93
+ path = "/items/1/buy"
94
+ json = { user = "alice" }
95
+
96
+ [[request]]
97
+ name = "bob buys"
98
+ path = "/items/1/buy"
99
+ json = { user = "bob" }
100
+ ```
101
+
102
+ | Key | Meaning |
103
+ | --- | --- |
104
+ | `app` | Base URL of the app. |
105
+ | `database` | The real Postgres, used directly for setup and snapshots. |
106
+ | `setup` | SQL run before every order. It must reset the data. |
107
+ | `[[request]]` | `name`, `path`, and optional `method`, `json` and `headers`. |
108
+ | `command` | Optional. Starts the app with `DATABASE_URL` pointing at the proxy. |
109
+ | `init` | Optional SQL run once before the app starts. |
110
+ | `invariants` | Optional SQL that must return true after every order. |
111
+ | `ignore` | Optional `table.column` entries to leave out of the comparison. |
112
+ | `refs` | Optional id references without a foreign key, like `{ "orders.user_id" = "users.id" }`. |
113
+ | `proxy`, `database_env`, `schema`, `stall_ms` | Defaults `127.0.0.1:6499`, `DATABASE_URL`, `public` and `1000`. |
114
+
115
+ `${NAME}` is read from the environment. Each request is also paired with itself, which catches double submits.
116
+
117
+ ## How it decides
118
+
119
+ The check is serializability. Every outcome must match running the two requests one after the other, in some order. racetrap compares the HTTP status of each request and the rows of every table.
120
+
121
+ Values the database fills in differ between correct runs, so ids, timestamps and random values are left out, and a reference to a generated id is compared by the row it points to. A deadlock or an unretried serialization failure that turns into a 500 counts as a race. racetrap stops instead of passing if the app dies, and the exit code is 1 when any pair races, so it can fail a CI job.
122
+
123
+ ## Prior work
124
+
125
+ Finding these bugs from database traces comes from [ACIDRain](https://www.bailis.org/papers/acidrain-sigmod2017.pdf) (SIGMOD 2017), which found 22 attacks in 12 popular e-commerce apps. [RaceDB](https://par.nsf.gov/biblio/10617180) (IEEE S&P 2025) confirms candidates by replaying them. [ChaosSQL](https://github.com/bregaldahq/chaossql) and [Deadheat](https://github.com/sanjayrawatt/deadheat) also proxy the database, but check a rule you write and randomize the timing. racetrap needs no rule and gives the same answer on every run.
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,74 @@
1
+ """How often does firing two requests at the same instant catch the double booking?
2
+
3
+ This is what people try without racetrap. It starts the ticket example app
4
+ straight on the database, fires alice and bob together behind a barrier, and
5
+ counts how often the last seat gets sold twice.
6
+
7
+ DATABASE_URL=postgresql://... uv run python benchmarks/burst.py 1000
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from pathlib import Path
19
+
20
+ import psycopg
21
+
22
+ APP = Path(__file__).parent.parent / "examples" / "tickets"
23
+ URL = "http://127.0.0.1:8765"
24
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
25
+
26
+
27
+ def book(user, barrier):
28
+ req = urllib.request.Request(
29
+ f"{URL}/shows/1/book", data=json.dumps({"user": user}).encode(),
30
+ headers={"content-type": "application/json"}, method="POST",
31
+ )
32
+ barrier.wait()
33
+ try:
34
+ opener.open(req, timeout=10).close()
35
+ except urllib.error.HTTPError:
36
+ pass # the loser gets 409, which is the correct answer
37
+
38
+
39
+ def main(trials):
40
+ db = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
41
+ db.execute("DROP TABLE IF EXISTS bookings, shows")
42
+ db.execute("CREATE TABLE shows (id int PRIMARY KEY, title text NOT NULL, seats_left int NOT NULL)")
43
+ db.execute("CREATE TABLE bookings (id serial PRIMARY KEY, show_id int NOT NULL REFERENCES shows, user_name text NOT NULL)")
44
+ app = subprocess.Popen(
45
+ [sys.executable, "-m", "uvicorn", "app:app", "--port", "8765", "--log-level", "warning"], cwd=APP,
46
+ )
47
+ try:
48
+ for _ in range(100):
49
+ try:
50
+ opener.open(URL, timeout=1).close()
51
+ break
52
+ except urllib.error.HTTPError:
53
+ break
54
+ except OSError:
55
+ time.sleep(0.1)
56
+ caught = 0
57
+ for _ in range(trials):
58
+ db.execute("TRUNCATE bookings, shows RESTART IDENTITY")
59
+ db.execute("INSERT INTO shows VALUES (1, 'Dune', 1)")
60
+ barrier = threading.Barrier(2)
61
+ threads = [threading.Thread(target=book, args=(u, barrier)) for u in ("alice", "bob")]
62
+ for t in threads:
63
+ t.start()
64
+ for t in threads:
65
+ t.join()
66
+ caught += db.execute("SELECT count(*) FROM bookings").fetchone()[0] > 1
67
+ print(f"{caught} of {trials} bursts caught the double booking ({caught / trials:.1%})")
68
+ finally:
69
+ app.terminate()
70
+ app.wait()
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main(int(sys.argv[1]) if len(sys.argv) > 1 else 1000)
Binary file
@@ -0,0 +1,19 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="112" fill="#17181B"/>
3
+ <path d="M138 214 C 128 160 140 112 162 96 C 186 104 214 132 232 160 Z" fill="#9A968F"/>
4
+ <path d="M374 214 C 384 160 372 112 350 96 C 326 104 298 132 280 160 Z" fill="#9A968F"/>
5
+ <path d="M158 190 C 154 156 160 128 170 118 C 184 128 198 144 208 160 Z" fill="#2A2B2F"/>
6
+ <path d="M354 190 C 358 156 352 128 342 118 C 328 128 314 144 304 160 Z" fill="#2A2B2F"/>
7
+ <path d="M380 258 C 408 246 440 230 476 222 C 466 240 456 254 450 268 C 428 268 404 270 380 274 Z" fill="#E0483E"/>
8
+ <path d="M380 276 C 408 280 436 294 466 320 C 448 320 430 316 416 310 C 404 300 392 290 380 284 Z" fill="#B93A31"/>
9
+ <path d="M256 134 C 330 134 380 170 394 228 C 400 256 412 282 432 304 L 400 314 L 424 340 L 384 344 C 360 392 312 416 256 416 C 200 416 152 392 128 344 L 88 340 L 112 314 L 80 304 C 100 282 112 256 118 228 C 132 170 182 134 256 134 Z" fill="#D9D6D0"/>
10
+ <path d="M256 140 C 268 170 270 200 262 236 L 250 236 C 242 200 244 170 256 140 Z" fill="#5A5853"/>
11
+ <path d="M112 270 C 132 236 168 222 204 228 C 226 232 242 240 256 250 C 270 240 286 232 308 228 C 344 222 380 236 400 270 C 384 300 352 312 318 306 C 292 302 272 290 256 280 C 240 290 220 302 194 306 C 160 312 128 300 112 270 Z" fill="#E0483E"/>
12
+ <circle cx="200" cy="266" r="21" fill="#F3F1EC"/>
13
+ <circle cx="312" cy="266" r="21" fill="#F3F1EC"/>
14
+ <circle cx="209" cy="268" r="10" fill="#17181B"/>
15
+ <circle cx="321" cy="268" r="10" fill="#17181B"/>
16
+ <ellipse cx="256" cy="352" rx="74" ry="50" fill="#F3F1EC"/>
17
+ <path d="M232 318 C 232 306 280 306 280 318 C 280 330 264 342 256 342 C 248 342 232 330 232 318 Z" fill="#17181B"/>
18
+ <path d="M256 342 L 256 356 C 262 366 276 366 286 358" fill="none" stroke="#17181B" stroke-width="7" stroke-linecap="round"/>
19
+ </svg>
Binary file
@@ -0,0 +1,59 @@
1
+ """A tiny ticket shop with the race most booking code has, and two fixes."""
2
+
3
+ import os
4
+ from contextlib import asynccontextmanager
5
+
6
+ import asyncpg
7
+ from fastapi import FastAPI, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class Booking(BaseModel):
12
+ user: str
13
+
14
+
15
+ @asynccontextmanager
16
+ async def lifespan(app):
17
+ app.state.pool = await asyncpg.create_pool(os.environ["DATABASE_URL"], min_size=2)
18
+ yield
19
+ await app.state.pool.close()
20
+
21
+
22
+ app = FastAPI(lifespan=lifespan)
23
+
24
+
25
+ async def check_then_book(show_id, user, lock):
26
+ read = "SELECT seats_left FROM shows WHERE id = $1" + (" FOR UPDATE" if lock else "")
27
+ async with app.state.pool.acquire() as conn, conn.transaction():
28
+ seats = await conn.fetchval(read, show_id)
29
+ if not seats:
30
+ raise HTTPException(409, "sold out")
31
+ await conn.execute("UPDATE shows SET seats_left = $1 WHERE id = $2", seats - 1, show_id)
32
+ await conn.execute("INSERT INTO bookings (show_id, user_name) VALUES ($1, $2)", show_id, user)
33
+ return {"booked": user}
34
+
35
+
36
+ @app.post("/shows/{show_id}/book")
37
+ async def book(show_id: int, booking: Booking):
38
+ """Reads the seats, checks, writes. Inside a transaction. Every test passes."""
39
+ return await check_then_book(show_id, booking.user, lock=False)
40
+
41
+
42
+ @app.post("/shows/{show_id}/book-locked")
43
+ async def book_locked(show_id: int, booking: Booking):
44
+ """FOR UPDATE makes a second buyer wait until the first one commits."""
45
+ return await check_then_book(show_id, booking.user, lock=True)
46
+
47
+
48
+ @app.post("/shows/{show_id}/book-atomic")
49
+ async def book_atomic(show_id: int, booking: Booking):
50
+ """Checks and writes in one statement, so there is no gap to race in."""
51
+ async with app.state.pool.acquire() as conn, conn.transaction():
52
+ left = await conn.fetchval(
53
+ "UPDATE shows SET seats_left = seats_left - 1 WHERE id = $1 AND seats_left > 0 RETURNING seats_left",
54
+ show_id,
55
+ )
56
+ if left is None:
57
+ raise HTTPException(409, "sold out")
58
+ await conn.execute("INSERT INTO bookings (show_id, user_name) VALUES ($1, $2)", show_id, booking.user)
59
+ return {"booked": booking.user}
@@ -0,0 +1,26 @@
1
+ # One seat left. Alice and Bob both try to book it.
2
+
3
+ app = "http://127.0.0.1:8765"
4
+ database = "${DATABASE_URL}"
5
+ command = "python -m uvicorn app:app --port 8765 --log-level warning"
6
+
7
+ init = [
8
+ "DROP TABLE IF EXISTS bookings, shows",
9
+ "CREATE TABLE shows (id int PRIMARY KEY, title text NOT NULL, seats_left int NOT NULL)",
10
+ "CREATE TABLE bookings (id serial PRIMARY KEY, show_id int NOT NULL REFERENCES shows, user_name text NOT NULL)",
11
+ ]
12
+
13
+ setup = [
14
+ "TRUNCATE bookings, shows RESTART IDENTITY",
15
+ "INSERT INTO shows VALUES (1, 'Dune', 1)",
16
+ ]
17
+
18
+ [[request]]
19
+ name = "alice books"
20
+ path = "/shows/1/book"
21
+ json = { user = "alice" }
22
+
23
+ [[request]]
24
+ name = "bob books"
25
+ path = "/shows/1/book"
26
+ json = { user = "bob" }
@@ -0,0 +1,26 @@
1
+ # The same shop, with FOR UPDATE on the read. Every order passes.
2
+
3
+ app = "http://127.0.0.1:8765"
4
+ database = "${DATABASE_URL}"
5
+ command = "python -m uvicorn app:app --port 8765 --log-level warning"
6
+
7
+ init = [
8
+ "DROP TABLE IF EXISTS bookings, shows",
9
+ "CREATE TABLE shows (id int PRIMARY KEY, title text NOT NULL, seats_left int NOT NULL)",
10
+ "CREATE TABLE bookings (id serial PRIMARY KEY, show_id int NOT NULL REFERENCES shows, user_name text NOT NULL)",
11
+ ]
12
+
13
+ setup = [
14
+ "TRUNCATE bookings, shows RESTART IDENTITY",
15
+ "INSERT INTO shows VALUES (1, 'Dune', 1)",
16
+ ]
17
+
18
+ [[request]]
19
+ name = "alice books"
20
+ path = "/shows/1/book-locked"
21
+ json = { user = "alice" }
22
+
23
+ [[request]]
24
+ name = "bob books"
25
+ path = "/shows/1/book-locked"
26
+ json = { user = "bob" }
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "racetrap"
3
+ version = "0.1.0"
4
+ description = "Finds race conditions in web APIs by forcing the worst order of their SQL statements."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Rahul Gupta" }]
10
+ keywords = ["race condition", "postgres", "concurrency", "testing", "isolation"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Database",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+ dependencies = ["psycopg[binary]>=3.2"]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/w-rahul/racetrap"
23
+
24
+ [project.scripts]
25
+ racetrap = "racetrap.cli:main"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest>=8", "fastapi>=0.115", "uvicorn>=0.30", "asyncpg>=0.30"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling>=1.27"]
32
+ build-backend = "hatchling.build"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())