Implementing Defence Against Duplicate Delivery
Goal
Build a double charge yourself, then block it with an idempotency key, and handle concurrent requests and body mismatches to complete a defense at the level of a real payment API.
Why it matters
In delivery across a network, exactly-once in the pure sense is close to impossible. This is because when an acknowledgement has not arrived, the sender cannot distinguish "failed to arrive" from "arrived but only the response was lost". So the practical answer is settled — send at-least-once and remove duplicates on the receiving side. The most subtle part of this lab is step 5. A naive implementation that looks up the key and stores it if absent lets both requests through when two arrive at the same time. You must merge the lookup and the claim atomically with a single Redis SET ... NX. The difference of this one line decides whether double-charge reports come in during a promotion.
Steps
- Start
/root/idem/pay.pyon 127.0.0.1:8130. On startup, initialize the Redis keybalanceto 10000, andPOST /payreceives{"amount":100}, deducts the balance, and returns{"charged":100,"balance":<잔액>}(the balance is the remaining balance). - Write the result of sending the same request twice without idempotency to
/root/idem/double.outin the formbalance_before=10000 balance_after=9800. - Change
pay.pyto require theIdempotency-Keyheader. If the header is absent, return 400. - If you send twice with the same key, the first response is repeated as is and the balance is deducted only once. In
/root/idem/dedup.out, writebalance_after=9900 calls=2. - Even if you send 2 requests concurrently with the same key, it is processed only once. In
/root/idem/concurrent.out, writeprocessed=1 balance_delta=100. - If you send a different amount with the same key, return 422. In
/root/idem/mismatch.out, writestatus=422. - The remaining lifetime of the idempotency key must be greater than 0 and at most 86400. In
/root/idem/ttl.txt, writettl=<초>(seconds).
Notes
- Atomic claim:
SET idem:<key> IN_PROGRESS NX EX 86400— if this command returns 0, someone has already claimed it. - Creating concurrent requests:
curl ... & curl ... & wait - For a definitive failure (insufficient balance), cache the response; for a transient failure (timeout), release the key so that the retry is really attempted again.
- Common mistake: a two-step implementation that looks up and then stores — the gap in between is the race window.
Start the payment service
Start /root/idem/pay.py on 127.0.0.1:8130. On startup, initialize the Redis key balance to 10000, and POST /pay receives {"amount":100}, deducts the balance, and returns {"charged":100,"balance":<잔액>} (the balance is the remaining balance).
If you keep the balance in Redis, the state persists even after a restart. Initialize the starting balance to the given value.
Reproduce the double charge
Write the result of sending the same request twice without idempotency to /root/idem/double.out in the form balance_before=10000 balance_after=9800.
If you send the same payment request twice, the balance is deducted twice. This is the state you must prevent.
Accept the idempotency key header
Change pay.py to require the Idempotency-Key header. If the header is absent, return 400.
It is safer to reject with 400 when the header is absent. Forcing the client to generate the key is the contract.
Process the same key only once
If you send twice with the same key, the first response is repeated as is and the balance is deducted only once. In /root/idem/dedup.out, write balance_after=9900 calls=2.
Return the first response you stored for the key as is. The balance must not change on the second request.
Process only once even under concurrent requests
Even if you send 2 requests concurrently with the same key, it is processed only once. In /root/idem/concurrent.out, write processed=1 balance_delta=100.
If you look up and then store, another request steps in between. You must claim with a single atomic command.
Reject when the key is the same but the body differs
If you send a different amount with the same key, return 422. In /root/idem/mismatch.out, write status=422.
If you store a hash of the first request's body along with it, you can compare. The status code is 422.
Set the key expiration
The remaining lifetime of the idempotency key must be greater than 0 and at most 86400. In /root/idem/ttl.txt, write ttl=<초> (seconds).
If you keep keys forever, memory grows without limit. 24 hours is the convention. Look up the remaining lifetime to check.