POST  /orders; RESPONSE LOST; SAME KEY  /  SAME ORDER. Build an Idempotent Order API with Python and SQLite.
Original technical illustration of the synthetic teaching example.

When a create-order request times out, the client cannot tell whether the server committed the order. Retrying with the same purchase key lets the server return the saved result. Retrying with a new key starts a different purchase.

This tutorial builds a small HTTP API so you can see that distinction yourself. It deliberately closes a connection after committing an order, then accepts a retry. It also checks concurrent requests and recovery after restarting the server. The example creates local order records only; it never charges a wallet.

Download and start the API

Use Python 3.10 or later with sqlite3, and curl. The package has no third-party dependencies. The commands below use a macOS or Linux shell; on Windows, run the curl commands in WSL. The recorded checks used Python 3.13.3 on macOS.

Download the complete checkout retry lab, extract it and open a terminal inside the extracted directory. You should see checkout.py, test_checkout.py and README.txt.

python3 -B -m unittest -v
python3 checkout.py --db checkout-demo.sqlite --port 8765

The test suite creates its own temporary database and chooses a free local port. Seven tests passed in the recorded run. The second command leaves a server running on 127.0.0.1:8765. Keep that terminal open and use another terminal for the requests below. If port 8765 is occupied, choose another port and change the request URLs to match.

This is a local learning server. Python's documentation does not recommend http.server for production. The example also has no customer authentication or merchant isolation. Python HTTP server documentation.

Create one order, then repeat the request

The browser is allowed to choose a known SKU. The server's catalog owns the amount. In this example, field-kit costs the synthetic raw amount 100000000.

curl -i http://127.0.0.1:8765/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: reader-attempt-0001' \
  -d '{"sku":"field-kit"}'

The first response has status 201. Its JSON contains a generated order id, the SKU, the saved amount and status set to open. Run the same command again. You should receive 200 with the same JSON body and the same order ID.

The different status codes are deliberate in this teaching API. 201 means this request created the record; 200 means it recovered an existing one. Other APIs have different replay contracts. Stripe, for example, documents response retention and parameter comparison in its own idempotency interface. Match the contract of the system you integrate. Stripe idempotent requests.

The implementation keeps the transaction around both the lookup and the insert.

def create_order(db_path, key, body):
    if not re.fullmatch(r'[A-Za-z0-9_-]{8,128}', key or ''):
        return 400, {'error':'invalid_idempotency_key'}
    if not isinstance(body, dict) or set(body) != {'sku'} or body['sku'] not in CATALOG:
        return 400, {'error':'send_only_a_known_sku'}
    with closing(sqlite3.connect(db_path, timeout=10, isolation_level=None)) as db:
        db.execute('BEGIN IMMEDIATE')
        try:
            row = db.execute('SELECT id, sku, amount FROM orders WHERE request_key=?', (key,)).fetchone()
            if row:
                db.commit()
                if row[1] != body['sku']: return 409, {'error':'idempotency_conflict'}
                return 200, {'id':row[0], 'sku':row[1], 'amount':row[2], 'status':'open'}
            order_id, amount = str(uuid.uuid4()), CATALOG[body['sku']]
            db.execute('INSERT INTO orders VALUES (?,?,?,?)', (order_id,key,body['sku'],amount))
            db.commit()
            return 201, {'id':order_id, 'sku':body['sku'], 'amount':amount, 'status':'open'}
        except BaseException:
            db.rollback(); raise

The unique key is a database constraint, so it survives the Python process. BEGIN IMMEDIATE serializes the competing SQLite writers before the lookup. The server returns only after commit. See SQLite transaction behavior for the single-writer model behind this example.

There is a small but consequential choice in the replay branch. It returns the amount saved with the order. It does not calculate a new price from the catalog. A price change between attempts should not silently change the purchase already accepted under that key.

A create-order request committing one database record, losing its response and returning the same order when the purchase key is retried

The saved purchase key connects the retry to the order that already exists.

Reproduce the uncertain result

Now use a fresh key and the lab's fault-injection header.

curl -i http://127.0.0.1:8765/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: reader-attempt-0002' \
  -H 'X-Demo-Drop-Response: yes' \
  -d '{"sku":"field-kit"}'

Curl should report an empty response, typically exit code 52. The handler committed the order and then shut down the connection before sending response bytes. This is a controlled failure in the lab, not evidence of a real network incident.

Retry without the fault header, keeping the same key.

curl -i http://127.0.0.1:8765/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: reader-attempt-0002' \
  -d '{"sku":"field-kit"}'

The response should be 200. The test suite checks that the disconnected call left one database row and the retry did not add another. If you repeat the fault-injected call after running the example before, the saved order will already exist; the database filename is intentionally persistent.

Reject a changed purchase under the same key

Reuse reader-attempt-0001, but ask for the other SKU.

curl -i http://127.0.0.1:8765/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: reader-attempt-0001' \
  -d '{"sku":"field-notes"}'

Expect 409 and idempotency_conflict. The existing purchase remains unchanged. Sending an extra amount field instead returns 400; the client cannot override the server price through this endpoint.

The tests also send twelve HTTP requests from six client threads. One creates the order and the others recover it. Another test stops and restarts the server against the same database, then verifies that the retry still returns the original order. These checks exercise the local service; they do not measure production throughput or power-loss durability.

Connect the pattern to a payment checkout

StableIn's merchant example uses a similar purchase identity before calling the payment platform. It saves the local order first and derives a stable platform request key from that order. Its browser keeps the purchase key before sending the request. The merchant implementation uses MySQL and an actual platform client, whereas this lab stops at the local record.

For a real integration, define the authenticated merchant/customer scope of the key, its retention period and the request fields that must remain identical. Remove the lab-only disconnect header. A retry that happens after a key has been deleted also needs an explicit policy.

StableIn Payments connects the checkout to notification handling and delivery. Bring your existing create-order API and the point where a purchase becomes binding. Those are the details needed to adapt this retry behavior without creating a second order.