TT Lab
Get started
Learn Learning paths Courses

The Language of Banking

A Client That Got No Response Sends It Again

Continue in TT Lab

In one line

An idempotency key is a label for a request that the client attaches and sends, and with that label the server remembers "this request has already been processed," blocking the second execution and returning the first response as it is.

Why this was needed

It is not rare for a client that sent a request to a transfer API not to get a response. The gateway's timeout may have been shorter than the server's processing, a load balancer may have cut the connection, or the phone may have gone into a basement. At this point, the client knows only one fact — it did not get a response. It does not know whether the money went out or not.

The client is left with two choices here. Give up, or send again. If it gives up, the user believes "the transfer did not go through" and presses again. In the end, the request goes through once more anyway. So eliminating retries themselves is not the answer; the answer is making retries safe.

Section 9.2.2 of RFC 9110 defines this property per method. If the intended effect on the server of sending the same request several times is the same as sending it once, the method is idempotent, and PUT, DELETE, and the safe methods belong here. The same section states that idempotent methods are distinguished because they can be retried automatically when communication is cut before the response is read, and stipulates that a client must not carelessly auto-retry a non-idempotent method. A transfer is a POST. That is, the protocol gives no guarantee. We must build the guarantee.

How it works

The way to build it was settled long ago. The client attaches one unique key to each request, and the server stores that key. The IETF draft draft-ietf-httpapi-idempotency-key-header organizes this practice into a document. A draft is not a standard — it has no RFC number and its content may change. Even so, it is the best-organized writing on this problem right now, so it serves as a common language in practice.

The draft sets four skeleton elements.

The draft also proposes the error codes. If a different body comes with the same key, it is 422 (Unprocessable Content), and if the earlier request is still being processed, it is 409 (Conflict). The difference between the two is that what the client has to do differs. With 422 the request must be fixed, and with 409 there is nothing to fix and it can simply ask again a moment later.

On the storage side, the core of this design is one line. Put a UNIQUE constraint on the key table and use the failure of the second INSERT itself as the verdict. "Look up first and insert if absent" has a gap between the lookup and the insert, so two retries that come in at the same moment both see "absent" and both execute. SQLite's ON CONFLICT clause decides what to do when a constraint is violated from five options, ROLLBACK, ABORT, FAIL, IGNORE, and REPLACE, and the default is ABORT. The thing to watch out for here is INSERT OR IGNORE. It quietly skips the conflicting row, so the code cannot tell whether it is a retry or a new request. What we need is an exception — in Python, it comes up as the IntegrityError of sqlite3.

요청 + Idempotency-Key
      │
      ├─ 키 선점 INSERT 성공  →  이체 실행 → 응답 저장(completed) → 201
      └─ UNIQUE 위반          →  지문 다름  → 422
                                 처리 중    → 409
                                 완료됨    → 저장한 응답을 그대로 재생

What it looks like in the field

First, the most common implementation is one that does not look at the fingerprint. If you answer "it already exists, so success" by looking at the key alone, a request that a teller resent from the same screen after correcting the amount is silently ignored. The customer believes they sent 300,000 won, but what actually went out is 200,000 won. This incident is not even left as an error in the logs.

Second, comparing the body as a string. If the client library changes the order of JSON fields or whitespace, the same request becomes a different fingerprint and 422s pour in. So you compute the fingerprint after normalizing. RFC 8785 (JSON Canonicalization Scheme) specifies this normalization — it sorts object keys in code point order, removes whitespace, fixes the notation of numbers and strings to one form, and then serializes as UTF-8. In Python, json.dumps(obj, sort_keys=True, separators=(",", ":")) is a usable approximation in practice (it does not follow RFC 8785's number notation rules exactly).

Third, there is no in-progress state. If you write the key as "completed" first and then execute the transfer, only the key is left if the process dies midway through execution. A later retry gets the answer "already processed," and the money never goes out. Conversely, if you do the transfer first and write the key afterward, a duplicate occurs. So you separate claim (in_progress) and completion (completed) and record in two steps.

Fourth, the key scope is not decided. If the key is global, then when another customer happens to send the same string, they get someone else's response. The scope is usually set as three things (customer, endpoint, key). The retention period must be decided together — the draft above also says to publish the expiry policy in documentation. If you delete the key after the period passes, a later retry becomes a new request, so the retention period must be more generous than the client's retry limit.

What really matters in practice

What you will do in the next lab

You build that day's request log yourself and first tally the duplicate transfers created by a system without idempotency protection. Then you attach in turn the idempotency key table and the UNIQUE constraint, the normalized fingerprint, the in-progress state, response replay, and the key scope and retention period. The grader actually runs your endpoint with different accounts, amounts, and keys each time and cross-checks the responses and balances, and also sends two retries that arrive at the same moment. At the end, you replay a full day's requests without dropping any and prove that duplicate transfers come to 0.