Idempotency — Two Clicks, One Charge
A validly signed webhook arrived twice: design principles
In one line
You connect signature over the raw body, a time window, and event deduplication into a single webhook processing path.
Why this was needed
The payment provider did not receive a response and sent the same event again. The server thought it was a normal request because the signature matched, and added the revenue again. A signature is evidence about who sent it; it is not evidence that this is the first time the request is being processed. You have to check separately the time window that allows resends and the stored event id.
How it works
What is signed is the timestamp string and the raw body bytes. If you re-serialize the JSON and then compare signatures, the whitespace or key order changes and a normal request is rejected. Use HMAC-SHA256 and compare_digest, and check the time difference in both directions. A valid event records its id and body fingerprint in the inbox, and commits it in the same transaction as the revenue total. An exception in the middle rolls back both writes.
원문+시각 → 서명·시간 검사 → inbox id+본문지문 → 합계 반영 → 동일 트랜잭션
Worksheet: read the contract and predict the failure
What follows is not an answer sheet for memorizing the implementation, but a step-by-step code review. Each changed fragment deliberately breaks the contract. Note that normal cases may still pass after the change. Before running, predict which input, exception, or state you would have to observe to reveal the difference, and after implementing, compare that prediction with the result.
1. Preserve the raw bytes to sign
signed_bytes(timestamp, body) accepts only an int timestamp (not bool) and a bytes body, and returns str(timestamp).encode()+b'.'+body. A wrong type is a ValueError.
Basis for the judgment: parsing the raw body as JSON and rebuilding it changes what is signed.
The wrong changed fragment to review:
+ b":" + body
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
2. Compute the HMAC
signature(secret, timestamp, body) is the hex string obtained by applying HMAC-SHA256 to signed_bytes with a bytes secret.
Basis for the judgment: use the standard HMAC instead of appending a secret to an ordinary hash.
The wrong changed fragment to review:
body, hashlib.sha256
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
3. Reject a wrong signature
verify(secret, timestamp, body, supplied) is True when supplied is a str and equals the computed signature by compare_digest, and False otherwise.
Basis for the judgment: whether a signature is present and whether it is correct are different checks.
The wrong changed fragment to review:
signature(secret,timestamp,body), signature(secret,timestamp,body)
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
4. Limit replay from the past and the future
fresh(timestamp, now, tolerance=300) is True if timestamp and now are ints (not bool) and abs(now-timestamp)<=tolerance. Otherwise it is False. tolerance is a positive int given by the caller.
Basis for the judgment: if you unconditionally allow a future timestamp, an attacker can extend the validity period.
The wrong changed fragment to review:
(now-timestamp)
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
5. Get ready to store the event and the effect together
init_db(path) creates inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL) and total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL), and inserts id=1,amount=0 into total without duplicates.
Basis for the judgment: the duplicate event record and the business total must be in the same DB transaction.
The wrong changed fragment to review:
VALUES (1,1)
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
6. Roll back a failure between the record and the revenue
apply(path, event, fault=lambda:None) validates that the id of event is a non-empty str and that amount is a positive int (not bool). It computes the fingerprint of id and amount as canonical JSON. The same id and fingerprint gives False, a different fingerprint is a ValueError, and a new event returns True after inbox insert → fault() → total increase.
Basis for the judgment: if an exception occurs in fault, neither the inbox nor the total must remain.
The wrong changed fragment to review:
db.commit()
fault()
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
7. Read the total on a separate connection
total(path) returns the integer amount of the row with id=1 in total.
Basis for the judgment: instead of the number of callback executions, check the business effect that actually remains in the DB.
The wrong changed fragment to review:
SELECT id FROM total WHERE id=1
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
8. Handle a real webhook request
create_app(path, secret, clock) reads the raw body, X-Timestamp, and X-Signature at POST /webhook. A bad time format, time window, or signature failure is 401, a JSON parse failure is 400, and a ValueError from apply is 409. The normal case is 200 {accepted:True, duplicate:False on first processing}.
Basis for the judgment: verify the signature first and then read the JSON, and also return a duplicate as a normal acknowledgment response.
The wrong changed fragment to review:
"duplicate":False
Compare it with the public contract of the function that contains this fragment. If a single success case cannot tell the difference, choose as the observation target an input that should be rejected or the state left after a failure.
What it looks like in the field
This signature format is a teaching protocol and does not stand in for the specification of any real payment provider. Use only a secret meant for learning. The reliability of the server clock, key rotation, the allowed body size, and the permanent retention period are separate operational tasks. Deduplication applies to the same event id with the same body, and reusing the same id with a different body is a conflict.
What you will do in the next lab
The eight steps connect into one runnable deliverable. Preserve the raw bytes to sign → compute the HMAC → reject a wrong signature → limit replay from the past and the future → get ready to store the event and the effect together → roll back a failure between the record and the revenue → read the total on a separate connection → handle a real webhook request.
Each step checks not the fact that a function or file exists but the actual return values, exceptions, and state changes. After you see the answer, deliberately change a boundary comparison or the cleanup code and check which test fails. Explain why the earlier tests are kept in the next step too, and write down one operational condition that this lab does not guarantee.