Add idempotency handling for order booking creation using Redis to prevent duplicate requests.

This commit is contained in:
Jeppe Bundgaard
2026-03-18 13:42:47 +01:00
parent da2cbee987
commit 90acf50b55
+192 -2
View File
@@ -3,6 +3,7 @@
namespace routes;
use classes\authentication;
use classes\redis;
use Exception;
use modules\subusers\helpers\subusers_permission_node_key;
use objects\departments_o;
@@ -65,11 +66,29 @@ class orderBookingRoute
'pickup' => $pickup,
'items' => $items,
];
$fingerprint = $this->buildBookingCreationFingerprint($data);
$cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint);
if ($cachedBooking !== null) {
$response->success($cachedBooking->asArray());
}
if (!$this->reserveBookingCreationSlot($fingerprint)) {
$cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint);
if ($cachedBooking !== null) {
$response->success($cachedBooking->asArray());
}
$response->error('A similar booking request is already being processed. Please wait a moment and retry.', 429);
}
/**
* Create the object
*/
$order_bookings_o = new order_bookings_o();
$order_bookings_o->add($data);
try {
$order_bookings_o->add($data);
$this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id);
} catch (\Throwable $e) {
$this->clearBookingCreationSlot($fingerprint);
throw $e;
}
$response->success($order_bookings_o->asArray());
},
[
@@ -534,4 +553,175 @@ class orderBookingRoute
}
}
}
private function buildBookingCreationFingerprint(array $data): string
{
$payload = [
'customer_number' => (int)($data['customer_number'] ?? 0),
'department' => (int)($data['department'] ?? 0),
'reg_1' => (string)($data['reg_1'] ?? ''),
'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null),
'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null),
'datetime' => (string)($data['datetime'] ?? ''),
'note' => $this->normalizeNullableString($data['note'] ?? null),
'reference' => $this->normalizeNullableString($data['reference'] ?? null),
'po' => $this->normalizeNullableString($data['po'] ?? null),
'pickup' => (bool)($data['pickup'] ?? false),
'items' => $this->normalizeItemsForFingerprint((array)($data['items'] ?? [])),
];
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
return md5((string)microtime(true));
}
return hash('sha256', $encoded);
}
private function normalizeNullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
if (strtolower($normalized) === 'null') {
return null;
}
return $normalized;
}
private function normalizeItemsForFingerprint(array $items): array
{
$normalized = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$id = (int)($item['id'] ?? 0);
$quantity = (int)($item['quantity'] ?? 0);
if ($id < 1 || $quantity < 1) {
continue;
}
$normalized[] = [
'id' => $id,
'quantity' => $quantity,
];
}
usort($normalized, static function (array $a, array $b): int {
if ($a['id'] === $b['id']) {
return $a['quantity'] <=> $b['quantity'];
}
return $a['id'] <=> $b['id'];
});
return $normalized;
}
private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o
{
try {
$redis = $this->resolveRedisClient();
if ($redis === null) {
return null;
}
$id = (int)($redis->get($this->bookingIdempotencyResultKey($fingerprint)) ?? 0);
if ($id < 1) {
return null;
}
$booking = (new order_bookings_o())->select($id);
if (!$booking->exists()) {
return null;
}
return $booking;
} catch (\Throwable) {
return null;
}
}
private function reserveBookingCreationSlot(string $fingerprint): bool
{
try {
$redis = $this->resolveRedisClient();
if ($redis === null) {
return true;
}
return $redis->set_if_absent_with_expiration(
$this->bookingIdempotencyLockKey($fingerprint),
'1',
$this->bookingIdempotencyLockTtlSeconds()
);
} catch (\Throwable) {
// Fail open when Redis is unavailable to avoid blocking all bookings.
return true;
}
}
private function storeBookingIdempotencyResult(string $fingerprint, int $bookingId): void
{
try {
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->setEx(
$this->bookingIdempotencyResultKey($fingerprint),
(string)$bookingId,
$this->bookingIdempotencyResultTtlSeconds()
);
$redis->delete($this->bookingIdempotencyLockKey($fingerprint));
} catch (\Throwable) {
// Best effort only.
}
}
private function clearBookingCreationSlot(string $fingerprint): void
{
try {
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->delete($this->bookingIdempotencyLockKey($fingerprint));
} catch (\Throwable) {
// Best effort only.
}
}
private function resolveRedisClient(): ?redis
{
try {
if (defined('redis')) {
$instance = constant('redis');
if ($instance instanceof redis) {
return $instance;
}
}
return (new redis())->connect();
} catch (\Throwable) {
return null;
}
}
private function bookingIdempotencyLockKey(string $fingerprint): string
{
return 'order_booking:idempotency:lock:' . $fingerprint;
}
private function bookingIdempotencyResultKey(string $fingerprint): string
{
return 'order_booking:idempotency:result:' . $fingerprint;
}
private function bookingIdempotencyLockTtlSeconds(): int
{
return 30;
}
private function bookingIdempotencyResultTtlSeconds(): int
{
return 300;
}
}