Idempotency — Two Clicks, One Charge
The same key is not always the same request
Goal
Judge a resend by separating the key scope, the normalization of the body, and the conflict on reuse.
Why it matters
When two customers happened to use the same idempotency key, one customer was returned the other customer's response. In another request, the amount was changed under the same key, yet the earlier success was returned. If you compare only the idempotency key itself, you miss the meaning of the request and the security boundary. The key must be tied to a tenant and an operation scope, and the meaning of the body must be checked with a separate fingerprint.
Steps
- In
/root/work/idem-fingerprint-lab/service.py, valid_key(value) returns as is only 1–64 characters of letters, digits, underscores, and hyphens, and any other input is a ValueError.
Prepare it once at the beginning. It does not overwrite an existing file.
mkdir -p /root/work/idem-fingerprint-lab
test -e /root/work/idem-fingerprint-lab/service.py || cp /opt/fixtures/ten_labs/idem-fingerprint-lab/service.py /root/work/idem-fingerprint-lab/service.py
cd /root/work/idem-fingerprint-lab
-
In
/root/work/idem-fingerprint-lab/service.py, canonical(body) accepts only a dict and returns a JSON string with sort_keys=True, separators=(',',':'), ensure_ascii=False, and allow_nan=False. Values that cannot be serialized are unified into a ValueError. -
In
/root/work/idem-fingerprint-lab/service.py, fingerprint(body) is a 64-character hex string obtained by applying SHA-256 to the UTF-8 bytes of canonical(body). -
In
/root/work/idem-fingerprint-lab/service.py, scoped_key(tenant, method, path, key) validates tenant and key with valid_key, and uppercases method. path must be a string that starts with /. It returns the four values encoded as a JSON array with separators=(',',':'). -
In
/root/work/idem-fingerprint-lab/service.py, classify(record, digest) is 'new' if record=None, 'replay' if record['fingerprint']==digest, and 'conflict' otherwise. -
In
/root/work/idem-fingerprint-lab/service.py, remember(records, key, digest, response) stores {fingerprint:digest, response:a deepcopy of response} under a new key. If it already exists, it is a ValueError and the existing record is preserved. -
In
/root/work/idem-fingerprint-lab/service.py, replay(record) is a deep copy of record['response']. -
In
/root/work/idem-fingerprint-lab/service.py, execute(records, tenant, method, path, key, body, action) computes the scope and the fingerprint. For new, it remembers the result of action() and returns a copy; for replay, it returns a copy of the existing response; and for conflict, it is a ValueError. An action exception is propagated and no record is left.
Notes
- Do it in the existing lab-dev environment, without the internet or package installation.
- Each step runs within a 45-second grading budget. Do not add real sleep or network calls.
- Grading freshly imports the submitted module and checks it with independent inputs and a temporary DB. Implement the contract instead of returning expected values as constants.
- FastAPI official documentation · pytest official documentation · Python sqlite3
- Limitation: this lab learns the contract that separates the key and the meaning of the request with a single-process memory dictionary. Preservation after a process failure and the concurrency of multiple workers are covered in the next SQLite lab. A hash is not encryption, and we do not claim that JSON normalization is an international standard that standardizes even the numeric representations of every language.
Validate the key grammar
In /root/work/idem-fingerprint-lab/service.py, valid_key(value) returns as is only 1–64 characters of letters, digits, underscores, and hyphens, and any other input is a ValueError.
Prepare it once at the beginning. It does not overwrite an existing file.
mkdir -p /root/work/idem-fingerprint-lab
test -e /root/work/idem-fingerprint-lab/service.py || cp /opt/fixtures/ten_labs/idem-fingerprint-lab/service.py /root/work/idem-fingerprint-lab/service.py
cd /root/work/idem-fingerprint-lab
Limit the key length and the allowed characters, and do not treat an empty key as a normal resend.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/01-contract.sh.
Fold object order, preserve array order
In /root/work/idem-fingerprint-lab/service.py, canonical(body) accepts only a dict and returns a JSON string with sort_keys=True, separators=(',',':'), ensure_ascii=False, and allow_nan=False. Values that cannot be serialized are unified into a ValueError.
If you sort an array, you change the order of operations the user requested.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/02-contract.sh.
Compute the body fingerprint
In /root/work/idem-fingerprint-lab/service.py, fingerprint(body) is a 64-character hex string obtained by applying SHA-256 to the UTF-8 bytes of canonical(body).
Python's hash() changes from process to process, so it is not used as a stored fingerprint.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/03-contract.sh.
Distinguish keys by tenant and operation
In /root/work/idem-fingerprint-lab/service.py, scoped_key(tenant, method, path, key) validates tenant and key with valid_key, and uppercases method. path must be a string that starts with /. It returns the four values encoded as a JSON array with separators=(',',':').
Encoding the structure makes the boundaries clearer than simply joining with a delimiter. The case of the path is preserved.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/04-contract.sh.
Distinguish the three verdicts
In /root/work/idem-fingerprint-lab/service.py, classify(record, digest) is 'new' if record=None, 'replay' if record['fingerprint']==digest, and 'conflict' otherwise.
Do not replay every re-request as a success merely because the key exists.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/05-contract.sh.
Copy when storing the response
In /root/work/idem-fingerprint-lab/service.py, remember(records, key, digest, response) stores {fingerprint:digest, response:a deepcopy of response} under a new key. If it already exists, it is a ValueError and the existing record is preserved.
If you do not copy the lists inside the response either, the nested state is shared.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/06-contract.sh.
Copy when reading the response too
In /root/work/idem-fingerprint-lab/service.py, replay(record) is a deep copy of record['response'].
It keeps a caller that modified the first response from changing even the result of the next resend.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/07-contract.sh.
Call the business function only once
In /root/work/idem-fingerprint-lab/service.py, execute(records, tenant, method, path, key, body, action) computes the scope and the fingerprint. For new, it remembers the result of action() and returns a copy; for replay, it returns a copy of the existing response; and for conflict, it is a ValueError. An action exception is propagated and no record is left.
You can know the resend contract only by checking the number of calls of the business function and even the record left after a failure.
After saving, check with bash /opt/lab/checks/idem-fingerprint-lab/08-contract.sh.