DEV Community

Cover image for How a Timeout Can Charge Your Customer Twice (And How to Stop It in Laravel)
Elias Alrgeai
Elias Alrgeai

Posted on Originally published at Medium

How a Timeout Can Charge Your Customer Twice (And How to Stop It in Laravel)

A customer attempts a payment. The request safely reaches your server, and the transaction is performed successfully. Then, while the response is being sent back to the client, the connection drops. The client never receives a success message. Instead, they see a timeout.

The client has no possible way of knowing if the charge went through or not, so they logically retry. This is where problems happen. Your server receives a second POST request for attempting the same payment, with no idea if it was successfully charged before or not.

Without correctly handling this scenario, the client gets charged twice.

What an Idempotency Key is

In this context, an idempotency key, usually a UUID, represents a specific payment intent. The client generates the idempotency key, stores it, and sends it along with the request to the server.

Idempotency-Key: 8f14e45f-ceea-4e97-9e39-1b2f3a4c5d6e
Enter fullscreen mode Exit fullscreen mode

The key is sent as a header on the request. If the client re-attempts the payment due to a timeout, it never creates a new one, it instead uses the same key. This way, the server can verify if a transaction has been processed before or not.

The Naive Approach and Why it Fails

Here is an example of a naive way of handling this scenario:

if (!IdempotencyKey::where('key', $key)->exists()) {
    $result = processCharge($request);
    IdempotencyKey::create(['key' => $key, 'response' => $result]);
}
Enter fullscreen mode Exit fullscreen mode

This implementation may look correct, but it has a massive flaw.

A server can handle multiple requests simultaneously. If two requests arrive around the same time, which is more common than expected, both could run exists() before either one gets a chance to save the key. This results in both requests being processed at the same time, double charging the customer.

The Fix: Atomic Locking

The check and save must happen as one process, not two separate steps. That's what Laravel's Cache::lock() is for.

// Block for 5 seconds to let the first request finish processing, then reuse its result
Cache::lock('idempotency:' . $key, 10)->block(5, function () use ($key, $request) {
    $existing = IdempotencyKey::where('key', $key)->first();

    if ($existing) {
        return $existing->response;
    }

    $result = processCharge($request);

    IdempotencyKey::create(['key' => $key, 'response' => $result]);

    return $result;
});
Enter fullscreen mode Exit fullscreen mode

Unlike in the naive implementation, Cache::lock() is used to ensure multiple requests cannot be processed simultaneously. Even if the difference is in milliseconds, whichever request comes first gets the lock, and the second request cannot be processed until the first one is complete, making double charging completely impossible.

Storing the Response, Not Just the Key

In the correct approach, notice that the response is stored along with the key. This is very critical because if the client retries, we want to give them the exact same response that was supposed to be sent back in the original request. From the client's view, the retry looks like a normal, successful request.

Expiration

Idempotency keys should never be stored forever. Once the retry window has passed, the key should be deleted to prevent unnecessary storage cluttering your database.

The majority of implementations store a TTL (time-to-live) with each key. For example, Stripe uses a TTL of 24 hours. Long enough for realistic retry scenarios, but short enough to ensure old keys don't pile up permanently.

A great way of implementing this system in Laravel is by using a scheduled job that scans the DB for old idempotency keys that aren't needed anymore and deletes them.

Wrapping Up

Idempotency keys exist to tell the server if a transaction has already been processed before or not. However, that isn't enough. Two requests can arrive at approximately the same time, double charging a client.

The check and save must happen atomically (locking out simultaneous requests), using a method such as Cache::lock(). It is also crucial to store the response along with the idempotency key, so when the client retries, they receive the same response they would have received with the original request.

Integrating this system correctly ensures that a timeout never leaves you guessing whether a transaction was successful or not.

Top comments (0)