Double-Tap Pay Button

MediumdesignAlso known as Idempotency Key Store

A food delivery app has a "Pay" button. People double-tap it, and on a weak connection the app retries a request whose reply was lost, even though the charge went through. To stop double charges, the app sends a random idempotency key with each payment attempt and reuses the same key for every retry of that attempt. The payment service remembers each key: the first request runs, a repeat gets the saved reply, and a repeat that arrives while the first is still running is told to wait.

This is the idempotency-key store from Idempotency and Retries, used in front of any write that must not happen twice (see Design a Payment System).

Your task

Write a class IdempotencyStore. Each key has at most one live record; a record made at time s is live while t < s + ttlMs and is treated as gone from s + ttlMs on.

  • constructor(ttlMs).
  • begin(key, fingerprint, t): a request arrives at time t. fingerprint summarizes the request body. Return:
    • "run" if the key has no live record. Create one (in progress, made at t); the caller now performs the payment.
    • "conflict" if the live record has a different fingerprint: the same key was reused for a different request.
    • "wait" if the live record has the same fingerprint and is still in progress.
    • the saved response if the live record has the same fingerprint and is finished.
  • finish(key, response, t): the payment finished. If the key has a live record that is in progress, save response on it and mark it finished. Otherwise do nothing.
  • release(key, t): the payment failed in a way that is safe to retry. If the key has a live record that is in progress, delete it. Otherwise do nothing.

Expiry counts from when the record was created by begin, whether or not it has finished.

class IdempotencyStore

Examples

Example 1
Input
new IdempotencyStore(60000)
.begin("k1", "pay 45 card 4242", 0)
.begin("k1", "pay 45 card 4242", 100)
.finish("k1", "201 paid #7", 250)
.begin("k1", "pay 45 card 4242", 300)
.begin("k1", "pay 99 card 4242", 400)
Output
[null, "run", "wait", null, "201 paid #7", "conflict"]
Why

The first tap creates the record and runs the payment. The second tap, 100 ms later, finds it still in progress: "wait", so the card is not charged twice. At 250 the payment finishes and the reply is saved. A retry at 300 gets that exact reply back without charging again. At 400 the same key arrives with a different amount, which is a bug in the client, so it is refused.

Example 2
Input
new IdempotencyStore(1000)
.begin("a", "x", 0)
.finish("a", "201 ok", 10)
.begin("a", "x", 999)
.begin("a", "x", 1000)
.begin("b", "y", 1000)
.release("b", 1005)
.begin("b", "y", 1006)
Output
[null, "run", null, "201 ok", "run", "run", null, "run"]
Why

Key "a" was made at 0 with a TTL of 1,000, so at 999 it still replays "201 ok", and at 1,000 it is gone and the request runs again. That is why the TTL must be longer than any client keeps retrying. Key "b" fails safely and is released, so the retry at 1,006 runs.

Constraints

  • 1 ≤ ttlMs ≤ 1,000,000,000
  • 0 ≤ t ≤ 1,000,000,000,000, and t never decreases from one call to the next
  • Keys and fingerprints are non-empty strings of up to 40 characters.
  • Every response starts with a three-digit status code, like "201 paid #7", so it never equals "run", "wait" or "conflict".
  • At most 2,000 calls per test
Start with brute force

Without a store, the server could look for an existing payment with the same amount and card in the last minute. That blocks legitimate repeat orders and still double-charges when the two requests arrive at the same moment, because neither finds the other's payment yet.

The key says exactly which requests are the same attempt, and the in-progress record closes the gap between "started" and "finished". In a real system, begin must be one atomic insert-if-absent in the shared store, or two servers can both see "no record" for the same double-tap.

Hints

0 of 4 shown

Walkthrough and solution

Unlocks when you pass every test. Stuck for a while? Looking is fine too, as long as you then close it and write the code yourself.

Ctrl+Enter runs

Run your code to see results here. Run checks the examples. Submit checks every test, including hidden edge cases.

Write code