diff --git a/app/Http/Middleware/IdempotencyMiddleware.php b/app/Http/Middleware/IdempotencyMiddleware.php new file mode 100644 index 0000000..45bbeee --- /dev/null +++ b/app/Http/Middleware/IdempotencyMiddleware.php @@ -0,0 +1,104 @@ +header('X-Idempotency-Key'); + + if (! is_string($idempotencyKey) || ! Str::isUuid($idempotencyKey)) { + return response()->json([ + 'message' => 'X-Idempotency-Key MUST be a valid UUID.', + ], Response::HTTP_BAD_REQUEST); + } + + $cacheKey = $this->cacheKey($request, $idempotencyKey); + $lockKey = $this->lockKey($request, $idempotencyKey); + + $lock = Cache::lock($lockKey, config('slots.idempotency_lock_seconds')); + if (! $lock->get()) { + return $this->waitForCachedResponse($cacheKey) ?? $this->inProgressResponse(); + } + + $response = $next($request); + + try { + $cached = Cache::get($cacheKey); + if (is_array($cached)) { + return $this->replayResponse($cached); + } + + if ($response instanceof JsonResponse) { + Cache::put($cacheKey, [ + 'status' => $response->getStatusCode(), + 'body' => $response->getData(true), + ], config('slots.idempotency_cache_ttl_seconds')); + } + + return $response; + } finally { + $lock->release(); + } + } + + private function waitForCachedResponse(string $cacheKey): ?JsonResponse + { + $deadline = microtime(true) + config('slots.idempotency_wait_seconds'); + + while (microtime(true) < $deadline) { + $cached = Cache::get($cacheKey); + if (is_array($cached) && isset($cached['body'], $cached['status'])) { + return $this->replayResponse($cached); + } + + usleep(100_000); + } + + return null; + } + + private function inProgressResponse(): ?JsonResponse + { + return response()->json([ + 'message' => 'Conflict. Retry with same idempotency key', + ], Response::HTTP_CONFLICT) + ->header('X-Retry-After', 1); + } + + /** + * @param array{status: int, body: string} $data + */ + private function replayResponse(array $data): JsonResponse + { + return response()->json($data['body'], (int) $data['status']); + } + + private function cacheKey(Request $request, string $idempotencyKey): string + { + return sprintf( + 'idempotency:response:%s:%s:%s', + strtoupper($request->method()), + $request->path(), + $idempotencyKey + ); + } + + private function lockKey(Request $request, string $idempotencyKey): string + { + return sprintf( + 'idempotency:lock:%s:%s:%s', + strtoupper($request->method()), + $request->path(), + $idempotencyKey + ); + } +}