TT Lab
Get started
Learn Learning paths Courses

Idempotency — Two Clicks, One Charge

The same key is not always the same request: design principles

Continue in TT Lab

In one line

Judge a resend by separating the key scope, the normalization of the body, and the conflict on reuse.

Why this was needed

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.

How it works

Validate the grammar of the string key, and tie the HTTP method and the exact path to the tenant. JSON key order and whitespace are normalized, but array order is preserved. NaN is not a standard JSON value, so it is rejected. The request fingerprint is computed with SHA-256, and if a different fingerprint arrives for a key in the same scope, it is treated as a conflict. The stored response is returned as a deep copy so that the caller cannot change the result of later resends.

tenant + method + path + key → 범위 키
본문 → 정규 JSON → 지문 → 최초 저장 / 같은 요청 재생 / 다른 요청 충돌

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. Validate the key grammar

valid_key(value) returns as is only 1–64 characters of letters, digits, underscores, and hyphens, and any other input is a ValueError.

Basis for the judgment: limit the key length and the allowed characters, and do not treat an empty key as a normal resend.

The wrong changed fragment to review:

{1,128}

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. Fold object order, preserve array order

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.

Basis for the judgment: if you sort an array, you change the order of operations the user requested.

The wrong changed fragment to review:

sort_keys=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.

3. Compute the body fingerprint

fingerprint(body) is a 64-character hex string obtained by applying SHA-256 to the UTF-8 bytes of canonical(body).

Basis for the judgment: Python's hash() changes from process to process, so it is not used as a stored fingerprint.

The wrong changed fragment to review:

hashlib.sha512(

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. Distinguish keys by tenant and operation

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=(',',':').

Basis for the judgment: encoding the structure makes the boundaries clearer than simply joining with a delimiter. The case of the path is preserved.

The wrong changed fragment to review:

method.upper(), path.lower(),

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. Distinguish the three verdicts

classify(record, digest) is 'new' if record=None, 'replay' if record['fingerprint']==digest, and 'conflict' otherwise.

Basis for the judgment: do not replay every re-request as a success merely because the key exists.

The wrong changed fragment to review:

else "replay"

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. Copy when storing the response

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.

Basis for the judgment: if you do not copy the lists inside the response either, the nested state is shared.

The wrong changed fragment to review:

response

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. Copy when reading the response too

replay(record) is a deep copy of record['response'].

Basis for the judgment: it keeps a caller that modified the first response from changing even the result of the next resend.

The wrong changed fragment to review:

record["response"]

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. Call the business function only once

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.

Basis for the judgment: 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.

The wrong changed fragment to review:

if state in ("new", "replay"):

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 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.

What you will do in the next lab

The eight steps connect into one runnable deliverable. Validate the key grammar → fold object order and preserve array order → compute the body fingerprint → distinguish keys by tenant and operation → distinguish the three verdicts → copy when storing the response → copy when reading the response too → call the business function only once.

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.