Skip to Content
Course content

201: Idempotency Keys for Safe Retries

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a junior developer who had just built a "Wallet Top-up" feature. The code looked clean, but during our staging test, we hit a classic snag. I triggered a request to add $50 to a user's account, but I intentionally simulated a network timeout on the response. The client-side code did exactly what it was programmed to do: it saw the timeout and automatically retried the request. The result? The user ended up with $100 in their account, and we had a massive data integrity headache on our hands. This is the "Double-Spend" problem, and it happens more often than you'd think in distributed systems.

In a perfect world, networks never flake and servers never hang. In the real world, you have to assume that any request you send might be executed by the server but fail to acknowledge the client. If the client retries that request, you can't just run the same logic again—unless that logic is idempotent. An operation is idempotent if performing it multiple times has the same effect as performing it once. Adding $50 to a balance is not idempotent; setting a balance to exactly $50 is.

The Strategy of the Idempotency Key

Since we can't always make our business logic inherently idempotent, we use an Idempotency Key. This is essentially a unique identifier (usually a UUID) generated by the client and sent in the request header. The server keeps a record of these keys. If it sees a key it has already processed, it doesn't execute the logic again; it simply returns the response it generated the first time.

I usually implement this using a dedicated table or a fast K/V store like Redis. The workflow looks like this: the server receives the request, checks if the key exists in the database, and if so, returns the cached result immediately. If it's a new key, it processes the request and stores the result along with the key before sending the response back.

// A simplified look at how I'd structure this in a PHP service
public function handleTopUp(Request $request) 
{
    $idempotencyKey = $request->header('X-Idempotency-Key');
    
    if (!$idempotencyKey) {
        throw new InvalidArgumentException("Idempotency key is required for this operation.");
    }

    // Check if we've already processed this specific request
    $existingRequest = $this->db->table('idempotency_keys')
        ->where('key', $idempotencyKey)
        ->first();

    if ($existingRequest) {
        // Return the exact same response we gave the first time
        return response()->json(
            json_decode($existingRequest->response_body), 
            $existingRequest->response_code
        );
    }

    // Actually perform the business logic
    $result = $this->walletService->addFunds($request->userId, $request->amount);

    // Store the result so retries don't trigger a second charge
    $this->db->table('idempotency_keys')->insert([
        'key' => $idempotencyKey,
        'response_body' => json_encode($result),
        'response_code' => 201,
        'created_at' => now()
    ]);

    return response()->json($result, 201);
}

Handling Race Conditions and Expiration

There is a subtle trap here: the "race condition." If a client sends two identical requests at the exact same millisecond, both might pass the "does this key exist?" check before either has a chance to write the key to the database. To stop this, you should use a unique constraint on the key column in your database. Let the database throw a duplicate key exception, and catch that to return the existing response.

You also don't want this table to grow infinitely. Idempotency keys aren't meant to be permanent archives; they are a safety net for retries. I typically set a TTL (Time To Live) of 24 to 48 hours. After that, it's safe to assume that any retry is actually a new, separate request or a bug in the client's retry logic that needs manual intervention rather than automatic recovery.




📋 Practical Task

Implement a Duplicate-Proof Subscription Activation

You are tasked with fixing a bug in a subscription system where users are being charged multiple times if they click the "Activate Subscription" button rapidly or if their browser retries a timed-out request.

Requirements:

  • Create a PHP function processSubscription($userId, $planId, $idempotencyKey).
  • Use a mock database (an associative array or a simple SQLite table) to track idempotency_keys.
  • The function must check if the $idempotencyKey has already been used.
  • If the key exists, return the stored response: ["status" => "success", "message" => "Subscription already active"].
  • If the key does not exist, simulate the subscription logic (e.g., a print statement saying "Charging credit card..."), save the key and the success response to your store, and then return the response.
  • Ensure that if the same key is passed twice, the "Charging credit card..." logic only executes once.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.