I have a cancel order method that refunds the user.
However, using the API, if a user calls the endpoint twice (in a loop) for the same record, it refunds the user twice. If I try 3x Api calls at once, first 2 requests gets the refund, 3rd request doesn't.
public function cancelOrder($orderId) {
// First I tried to solve with cache,
// but it is not fast enough to catch the loop
if (Cache::has("api-canceling-$orderId")) {
return response()->json(['message' => "Already cancelling"], 403);
}
Cache::put("api-voiding-$labelId", true, 60);
// Here I get the transaction, and check if 'transaction->is_cancelled'.
// I thought cache will be faster but apparently not enough.
$transaction = Transaction::where('order_id', $orderId)
->where('user_id', auth()->user()->id)
->where('type', "Order Charge")
->firstOrFail();
if ($transaction->is_cancelled) {
return response()->json(['message' => "Order already cancelled"], 403);
}
// Here I do the api call to 3rd party service and wait for response
try {
$result = (new OrderCanceller)->cancel($orderId);
} catch (Exception $e) {
return response()->json(['message' => $e->getMessage()], 403);
}
$transaction->is_cancelled = true;
$transaction->save();
// This is the operation getting called twice.
Transaction::createCancelRefund($transaction);
return response()->json(['message' => 'Refund is made to your account']);
}
The createCancelRefund()
method looks like this:
public static function createCancelRefund($transaction) {
Transaction::create([
'user_id' => $transaction->user_id,
'credit_movement' => $transaction->credit_movement * -1,
'type' => "Order Refund"
]);
}
Things I have tried:
Wrapping everything inside cancelOrder()
method into DB::transaction({}, 5)
closure. (Also tried DB::beginTransaction()
approach)
Using ->lockForUpdate()
on $transaction = Transaction::where('order_id', $orderId)
... query.
Wrapping createCancelRefund()
content inside DB::transaction({}, 5)
, but I think as it's create()
it didn't help.
Tried using cache but it was not as fast.
Looked up to throttling but doesn't seem like it can prevent this (if I say 2 request/min, duplicate creation still occurs)
What is the proper way of preventing duplicate refund creations inside createCancelRefund()
?
Atomic Lock solved my problem.
Atomic locks allow for the manipulation of distributed locks without worrying about race conditions. For example, Laravel Forge uses atomic locks to ensure that only one remote task is being executed on a server at a time
public function cancelOrder($orderId) {
return Cache::lock("api-canceling-{$orderId}")->get(function () use ($orderId) {
$transaction = Transaction::where('order_id', $orderId)
->where('user_id', auth()->user()->id)
->where('type', "Order Charge")
->firstOrFail();
if ($transaction->is_cancelled) {
return response()->json(['message' => "Order already cancelled"], 403);
}
try {
$result = (new OrderCanceller)->cancel($orderId);
} catch (Exception $e) {
return response()->json(['message' => $e->getMessage()], 403);
}
$transaction->is_cancelled = true;
$transaction->save();
// This is the operation getting called twice.
Transaction::createCancelRefund($transaction);
return response()->json(['message' => 'Refund is made to your account']);
});
return response()->json(['message' => "Already cancelling"], 403);
}