Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°58

Idempotency Key Handler

Easy FDEIntegrationsReliability

Networks retry. A customer’s webhook fires twice; a payment request is re-sent after a timeout. Without idempotency you double-charge or double-write. The fix: dedupe by a client-supplied key and always return the first result. Solve it in Python or TypeScript, with hidden tests.

The problem

Implement process(requests). Each request is [key, value]. The first time you see a key, record its value as the result. Any later request with the same key must return that first recorded result — the new value is ignored (the operation already happened). Return the list of results, one per request, in order.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement process(requests): keep a map of key → first result. For each request, if the key is new, store its value; then append the stored result for that key.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input requests = [["k1","a"],["k1","b"],["k2","c"],["k1","d"]]
Output ["a", "a", "c", "a"]
every k1 returns the first value "a"; the retries are no-ops
EXAMPLE 2
Input requests = [["a","1"],["b","2"],["a","9"],["b","8"]]
Output ["1", "2", "1", "2"]
interleaved keys each keep their own first result
EXAMPLE 3
Input requests = [["k",""],["k","x"]]
Output ["", ""]
edge case: an empty-string first value still wins; test membership, not truthiness
EXAMPLE 4
Input requests = []
Output []
edge case: no requests, no results
CONSTRAINTS
  • The result for a key is fixed by its first occurrence; later values for that key are discarded.
  • Return one result per request, preserving order.
  • This is exactly how an idempotency-key store makes a retried POST safe.
Hints 3 ideas
HINTS — 3 IDEAS
  1. Use a dict/Map from key to the first value seen.
  2. On each request: if the key isn’t stored yet, store the value.
  3. Always append the stored value for the key (which is the first one) to the output.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious first version answers each request by looking back: for request i, scan requests[0..i] from the start and return the value of the first request with the same key. It works because the first occurrence is, by definition, the result for that key, and an empty-string first value is handled correctly because nothing is tested for truthiness. It costs O(n²) time and O(1) extra space beyond the output. With all-distinct keys every scan runs all the way to i, so 100,000 requests take about 5 billion comparisons, and a real server would be rescanning its whole request history on every call.

The approach

The insight is that “first write wins” needs only one fact per key: the value its first request carried. Remember that fact in a hash map and every later request becomes a constant-time lookup instead of a scan. The invariant: after request i, store maps every key seen so far to the value from its first occurrence, and nothing else.

The reference keeps store = {} and out = []. For each key, value it tests membership with if key not in store and only then writes store[key] = value, so a retry can never overwrite the original. It then appends store[key], not value, to out: for a new key the two are the same, and for a replay it is the first result. Testing membership rather than truthiness matters, because a first value of "" must still win. One pass, one dict.

Walk through an example

The first example, one row per request: only a new key writes to store, and every row appends the stored value.

steprequestkey in store?store afterout after
1["k1", "a"]no: store "a"{k1: "a"}["a"]
2["k1", "b"]yes: ignore "b"{k1: "a"}["a", "a"]
3["k2", "c"]no: store "c"{k1: "a", k2: "c"}["a", "a", "c"]
4["k1", "d"]yes: ignore "d"{k1: "a", k2: "c"}["a", "a", "c", "a"]

Complexity

Hash map: O(n) time for n requests, since each does one average O(1) membership test and at most one insert (hashing a key costs its length), and O(u) space for u distinct keys plus O(n) for out. Brute force: O(n²) time, because each request rescans everything before it, and O(1) extra space.

Edge cases to test

  • An empty request list returns [].
  • A falsy first value, [["k", ""], ["k", "x"]], returns ["", ""]. A truthiness check such as if not store.get(key) treats "" as missing, overwrites it, and returns ["", "x"].
  • Interleaved keys, [["a", "1"], ["b", "2"], ["a", "9"], ["b", "8"]], return ["1", "2", "1", "2"]: each key keeps its own first result.
  • Keys are compared exactly, so [["k1", "a"], ["K1", "b"]] returns ["a", "b"]: two keys that differ only in case are two different operations.
  • An immediate replay with the same value, [["k", "v"], ["k", "v"]], returns ["v", "v"], and the second request must still be treated as a replay, not a new operation.

Common mistakes

  • Writing store[key] = value on every request. Last write wins, and the first example returns ["a", "b", "c", "d"].
  • Checking truthiness instead of membership (if not store.get(key)): an empty-string first value counts as missing and gets overwritten.
  • Storing the response only after executing, with no atomicity — a concurrent retry then double-executes.
  • No expiry on stored keys, so the store grows without bound.
  • Keying on the wrong thing (the whole payload, or a value that changes between retries).

Follow-ups an interviewer asks next

Two copies of the same request arrive at the same moment on two servers.
The check and the write must be one atomic step, or both copies see “not stored” and both execute. Use an insert-if-absent on a shared store, such as a unique constraint on the key column or Redis SET key value NX, and record the key as “in progress” before doing the work. The loser of the race then waits for the stored result or gets a conflict error instead of executing.
The same key arrives with a different payload.
This handler silently returns the old result, which hides a client bug: the key was reused for a different operation. Store a hash of the request body next to the key and compare it on every replay. A match returns the stored response; a mismatch returns an error instead of a response that belongs to another request.
The store grows without bound. How do you expire keys?
Give each entry a time-to-live longer than the longest window in which a client might retry, and delete entries older than that, with a TTL in Redis or a periodic cleanup job in a database. Do not evict by memory pressure alone, as an LRU cache would: a key evicted early lets a late retry execute a second time.
The server crashes after charging the card but before storing the result.
Then the retry finds no key and charges again. When the side effect is local, write the key, the effect and the stored response in one database transaction. When it is a call to another service, record the key as started before the call and pass the same idempotency key downstream, so that service deduplicates the retry too.

Where this shows up

Idempotency keys are what make payment and order APIs safe to retry: the network will make a client resend after a timeout, and this handler guarantees “charge once” even when the request arrives twice. Stripe-style idempotency keys are the reference implementation of this pattern.

Reference solution

Reference solution — Python
def process(requests):
    store = {}
    out = []
    for key, value in requests:
        if key not in store:
            store[key] = value        # first write wins
        out.append(store[key])        # replays return the first result
    return out


print(process([["k1","a"],["k1","b"],["k2","c"],["k1","d"]]))  # -> ['a', 'a', 'c', 'a']
Reference solution — TypeScript
function process(requests: string[][]): string[] {
  const store = new Map<string, string>();
  const out: string[] = [];
  for (const [key, value] of requests) {
    if (!store.has(key)) store.set(key, value);   // first write wins
    out.push(store.get(key)!);                     // replays return the first result
  }
  return out;
}

console.log(process([["k1","a"],["k1","b"],["k2","c"],["k1","d"]])); // -> ["a","a","c","a"]

Questions about Idempotency Key Handler

What is an idempotency key?

An idempotency key is a unique value, often a random UUID, that the client generates once per logical operation and sends with every attempt of that operation, usually in an Idempotency-Key HTTP header. The server stores the result of the first attempt under that key. When the same key arrives again, because the client retried after a timeout or a dropped connection, the server returns the stored result instead of performing the operation a second time. The key names the operation, so a retry must reuse it and a genuinely new operation must get a new one.

Why does the first result win instead of the latest one?

Because the first request is the one that actually ran. A retry is a copy of an operation that already happened, sent only because the client never saw the answer. If the payment was charged on the first attempt, the truthful response to every retry is the result of that charge. Letting a later request overwrite the stored result would either run the operation again or report something that never happened. In this challenge that means a later value for a known key is discarded, and every request with that key returns the first value.

What happens when two requests with the same idempotency key arrive at the same time?

With a plain lookup followed by a write, both requests can see that the key is missing and both execute, which is exactly the double charge the key was meant to prevent. A production handler makes the check and the claim one atomic operation, such as an insert that fails if the key already exists, and marks the key as in progress before doing the work. The second request then either waits for the first to finish and returns its result, or receives a conflict error and retries later.

Are PUT and DELETE already idempotent without a key?

By HTTP semantics, yes: GET, PUT and DELETE are among the methods defined as idempotent, meaning that repeating the request has the same effect on the server as sending it once. POST and PATCH are not, which is why idempotency keys are mostly used on POST endpoints that create payments or orders. Idempotent describes the effect, not the response: a second DELETE of the same resource can return 404 even though nothing more was deleted. An API can still accept keys on PUT and DELETE to return identical responses on retries.

Finished this one? 0 / 76 Challenges done
NextConfig Validator Next on the “Integrations & messy data” ladder · rung 3 of 10

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges