├── .gitignore ├── LICENSE.md ├── ReadMe.md ├── composer.json └── src ├── Events └── IdempotencyAlertFired.php ├── IdempotencyServiceProvider.php ├── Logging ├── AlertDispatcher.php └── EventType.php ├── Middleware └── EnsureIdempotency.php ├── Telemetry ├── Drivers │ └── InspectorTelemetryDriver.php ├── TelemetryDriver.php └── TelemetryManager.php └── config └── idempotency.php /.gitignore: -------------------------------------------------------------------------------- 1 | ### Laravel template 2 | /vendor/ 3 | node_modules/ 4 | npm-debug.log 5 | yarn-error.log 6 | 7 | # Laravel 4 specific 8 | bootstrap/compiled.php 9 | app/storage/ 10 | 11 | # Laravel 5 & Lumen specific 12 | public/storage 13 | public/hot 14 | 15 | # Laravel 5 & Lumen specific with changed public path 16 | public_html/storage 17 | public_html/hot 18 | 19 | storage/*.key 20 | .env 21 | Homestead.yaml 22 | Homestead.json 23 | /.vagrant 24 | .phpunit.result.cache 25 | composer.lock 26 | 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2025 Paul Edward 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # Idempotency for Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/infinitypaul/idempotency-laravel.svg?style=flat-square)](https://packagist.org/packages/infinitypaul/idempotency-laravel) 4 | 5 | A production-ready Laravel middleware for implementing idempotency in your API requests. Safely retry API calls without worrying about duplicate processing. 6 | 7 | ## What Is Idempotency? 8 | 9 | Idempotency ensures that an API operation produces the same result regardless of how many times it is executed. This is critical for payment processing, order submissions, and other operations where duplicate execution could have unintended consequences. 10 | 11 | ## Features 12 | 13 | - **Robust Cache Mechanism**: Reliably stores and serves cached responses 14 | - **Lock-Based Concurrency Control**: Prevents race conditions with distributed locks 15 | - **Comprehensive Telemetry**: Track and monitor idempotency operations 16 | - **Alert System**: Get notified about suspicious activity 17 | - **Payload Validation**: Detect when the same key is used with different payloads 18 | - **Detailed Logging**: Easily debug idempotency issues 19 | 20 | ## Installation 21 | 22 | You can install the package via composer: 23 | 24 | ```bash 25 | composer require infinitypaul/idempotency-laravel 26 | ``` 27 | 28 | ## Configuration 29 | 30 | ```bash 31 | php artisan vendor:publish --provider="Infinitypaul\Idempotency\IdempotencyServiceProvider" 32 | ``` 33 | This will create a config/idempotency.php file with the following options: 34 | 35 | ```php 36 | return [ 37 | // Enable or disable idempotency functionality 38 | 'enabled' => env('IDEMPOTENCY_ENABLED', true), 39 | 40 | // HTTP methods that should be considered for idempotency 41 | 'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'], 42 | 43 | // How long to cache idempotent responses (in minutes) 44 | 'ttl' => env('IDEMPOTENCY_TTL', 1440), // 24 hours 45 | 46 | // Validation settings 47 | 'validation' => [ 48 | // Pattern to validate idempotency keys (UUID format by default) 49 | 'pattern' => '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', 50 | 51 | // Maximum response size to cache (in bytes) 52 | 'max_length' => env('IDEMPOTENCY_MAX_LENGTH', 10485760), // 10MB 53 | ], 54 | 55 | // Alert settings 56 | 'alert' => [ 57 | // Number of hits before sending an alert 58 | 'threshold' => env('IDEMPOTENCY_ALERT_THRESHOLD', 5), 59 | ], 60 | 61 | // Telemetry settings 62 | 'telemetry' => [ 63 | // Default telemetry driver 64 | 'driver' => env('IDEMPOTENCY_TELEMETRY_DRIVER', 'inspector'), 65 | 66 | // Custom driver class if using 'custom' driver 67 | 'custom_driver_class' => null, 68 | ], 69 | ]; 70 | ``` 71 | ## Usage 72 | Add the middleware to your routes or route groups in your routes/api.php file: 73 | ```php 74 | Route::middleware(['auth:api', \Infinitypaul\Idempotency\Middleware\EnsureIdempotency::class]) 75 | ->group(function () { 76 | Route::post('/payments', [PaymentController::class, 'store']); 77 | // Other routes... 78 | }); 79 | ``` 80 | ### Using With Requests 81 | To make an idempotent request, clients should include an Idempotency-Key header with a unique UUID: 82 | 83 | ```http request 84 | POST /api/payments HTTP/1.1 85 | Content-Type: application/json 86 | Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000 87 | 88 | { 89 | "amount": 1000, 90 | "currency": "USD", 91 | "description": "Order #1234" 92 | } 93 | ``` 94 | If the same idempotency key is used again with the same payload, the original response will be returned without re-executing the operation. 95 | 96 | ## Response Headers 97 | The middleware adds these headers to responses: 98 | 99 | - `Idempotency-Key`: The key used for the request 100 | - `Idempotency-Status`: Either `Original` (first request) or `Repeated` (cached response) 101 | 102 | ## Telemetry 103 | The package provides built-in telemetry through various service. The telemetry records: 104 | 105 | - Request processing time 106 | - Cache hits and misses 107 | - Lock acquisition time 108 | - Response sizes 109 | - Error rates 110 | 111 | ## Telemetry Drivers 112 | I intend to add more drivers in my free time 113 | 114 | - Inspector (https://inspector.dev/) 115 | 116 | ## Custom Driver 117 | To use a custom telemetry driver, implement the TelemetryDriver interface: 118 | 119 | ```php 120 | [ 136 | 'driver' => 'custom', 137 | 'custom_driver_class' => \App\Telemetry\CustomTelemetryDriver::class, 138 | ], 139 | ``` 140 | 141 | ## Advanced Usage 142 | ### Custom Events 143 | The package dispatches an events that you can listen for: 144 | 145 | ```php 146 | \Infinitypaul\Idempotency\Events\IdempotencyAlertFired::class 147 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "infinitypaul/idempotency-laravel", 3 | "description": "Elegant and production-ready idempotency middleware for Laravel APIs.", 4 | "type": "library", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "Infinitypaul\\Idempotency\\": "src/" 9 | } 10 | }, 11 | "extra": { 12 | "laravel": { 13 | "providers": [ 14 | "Infinitypaul\\Idempotency\\IdempotencyServiceProvider" 15 | ] 16 | } 17 | }, 18 | "authors": [ 19 | { 20 | "name": "infinitypaul", 21 | "email": "infinitypaul@live.com" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.1", 26 | "illuminate/support": "^9.0|^10.0|^11.0|^12.0", 27 | "illuminate/events": "^9.0|^10.0|^11.0|^12.0", 28 | "illuminate/http": "^9.0|^10.0|^11.0|^12.0", 29 | "inspector-apm/inspector-laravel": "^4.11" 30 | }, 31 | "minimum-stability": "dev", 32 | "prefer-stable": true 33 | } 34 | -------------------------------------------------------------------------------- /src/Events/IdempotencyAlertFired.php: -------------------------------------------------------------------------------- 1 | eventType = $eventType; 13 | $this->context = $context; 14 | } 15 | } -------------------------------------------------------------------------------- /src/IdempotencyServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ .'/config/idempotency.php', 'idempotency'); 12 | 13 | $this->app->singleton(TelemetryManager::class, function ($app) { 14 | return new TelemetryManager($app); 15 | }); 16 | } 17 | 18 | public function boot() 19 | { 20 | $this->publishes([ 21 | __DIR__ . '/config/idempotency.php' => config_path('idempotency.php') 22 | ], 'config'); 23 | } 24 | } -------------------------------------------------------------------------------- /src/Logging/AlertDispatcher.php: -------------------------------------------------------------------------------- 1 | shouldSendAlert($eventType, $context)) { 13 | return; 14 | } 15 | 16 | event(new IdempotencyAlertFired($eventType, $context)); 17 | } 18 | 19 | /** 20 | * Determine whether this alert should be sent based on cooldown. 21 | */ 22 | protected function shouldSendAlert($eventType, $context): bool 23 | { 24 | $hashKey = md5($eventType . ':' . json_encode($context)); 25 | $cacheKey = "idempotency:alert_sent:{$hashKey}"; 26 | 27 | if (Cache::has($cacheKey)) { 28 | return false; 29 | } 30 | 31 | $cooldown = config("idempotency.alerts.threshold", 60); 32 | Cache::put($cacheKey, true, now()->addMinutes($cooldown)); 33 | 34 | return true; 35 | } 36 | } -------------------------------------------------------------------------------- /src/Logging/EventType.php: -------------------------------------------------------------------------------- 1 | telemetryManager = $telemetryManager; 29 | } 30 | 31 | /** 32 | * Handle an incoming request. 33 | * 34 | * @param Request $request 35 | * @param Closure $next 36 | * @return mixed 37 | * @throws LockTimeoutException|Exception 38 | */ 39 | public function handle(Request $request, Closure $next): mixed 40 | { 41 | $this->startTime = microtime(true); 42 | $this->initializeTelemetry($request); 43 | 44 | if (!$this->isMethodApplicable($request)) { 45 | return $this->handleSkippedMethod($next, $request); 46 | } 47 | 48 | 49 | $idempotencyKey = $request->header('Idempotency-Key'); 50 | 51 | if (!$idempotencyKey) { 52 | return $this->handleMissingKey(); 53 | } 54 | 55 | 56 | if (!$this->isValidUuid($idempotencyKey)) { 57 | return $this->handleInvalidKey(); 58 | } 59 | 60 | 61 | $keys = $this->generateCacheKeys($idempotencyKey); 62 | 63 | // Check for cached response 64 | if (Cache::has($keys['response'])) { 65 | return $this->handleCachedResponse($keys, $idempotencyKey, $request); 66 | } 67 | 68 | return $this->handleNewRequest($keys, $idempotencyKey, $next, $request); 69 | } 70 | 71 | /** 72 | * Initialize telemetry for the request. 73 | * 74 | * @param Request $request 75 | * @return void 76 | */ 77 | private function initializeTelemetry(Request $request): void 78 | { 79 | $context = [ 80 | 'url' => $request->fullUrl(), 81 | 'method' => $request->method(), 82 | ]; 83 | 84 | $telemetry = $this->telemetryManager->driver(); 85 | $this->segment = $telemetry->startSegment('idempotency', 'Idempotency Middleware'); 86 | $telemetry->recordMetric('requests.total'); 87 | $telemetry->addSegmentContext($this->segment, 'request_info', $context); 88 | } 89 | 90 | /** 91 | * Check if the request method is applicable for idempotency. 92 | * 93 | * @param Request $request 94 | * @return bool 95 | */ 96 | private function isMethodApplicable(Request $request): bool 97 | { 98 | return in_array( 99 | $request->method(), 100 | config('idempotency.methods') 101 | ); 102 | } 103 | 104 | /** 105 | * Handle request with a method that doesn't require idempotency. 106 | * 107 | * @param Closure $next 108 | * @param Request $request 109 | * @return mixed 110 | */ 111 | private function handleSkippedMethod(Closure $next, Request $request): mixed 112 | { 113 | $telemetry = $this->telemetryManager->driver(); 114 | $telemetry->addSegmentContext($this->segment, 'skipped', true); 115 | $telemetry->addSegmentContext($this->segment, 'reason', 'method_not_applicable'); 116 | $telemetry->endSegment($this->segment); 117 | $telemetry->recordMetric('requests.skipped'); 118 | 119 | return $next($request); 120 | } 121 | 122 | /** 123 | * Handle request with missing idempotency key. 124 | * 125 | * @return JsonResponse 126 | */ 127 | private function handleMissingKey(): JsonResponse 128 | { 129 | $telemetry = $this->telemetryManager->driver(); 130 | $telemetry->addSegmentContext($this->segment, 'error', 'missing_key'); 131 | $telemetry->endSegment($this->segment); 132 | $telemetry->recordMetric('errors.missing_key'); 133 | return response()->json(['error' => 'Missing Idempotency-Key header'], 400); 134 | } 135 | 136 | /** 137 | * Check if the idempotency key is a valid UUID. 138 | * 139 | * @param string $key 140 | * @return bool 141 | */ 142 | private function isValidUuid(string $key): bool 143 | { 144 | return preg_match(config('idempotency.validation.pattern'), $key); 145 | } 146 | 147 | /** 148 | * Handle request with invalid idempotency key format. 149 | * 150 | * @return JsonResponse 151 | */ 152 | private function handleInvalidKey(): JsonResponse 153 | { 154 | $telemetry = $this->telemetryManager->driver(); 155 | $telemetry->addSegmentContext($this->segment, 'error', 'invalid_key_format'); 156 | $telemetry->endSegment($this->segment); 157 | $telemetry->recordMetric('errors.invalid_key'); 158 | 159 | return response()->json(['error' => 'Invalid Idempotency-Key format. Must be a valid UUID.'], 400); 160 | } 161 | 162 | /** 163 | * Generate cache keys for the idempotency key. 164 | * 165 | * @param string $idempotencyKey 166 | * @return array 167 | */ 168 | private function generateCacheKeys(string $idempotencyKey): array 169 | { 170 | return [ 171 | 'response' => "idempotency:{$idempotencyKey}:response", 172 | 'processing' => "idempotency:{$idempotencyKey}:processing", 173 | 'metadata' => "idempotency:{$idempotencyKey}:metadata", 174 | 'lock' => "idempotency_lock:{$idempotencyKey}", 175 | 'payload_hash' => "idempotency:{$idempotencyKey}:payload_hash", 176 | ]; 177 | } 178 | 179 | /** 180 | * Handle request with a cached response. 181 | * 182 | * @param array $keys 183 | * @param string $idempotencyKey 184 | * @param Request $request 185 | * @return mixed 186 | */ 187 | private function handleCachedResponse(array $keys, string $idempotencyKey, Request $request): mixed 188 | { 189 | $storedHash = Cache::get($keys['payload_hash']); 190 | $currentHash = md5(json_encode($request->all())); 191 | 192 | if ($storedHash !== $currentHash) { 193 | $telemetry = $this->telemetryManager->driver(); 194 | $telemetry->recordMetric('errors.payload_mismatch', 1); 195 | $telemetry->addSegmentContext($this->segment, 'error', 'payload_mismatch'); 196 | $telemetry->endSegment($this->segment); 197 | 198 | return response()->json([ 199 | 'error' => 'Idempotency-Key reused with different request payload', 200 | ], 422); 201 | } 202 | 203 | $telemetry = $this->telemetryManager->driver(); 204 | $telemetry->recordMetric('cache.hit'); 205 | 206 | $duration = microtime(true) - $this->startTime; 207 | $telemetry->recordTiming('duplicate_handling_time', $duration * 1000); 208 | 209 | $metadata = $this->updateMetadata($keys['metadata']); 210 | 211 | $telemetry->addSegmentContext($this->segment, 'status', 'duplicate'); 212 | $telemetry->addSegmentContext($this->segment, 'hit_count', $metadata['hit_count']); 213 | $telemetry->addSegmentContext($this->segment, 'original_request_age', now()->timestamp - $metadata['created_at']); 214 | $telemetry->addSegmentContext($this->segment, 'handling_time_ms', $duration * 1000); 215 | $telemetry->endSegment($this->segment); 216 | 217 | $this->checkAlertThreshold($metadata, $idempotencyKey, $request); 218 | 219 | $response = Cache::get($keys['response']); 220 | $this->addIdempotencyHeaders($response, $idempotencyKey, 'Repeated'); 221 | 222 | return $response; 223 | } 224 | 225 | /** 226 | * Update metadata for a cached response. 227 | * 228 | * @param string $metadataKey 229 | * @return array 230 | */ 231 | private function updateMetadata(string $metadataKey): array 232 | { 233 | $metadata = Cache::get($metadataKey, [ 234 | 'created_at' => now()->subMinutes(1)->timestamp, 235 | 'hit_count' => 0, 236 | ]); 237 | 238 | $metadata['hit_count']++; 239 | $metadata['last_hit_at'] = now()->timestamp; 240 | 241 | Cache::put( 242 | $metadataKey, 243 | $metadata, 244 | now()->addMinutes(config('idempotency.ttl')) 245 | ); 246 | 247 | return $metadata; 248 | } 249 | 250 | /** 251 | * Check if the hit count exceeds the alert threshold. 252 | * 253 | * @param array $metadata 254 | * @param string $idempotencyKey 255 | * @param Request $request 256 | * @return void 257 | */ 258 | private function checkAlertThreshold(array $metadata, string $idempotencyKey, Request $request): void 259 | { 260 | if ($metadata['hit_count'] >= config('idempotency.alert.threshold')) { 261 | (new AlertDispatcher())->dispatch( 262 | EventType::RESPONSE_DUPLICATE, 263 | [ 264 | 'idempotency_key' => $idempotencyKey, 265 | 'meta_data' => $metadata, 266 | 'context' => [ 267 | 'url' => $request->fullUrl(), 268 | 'method' => $request->method(), 269 | ], 270 | ] 271 | ); 272 | } 273 | } 274 | 275 | /** 276 | * Add idempotency headers to the response. 277 | * 278 | * @param $response 279 | * @param string $idempotencyKey 280 | * @param string $status 281 | * @return void 282 | */ 283 | private function addIdempotencyHeaders($response, string $idempotencyKey, string $status): void 284 | { 285 | $response->headers->set('Idempotency-Key', $idempotencyKey); 286 | $response->headers->set('Idempotency-Status', $status); 287 | } 288 | 289 | /** 290 | * Handle a new request that needs to be processed. 291 | * 292 | * @param array $keys 293 | * @param string $idempotencyKey 294 | * @param Closure $next 295 | * @param Request $request 296 | * @return mixed 297 | * @throws LockTimeoutException 298 | */ 299 | private function handleNewRequest(array $keys, string $idempotencyKey, Closure $next, Request $request): mixed 300 | { 301 | $lock = Cache::lock($keys['lock'], self::LOCK_TIMEOUT_SECONDS); 302 | $lockAcquired = false; 303 | $lockAcquisitionStart = microtime(true); 304 | 305 | try { 306 | $lockAcquired = $lock->block(self::LOCK_WAIT_SECONDS); 307 | $lockAcquisitionTime = microtime(true) - $lockAcquisitionStart; 308 | 309 | $telemetry = $this->telemetryManager->driver(); 310 | $telemetry->recordTiming('lock_acquisition_time', $lockAcquisitionTime * 1000); 311 | 312 | if (!$lockAcquired) { 313 | return $this->handleLockAcquisitionFailure( 314 | $keys, 315 | $idempotencyKey, 316 | $request, 317 | $lockAcquisitionTime 318 | ); 319 | } 320 | 321 | 322 | $telemetry->recordMetric('lock.successful_acquisition', 1); 323 | 324 | return $this->processRequest($keys, $idempotencyKey, $next, $request); 325 | 326 | } catch (Exception $e) { 327 | $this->logException($idempotencyKey, $e); 328 | throw $e; 329 | } finally { 330 | Cache::forget($keys['processing']); 331 | if ($lockAcquired) { 332 | $lock->release(); 333 | } 334 | } 335 | } 336 | 337 | /** 338 | * Handle failure to acquire a lock. 339 | * 340 | * @param array $keys 341 | * @param string $idempotencyKey 342 | * @param Request $request 343 | * @param float $lockAcquisitionTime 344 | * @return mixed 345 | */ 346 | private function handleLockAcquisitionFailure( 347 | array $keys, 348 | string $idempotencyKey, 349 | Request $request, 350 | float $lockAcquisitionTime 351 | ): mixed 352 | { 353 | $telemetry = $this->telemetryManager->driver(); 354 | $telemetry->recordMetric('lock.failed_acquisition', 1); 355 | $telemetry->addSegmentContext($this->segment, 'lock_wait_time_ms', $lockAcquisitionTime * 1000); 356 | 357 | // Check if a response was cached while waiting for the lock 358 | if (Cache::has($keys['response'])) { 359 | return $this->handleLateCachedResponse($keys, $idempotencyKey); 360 | } 361 | 362 | // Check if another request is currently processing this key 363 | if (Cache::has($keys['processing'])) { 364 | return $this->handleConcurrentConflict($idempotencyKey, $request); 365 | } 366 | 367 | // Lock inconsistency (failed to acquire, but no cache or processing flag) 368 | return $this->handleLockInconsistency($idempotencyKey, $request); 369 | } 370 | 371 | /** 372 | * Handle a late cache hit after failing to acquire a lock. 373 | * 374 | * @param array $keys 375 | * @param string $idempotencyKey 376 | * @return Response 377 | */ 378 | private function handleLateCachedResponse(array $keys, string $idempotencyKey): Response 379 | { 380 | $telemetry = $this->telemetryManager->driver(); 381 | $telemetry->recordMetric('cache.late_hit', 1); 382 | $telemetry->addSegmentContext($this->segment, 'status', 'late_duplicate'); 383 | $telemetry->endSegment($this->segment); 384 | 385 | $response = Cache::get($keys['response']); 386 | $this->addIdempotencyHeaders($response, $idempotencyKey, 'Repeated'); 387 | 388 | return $response; 389 | } 390 | 391 | /** 392 | * Handle concurrent processing of the same idempotency key. 393 | * 394 | * @param string $idempotencyKey 395 | * @param Request $request 396 | * @return JsonResponse 397 | */ 398 | private function handleConcurrentConflict(string $idempotencyKey, Request $request): JsonResponse 399 | { 400 | $telemetry = $this->telemetryManager->driver(); 401 | $telemetry->recordMetric('responses.concurrent_conflict', 1); 402 | $telemetry->addSegmentContext($this->segment, 'status', 'concurrent_conflict'); 403 | $telemetry->endSegment($this->segment); 404 | 405 | (new AlertDispatcher())->dispatch( 406 | EventType::CONCURRENT_CONFLICT, 407 | [ 408 | 'idempotency_key' => $idempotencyKey, 409 | 'endpoint' => $request->path(), 410 | ] 411 | ); 412 | 413 | return response()->json([ 414 | 'error' => 'A request with this Idempotency-Key is currently being processed', 415 | ], 409); 416 | } 417 | 418 | /** 419 | * Handle a lock inconsistency. 420 | * 421 | * @param string $idempotencyKey 422 | * @param Request $request 423 | * @return JsonResponse 424 | */ 425 | private function handleLockInconsistency(string $idempotencyKey, Request $request): JsonResponse 426 | { 427 | $telemetry = $this->telemetryManager->driver(); 428 | $telemetry->recordMetric('errors.lock_inconsistency', 1); 429 | $telemetry->addSegmentContext($this->segment, 'status', 'lock_inconsistency'); 430 | $telemetry->endSegment($this->segment); 431 | 432 | (new AlertDispatcher())->dispatch( 433 | EventType::LOCK_INCONSISTENCY, 434 | [ 435 | 'idempotency_key' => $idempotencyKey, 436 | 'endpoint' => $request->path(), 437 | ] 438 | ); 439 | 440 | return response()->json([ 441 | 'error' => 'Could not process request. Please try again.', 442 | ], 500); 443 | } 444 | 445 | /** 446 | * Process a new request. 447 | * 448 | * @param array $keys 449 | * @param string $idempotencyKey 450 | * @param Closure $next 451 | * @param Request $request 452 | * @return mixed 453 | */ 454 | private function processRequest(array $keys, string $idempotencyKey, Closure $next, Request $request): mixed 455 | { 456 | Cache::put($keys['processing'], true, now()->addMinutes(self::PROCESSING_TTL_MINUTES)); 457 | 458 | $payloadHash = md5(json_encode($request->all())); 459 | Cache::put($keys['payload_hash'], $payloadHash, now()->addMinutes(config('idempotency.ttl'))); 460 | 461 | $this->setRequestMetadata($keys['metadata'], $request); 462 | 463 | $telemetry = $this->telemetryManager->driver(); 464 | $telemetry->recordMetric('requests.original', 1); 465 | 466 | $processingStart = microtime(true); 467 | $response = $next($request); 468 | $processingTime = microtime(true) - $processingStart; 469 | 470 | $telemetry->recordTiming('request_processing_time', $processingTime * 1000); 471 | $this->addIdempotencyHeaders($response, $idempotencyKey, 'Original'); 472 | 473 | $statusCode = $response->getStatusCode(); 474 | if ($statusCode < 500 && $statusCode !== 429) { 475 | $this->cacheResponse($keys['response'], $response, $request); 476 | 477 | if ($statusCode >= 400) { 478 | $telemetry->recordMetric('responses.error.cached', 1); 479 | } 480 | } else { 481 | $telemetry->recordMetric('responses.error.not_cached', 1); 482 | } 483 | 484 | $telemetry->addSegmentContext($this->segment, 'status', 'original'); 485 | $telemetry->addSegmentContext($this->segment, 'processing_time_ms', $processingTime * 1000); 486 | $telemetry->endSegment($this->segment); 487 | 488 | return $response; 489 | } 490 | 491 | /** 492 | * Set metadata for a new request. 493 | * 494 | * @param string $metadataKey 495 | * @param Request $request 496 | * @return void 497 | */ 498 | private function setRequestMetadata(string $metadataKey, Request $request): void 499 | { 500 | Cache::put( 501 | $metadataKey, 502 | [ 503 | 'created_at' => now()->timestamp, 504 | 'hit_count' => 0, 505 | 'endpoint' => $request->path(), 506 | 'user_id' => auth()->check() ? $request->user()->id : null, 507 | 'client_ip' => $request->ip(), 508 | ], 509 | now()->addMinutes(config('idempotency.ttl')) 510 | ); 511 | } 512 | 513 | 514 | 515 | /** 516 | * Cache a successful response. 517 | * 518 | * @param string $cacheKey 519 | * @param Response $response 520 | * @param Request $request 521 | * @return void 522 | */ 523 | private function cacheResponse(string $cacheKey, $response, Request $request): void 524 | { 525 | $cacheableResponse = $this->prepareCacheableResponse($response); 526 | 527 | try { 528 | Cache::put( 529 | $cacheKey, 530 | $cacheableResponse, 531 | now()->addMinutes(config('idempotency.ttl')) 532 | ); 533 | 534 | $responseSize = strlen($response->getContent()); 535 | $telemetry = $this->telemetryManager->driver(); 536 | $telemetry->recordSize('response_size', $responseSize); 537 | 538 | $this->checkResponseSizeWarning($responseSize, $request); 539 | } catch (\Exception $e) { 540 | $this->telemetryManager->driver()->recordMetric('errors.cache_failed', 1); 541 | 542 | (new AlertDispatcher())->dispatch( 543 | EventType::EXCEPTION_THROWN, 544 | [ 545 | 'message' => 'Failed to cache response: ' . $e->getMessage(), 546 | 'exception' => get_class($e), 547 | ] 548 | ); 549 | } 550 | } 551 | 552 | /** 553 | * Create a serializable version of the response 554 | * 555 | * @param mixed $response 556 | * @return mixed 557 | */ 558 | private function prepareCacheableResponse($response) 559 | { 560 | if (!method_exists($response, 'getStatusCode') || 561 | !method_exists($response, 'getContent')) { 562 | return $response; 563 | } 564 | 565 | $cacheableResponse = clone $response; 566 | 567 | 568 | $content = json_decode($response->getContent(), true); 569 | 570 | 571 | if (is_array($content)) { 572 | if (isset($content['exception'])) { 573 | unset($content['exception']); 574 | } 575 | 576 | 577 | array_walk_recursive($content, function (&$value, $key) { 578 | if ($key === 'exception') { 579 | $value = is_array($value) ? null : '[Filtered Exception]'; 580 | } 581 | }); 582 | 583 | $cacheableResponse->setContent(json_encode($content)); 584 | } 585 | 586 | 587 | if (property_exists($cacheableResponse, 'exception')) { 588 | unset($cacheableResponse->exception); 589 | } 590 | 591 | return $cacheableResponse; 592 | } 593 | 594 | 595 | /** 596 | * Check if response size exceeds the warning threshold. 597 | * 598 | * @param int $responseSize 599 | * @param Request $request 600 | * @return void 601 | */ 602 | private function checkResponseSizeWarning(int $responseSize, Request $request): void 603 | { 604 | if ($responseSize > config('idempotency.validation.max_length')) { 605 | (new AlertDispatcher())->dispatch( 606 | EventType::SIZE_WARNING, 607 | [ 608 | 'size_bytes' => $responseSize, 609 | 'size_kb' => round($responseSize / 1024, 2).' KB', 610 | 'context' => [ 611 | 'url' => $request->fullUrl(), 612 | 'method' => $request->method(), 613 | ], 614 | ] 615 | ); 616 | } 617 | } 618 | 619 | /** 620 | * Log an exception. 621 | * 622 | * @param string $idempotencyKey 623 | * @param Exception $exception 624 | * @return void 625 | */ 626 | private function logException(string $idempotencyKey, Exception $exception): void 627 | { 628 | (new AlertDispatcher())->dispatch( 629 | EventType::EXCEPTION_THROWN, 630 | [ 631 | 'idempotency_key' => $idempotencyKey, 632 | 'message' => $exception->getMessage(), 633 | 'exception' => get_class($exception), 634 | ] 635 | ); 636 | } 637 | } -------------------------------------------------------------------------------- /src/Telemetry/Drivers/InspectorTelemetryDriver.php: -------------------------------------------------------------------------------- 1 | addContext($key, $value); 23 | } 24 | } 25 | 26 | public function endSegment($segment) 27 | { 28 | if ($segment) { 29 | $segment->end(); 30 | } 31 | } 32 | 33 | public function recordMetric($name, $value = 1) 34 | { 35 | if (Inspector::isRecording()) { 36 | Inspector::startSegment('metric', $name)->addContext('value', $value)->end(); 37 | } 38 | } 39 | 40 | public function recordTiming($name, $milliseconds) 41 | { 42 | if (Inspector::isRecording()) { 43 | Inspector::startSegment('timing', $name)->addContext('value_ms', $milliseconds)->end(); 44 | } 45 | } 46 | 47 | public function recordSize($name, $bytes) 48 | { 49 | if (Inspector::isRecording()) { 50 | Inspector::startSegment('size', $name)->addContext('bytes', $bytes)->end(); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/Telemetry/TelemetryDriver.php: -------------------------------------------------------------------------------- 1 | telemetryEnabled = config('idempotency.enabled'); 17 | } 18 | 19 | public function getDefaultDriver() 20 | { 21 | return config('idempotency.telemetry.driver'); 22 | } 23 | 24 | public function createInspectorDriver() 25 | { 26 | return new InspectorTelemetryDriver(); 27 | } 28 | 29 | public function createCustomDriver(){ 30 | $class = config('idempotency.telemetry.custom_driver_class'); 31 | 32 | if(!$class || !class_exists($class)){ 33 | throw new InvalidArgumentException("Custom telemetry driver class [$class] not found."); 34 | } 35 | 36 | $driver = app($class); 37 | 38 | if(!$driver instanceof TelemetryDriver){ 39 | throw new InvalidArgumentException("Custom telemetry driver must implement TelemetryDriver interface."); 40 | } 41 | 42 | return $driver; 43 | } 44 | 45 | public function driver($driver = null) 46 | { 47 | if (! $this->telemetryEnabled) { 48 | return new class { 49 | public function __call($method, $args) { return null; } 50 | }; 51 | } 52 | 53 | return parent::driver($driver); 54 | } 55 | 56 | public function __call($method, $parameters) 57 | { 58 | if(!$this->telemetryEnabled){ 59 | return null; 60 | } 61 | 62 | return $this->driver()->$method(...$parameters); 63 | } 64 | } -------------------------------------------------------------------------------- /src/config/idempotency.php: -------------------------------------------------------------------------------- 1 | ['POST', 'PUT', 'PATCH', 'DELETE'], 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Cache Time-to-Live (TTL) 29 | |-------------------------------------------------------------------------- 30 | | 31 | | The number of minutes to store idempotency responses in the cache. 32 | | This determines how long a client can use the same idempotency key 33 | | to receive the cached response. 34 | | 35 | */ 36 | 'ttl' => env('IDEMPOTENCY_TTL', 60), 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Alert Threshold 41 | |-------------------------------------------------------------------------- 42 | | 43 | | The number of times a single idempotency key can be reused before 44 | | triggering an alert. This helps detect potential replay attacks 45 | | or integration issues with clients. 46 | | 47 | */ 48 | 'alert_threshold' => env('IDEMPOTENCY_ALERT_THRESHOLD', 5), 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Response Size Warning 53 | |-------------------------------------------------------------------------- 54 | | 55 | | The maximum size (in bytes) of a response before triggering a warning. 56 | | Large cached responses can consume significant memory. 57 | | Default is 100KB (102,400 bytes). 58 | | 59 | */ 60 | 'size_warning' => env('IDEMPOTENCY_SIZE_WARNING', 1024 * 100), 61 | 62 | 63 | /* 64 | |-------------------------------------------------------------------------- 65 | | Lock Timeout 66 | |-------------------------------------------------------------------------- 67 | | 68 | | The maximum time (in seconds) to hold a lock while processing a request. 69 | | This prevents deadlocks if processing unexpectedly hangs. 70 | | 71 | */ 72 | 'lock_timeout' => env('IDEMPOTENCY_LOCK_TIMEOUT', 30), 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Lock Wait Time 77 | |-------------------------------------------------------------------------- 78 | | 79 | | The maximum time (in seconds) to wait to acquire a lock. 80 | | This determines how long to wait for concurrent requests with the 81 | | same idempotency key to finish processing. 82 | | 83 | */ 84 | 'lock_wait' => env('IDEMPOTENCY_LOCK_WAIT', 5), 85 | 86 | /* 87 | |-------------------------------------------------------------------------- 88 | | Processing TTL 89 | |-------------------------------------------------------------------------- 90 | | 91 | | The number of minutes to keep the processing flag for an idempotency key. 92 | | This helps detect and resolve orphaned processing states. 93 | | 94 | */ 95 | 'processing_ttl' => env('IDEMPOTENCY_PROCESSING_TTL', 5), 96 | 97 | /* 98 | |-------------------------------------------------------------------------- 99 | | Enabled 100 | |-------------------------------------------------------------------------- 101 | | 102 | | Global switch to enable/disable idempotency functionality. 103 | | Useful for bypassing idempotency in certain environments like testing. 104 | | 105 | */ 106 | 'enabled' => env('IDEMPOTENCY_ENABLED', false), 107 | 108 | /* 109 | |-------------------------------------------------------------------------- 110 | | Header Name 111 | |-------------------------------------------------------------------------- 112 | | 113 | | The HTTP header name to use for idempotency keys. 114 | | Default follows the standard practice of using 'Idempotency-Key'. 115 | | 116 | */ 117 | 'header_name' => env('IDEMPOTENCY_HEADER_NAME', 'Idempotency-Key'), 118 | 119 | 120 | /* 121 | |-------------------------------------------------------------------------- 122 | | Telemetry 123 | |-------------------------------------------------------------------------- 124 | | 125 | | Configuration for telemetry features. 126 | | 127 | */ 128 | 'telemetry' => [ 129 | // Enable or disable telemetry for idempotency operations 130 | 'enabled' => env('IDEMPOTENCY_TELEMETRY_ENABLED', true), 131 | 132 | // Default driver to use for telemetry 133 | 'driver' => env('IDEMPOTENCY_TELEMETRY_DRIVER', 'null'), 134 | 135 | // Available telemetry drivers and their configurations 136 | 'custom_driver_class' => null, 137 | ], 138 | 139 | /* 140 | |-------------------------------------------------------------------------- 141 | | Alerts 142 | |-------------------------------------------------------------------------- 143 | | 144 | | Configuration for alerting features. 145 | | 146 | */ 147 | 'alerts' => [ 148 | // Alert threshold 149 | 'threshold' => env('IDEMPOTENCY_ALERTS_THRESHOLD', 60), 150 | 151 | ], 152 | 153 | /* 154 | |-------------------------------------------------------------------------- 155 | | Validation 156 | |-------------------------------------------------------------------------- 157 | | 158 | | Configuration for idempotency key validation. 159 | | 160 | */ 161 | 'validation' => [ 162 | // The pattern to validate idempotency keys against 163 | // Default is a UUID pattern 164 | 'pattern' => env('IDEMPOTENCY_KEY_PATTERN', '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'), 165 | 166 | // Maximum length for idempotency keys 167 | 'max_length' => env('IDEMPOTENCY_KEY_MAX_LENGTH', 255), 168 | ], 169 | ]; --------------------------------------------------------------------------------