Idempotency — Two Clicks, One Charge
Two Clicks, One Charge
Goal
On a network, retries cannot be avoided. When a client has not received a response, it has no way to tell whether the request never arrived or arrived and only the response did not come back.
So the server has to prevent duplicates. In this lab you fix a payment API that was deliberately made non-idempotent.
Getting started
cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1 # 키 k1 로 결제
./ledger.sh # 지금까지 적립된 것
Add setsid nohup. If you start it with just &, it dies together with the shell when the shell changes.
Where to fix
In server.py, it is just handle_pay. The idem table has already been created: key, request hash, status, response, time.
When you restart the server
ps -eo pid,args | awk '$2 ~ /python3$/ && $3 == "server.py" {print $1}' | xargs -r kill
Do not use pkill -f server.py. That pattern also matches the command line of the shell that ran this command,
and kills itself.
Steps
- Reproduce the duplicate →
01-duplicate.txt - The same key gets the same answer → the grader checks directly
- Reject when there is no key →
03-nokey.txt - Same key, different body →
04-conflict.txt - Concurrent requests →
05-race.txt - Remember even after a restart →
06-persist.txt - Retry policy →
07-retry.md - Wrap-up →
08-notes.md
Notes
In steps 2, 5, and 6, the grader starts the server itself and sends requests. Copying a log over will not pass.
A retry makes a duplicate payment
Start the server, send the same request twice, check the ledger, and leave the result in 01-duplicate.txt.
cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1
./probe.sh k1
./ledger.sh
payment_id goes up to 1 and 2, and the ledger has 2 entries. The client thinks it paid once, because it only retried because of a timeout.
On a network, retries cannot be avoided. When no response comes, the client cannot tell whether the request never arrived or arrived and only the response did not come back.
The same key gets the same answer
In server.py, fix handle_pay so that if the same Idempotency-Key comes twice, the second one is not posted and returns the first response as it is.
The idem table has already been created: key, request hash, status, response, time.
If it is the first request, post it and store the response. If the key already exists, return the stored response as it is. Do not create a new one.
Check: send it twice and ./ledger.sh should show 1 entry. And the payment_id of the two responses must be the same.
Reject it if there is no key
Make a request that arrives without an Idempotency-Key get rejected with 400, and leave the result in 03-nokey.txt.
If you call ./probe.sh with no arguments, it goes without a key.
If you make the key optional for a request that moves money, there will inevitably be clients that do not send a key. And those clients create duplicate payments. It is better to make it required from the start.
When the same key comes with a different body
Make the server reject with 422 when a different amount is sent with the same key, and leave the result in 04-conflict.txt.
Store the hash of the request body in idem.request_hash and compare it.
./probe.sh k1 '{"user":"u1","amount":1000}'
./probe.sh k1 '{"user":"u1","amount":99999}'
If you silently let the second one through, you return a 1000-won response to a 99999-won request. A bug that reuses keys gets buried silently.
When the same key arrives at the same time
Send 10 requests at the same time with the same key, check that the ledger has 1 entry, and leave the result in 05-race.txt.
pids=""
for i in $(seq 10); do ./probe.sh race >/dev/null 2>&1 & pids="$pids $!"; done
wait $pids
./ledger.sh
Be sure to write the PIDs after wait. If you use just wait, it also waits for the server you started in the background in step 1, and since the server never ends, the terminal simply freezes.
The naive approach of "look up first and insert if absent" breaks here: ten requests read "absent" at the same time.
To do it properly, put a unique constraint on the key and make the side whose insert fails wait, or wrap it in a lock. idem.key is already the primary key.
Does it remember even if the process is turned off and on
After one payment, turn the server off and on, send again with the same key, and leave in 06-persist.txt whether duplicates still do not appear.
If you implemented it with an in-memory dictionary, it breaks here, because it forgets on restart.
In practice it breaks for a more common reason: there are several servers. What Pod 1 remembers, Pod 2 does not know. So the storage has to be in a place everyone looks at together (here it is sqlite; in practice a DB or Redis).
Which errors should be retried
In 07-retry.md, separate the responses that may be retried from those that must not, and write the reason for each. At least 4 kinds.
Things to think about: 500, 503, 429, 400, 422, and the case where no response was received at all.
One hint: if you retry 400, it is 400 forever. And when retrying, do it with growing intervals (exponential backoff), and shake it (jitter) so that several clients do not retry at the same time. Otherwise the retries knock down the recovering server again.
Sum up the three things
Write at least three lines in 08-notes.md: why retries cannot be avoided, where to store the idempotency key, and why you must reject it when the same key comes with a different body.
Your text must contain the words 재시도, 저장, and 본문 (the Korean words for retry, storage, and body, in that order).